Compare commits
14 Commits
911d6babc5
...
cf1aba3676
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf1aba3676 | ||
|
|
d720467338 | ||
|
|
8efcbd4af4 | ||
|
|
76518a01a1 | ||
|
|
caad76ba95 | ||
|
|
bb9f225fd7 | ||
|
|
cfd472016e | ||
|
|
963b360a81 | ||
|
|
9f96b07ebd | ||
|
|
4da5b25a5c | ||
|
|
fa9ad9981c | ||
|
|
5c0652b395 | ||
|
|
71d7649db6 | ||
|
|
0def3e2e49 |
|
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
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.ktor3)
|
||||
@ -132,6 +131,9 @@ kotlin {
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.dns.sd.kt)
|
||||
|
||||
// Millennium UI
|
||||
implementation(libs.millennium)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||
@ -164,12 +166,6 @@ kotlin {
|
||||
// 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
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
@ -184,8 +180,6 @@ kotlin {
|
||||
|
||||
implementation(libs.semver)
|
||||
|
||||
implementation(libs.material.kolor)
|
||||
|
||||
// crypto
|
||||
implementation(libs.cryptography.core)
|
||||
implementation(libs.cryptography.provider.optimal)
|
||||
@ -206,7 +200,6 @@ kotlin {
|
||||
|
||||
// Shimmer effect
|
||||
implementation(libs.compose.shimmer)
|
||||
implementation(libs.compose.placeholder.material3)
|
||||
|
||||
// DLNA
|
||||
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.stream.StreamInfoItem
|
||||
|
||||
|
||||
actual class NewPipeService {
|
||||
actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
@ -47,8 +46,7 @@ actual class NewPipeService {
|
||||
url = item.url,
|
||||
uploader = item.uploaderName,
|
||||
durationMs = item.duration,
|
||||
thumbnailUrl = item.thumbnails.first().url,
|
||||
)
|
||||
thumbnailUrl = item.thumbnails.first().url)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@ -67,8 +65,7 @@ actual class NewPipeService {
|
||||
url = stream.content,
|
||||
codec = stream.codec,
|
||||
container = stream.format!!.suffix,
|
||||
bitrate = stream.bitrate,
|
||||
)
|
||||
bitrate = stream.bitrate)
|
||||
} + buildList {
|
||||
if (extractor.dashMpdUrl.isNotBlank()) add(
|
||||
AudioStream.Lossy(
|
||||
@ -90,7 +87,6 @@ actual class NewPipeService {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val videoStreams = extractor.videoStreams
|
||||
.filter { stream -> stream.isUrl && stream.format != null }
|
||||
.map { stream ->
|
||||
@ -98,8 +94,7 @@ actual class NewPipeService {
|
||||
url = stream.content,
|
||||
codec = stream.codec,
|
||||
bitrate = stream.bitrate,
|
||||
container = stream.format!!.suffix,
|
||||
)
|
||||
container = stream.format!!.suffix)
|
||||
}
|
||||
VideoInfo(
|
||||
id = id,
|
||||
|
||||
@ -45,7 +45,6 @@ actual val platformModules = module {
|
||||
collectionPlaybackHelper = get(),
|
||||
audioPlayerQueue = get(),
|
||||
settingsRepository = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
blacklistRepository = get())
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,14 +35,12 @@ import androidx.core.content.ContextCompat
|
||||
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* result is picked up by discovery/advertising retry loops */ }
|
||||
ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ }
|
||||
|
||||
return {
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) {
|
||||
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
}
|
||||
|
||||
@ -53,8 +53,7 @@ class MediaBrowseHelper(
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) {
|
||||
private val blacklistRepository: BlacklistRepository) {
|
||||
|
||||
companion object {
|
||||
const val MEDIA_ID_ROOT = "root"
|
||||
@ -253,8 +252,7 @@ class MediaBrowseHelper(
|
||||
.setIsPlayable(false)
|
||||
.build()
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build())
|
||||
}
|
||||
|
||||
private suspend fun buildBrowseItems(): List<MediaItem> {
|
||||
@ -390,8 +388,7 @@ class MediaBrowseHelper(
|
||||
.setIsBrowsable(true)
|
||||
.build()
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build())
|
||||
}
|
||||
|
||||
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
||||
@ -475,8 +472,7 @@ class MediaBrowseHelper(
|
||||
.setIsPlayable(false)
|
||||
.build()
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build())
|
||||
}
|
||||
|
||||
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
||||
|
||||
@ -32,8 +32,7 @@ import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
class AndroidLocalMediaDiscoveryService(
|
||||
private val context: Context,
|
||||
) : LocalMediaDiscoveryService, KoinComponent {
|
||||
private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
|
||||
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
||||
|
||||
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
|
||||
@ -59,8 +58,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
MediaStore.Audio.Media.ARTIST,
|
||||
MediaStore.Audio.Media.ALBUM,
|
||||
MediaStore.Audio.Media.DURATION,
|
||||
MediaStore.Audio.Media.MIME_TYPE,
|
||||
) + if (Build.VERSION.SDK_INT >= 29) {
|
||||
MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) {
|
||||
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
||||
} else {
|
||||
emptyArray()
|
||||
@ -131,8 +129,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
artists = listOfNotNull(artist),
|
||||
durationMs = durationMs,
|
||||
album = album,
|
||||
coverBytes = null,
|
||||
)
|
||||
coverBytes = null)
|
||||
|
||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
||||
acceptedRows += 1
|
||||
@ -150,8 +147,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
LocalMediaFolder(
|
||||
path = 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()) }
|
||||
|
||||
@ -172,8 +168,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
|
||||
override fun observeChanges(
|
||||
roots: List<String>,
|
||||
onChanged: LocalMediaChangeCallback,
|
||||
): LocalMediaObservation? {
|
||||
onChanged: LocalMediaChangeCallback): LocalMediaObservation? {
|
||||
logger.i { "observeChanges: start roots=${roots.size}" }
|
||||
if (!hasReadPermission()) {
|
||||
logger.w { "observeChanges: missing media permission, observer not registered" }
|
||||
@ -190,8 +185,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
context.contentResolver.registerContentObserver(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
true,
|
||||
observer,
|
||||
)
|
||||
observer)
|
||||
|
||||
logger.i { "observeChanges: observer registered" }
|
||||
|
||||
@ -257,8 +251,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
artists = emptyList(),
|
||||
durationMs = 0L,
|
||||
album = null,
|
||||
coverBytes = null,
|
||||
)
|
||||
coverBytes = null)
|
||||
|
||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
||||
}
|
||||
@ -269,8 +262,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
LocalMediaFolder(
|
||||
path = 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()) }
|
||||
|
||||
@ -292,8 +284,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||
)
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS))
|
||||
|
||||
return (configured + defaults)
|
||||
.map { it.absoluteFile }
|
||||
@ -320,8 +311,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
|
||||
private fun mergeFolders(
|
||||
mediaStoreFolders: List<LocalMediaFolder>,
|
||||
fallbackFolders: List<LocalMediaFolder>,
|
||||
): List<LocalMediaFolder> {
|
||||
fallbackFolders: List<LocalMediaFolder>): List<LocalMediaFolder> {
|
||||
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
||||
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
||||
|
||||
@ -342,8 +332,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
LocalMediaFolder(
|
||||
path = 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()) }
|
||||
}
|
||||
@ -357,7 +346,6 @@ class AndroidLocalMediaDiscoveryService(
|
||||
"wav",
|
||||
"ogg",
|
||||
"opus",
|
||||
"wma",
|
||||
)
|
||||
"wma")
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,19 +42,16 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState {
|
||||
|
||||
var isGranted by remember {
|
||||
mutableStateOf(
|
||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
contract = ActivityResultContracts.RequestPermission()) { granted ->
|
||||
isGranted = granted ||
|
||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
return LocalMediaPermissionState(
|
||||
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>
|
||||
<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_section_plugins">Plugins</string>
|
||||
<string name="settings_section_language_region">Language and Region</string>
|
||||
<string name="settings_section_appearance">Appearance</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_desktop">Desktop</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_save">Save</string>
|
||||
@ -43,6 +50,14 @@
|
||||
<string name="settings_theme_dark">Dark</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_subtitle_current">Current palette: %1$s</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_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_current">Current folder: %1$s</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_sleep_timer">Sleep timer</string>
|
||||
<string name="player_track_details">Track details</string>
|
||||
<string name="player_play_content_description">Play</string>
|
||||
<string name="player_pause_content_description">Pause</string>
|
||||
<string name="player_add_to_queue_content_description">Add to queue</string>
|
||||
|
||||
<string name="update_action_ignore">Ignore</string>
|
||||
<string name="update_action_update">Update</string>
|
||||
<string name="app_update_dialog_title">Spotube %1$s is available</string>
|
||||
<string name="app_update_dialog_no_notes">See what\'s new in this release on the Spotube downloads page.</string>
|
||||
<string name="app_update_dialog_no_notes">See what’s new in this release on the Spotube downloads page.</string>
|
||||
<string name="plugin_update_dialog_title">%1$s %2$s is available</string>
|
||||
<string name="plugin_update_dialog_current_version">Currently installed: %1$s</string>
|
||||
<string name="plugin_update_dialog_no_notes">See what\'s new in this update from the plugin\'s page.</string>
|
||||
<string name="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>
|
||||
|
||||
@ -24,30 +24,36 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import dev.krtirtho.spotube.core.DebugConfig
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
|
||||
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.theming.SpotubeTheme
|
||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
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.AppUpdateService
|
||||
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
|
||||
import dev.krtirtho.spotube.modules.update.PluginUpdateService
|
||||
import dev.krtirtho.spotube.modules.webview.WebViewScreen
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||
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.User
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlin.time.Clock
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.navigation3.koinEntryProvider
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
@ -77,7 +84,7 @@ interface NavigationItem {
|
||||
|
||||
private val tabs = listOf<NavigationItem>(
|
||||
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(
|
||||
@ -91,7 +98,7 @@ val sidebarTabs = tabs + listOf(
|
||||
LibraryTab.Playlists
|
||||
),
|
||||
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(
|
||||
@ -108,27 +115,23 @@ val sidebarTabs = tabs + listOf(
|
||||
Iconsax.IconsaxDirectboxReceive,
|
||||
Routes.Library,
|
||||
LibraryTab.Downloads
|
||||
),
|
||||
)
|
||||
)
|
||||
),
|
||||
NavigationItem.Group(
|
||||
"Connect",
|
||||
items = listOf(
|
||||
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(
|
||||
NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library),
|
||||
NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings)
|
||||
)
|
||||
|
||||
@OptIn(
|
||||
KoinExperimentalAPI::class,
|
||||
ExperimentalCoroutinesApi::class,
|
||||
ExperimentalSharedTransitionApi::class
|
||||
)
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@Composable
|
||||
fun App(
|
||||
content: @Composable () -> Unit = {}
|
||||
@ -140,24 +143,68 @@ fun App(
|
||||
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||
|
||||
val welcomeRepository: WelcomeRepository = koinInject()
|
||||
val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle()
|
||||
|
||||
var showSupportDialog by remember { mutableStateOf(false) }
|
||||
var supportDialogIsReminder by remember { mutableStateOf(false) }
|
||||
|
||||
// Nudge the user to support the project at most once every seven days, unless they opted
|
||||
// out in Settings. The first eligible launch after onboarding only records the baseline, so
|
||||
// the prompt never appears right after the onboarding donation page.
|
||||
LaunchedEffect(showOnboarding) {
|
||||
if (showOnboarding) return@LaunchedEffect
|
||||
// Read the persisted value rather than the collected state, whose initial value is the
|
||||
// default (0) until DataStore has emitted once.
|
||||
val current = settingsRepository.getCurrentSettings()
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
// Development override: show the reminder on every launch, regardless of timing or the
|
||||
// "Support reminders" toggle.
|
||||
if (DebugConfig.FORCE_SUPPORT_REMINDER) {
|
||||
supportDialogIsReminder = true
|
||||
showSupportDialog = true
|
||||
settingsRepository.updateSettings(
|
||||
current.copy(lastSupportReminderEpochMs = now))
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (!current.supportRemindersEnabled) return@LaunchedEffect
|
||||
val last = current.lastSupportReminderEpochMs
|
||||
if (last == 0L) {
|
||||
settingsRepository.updateSettings(
|
||||
current.copy(lastSupportReminderEpochMs = now))
|
||||
} else if (now - last >= SupportReminderIntervalMillis) {
|
||||
supportDialogIsReminder = true
|
||||
showSupportDialog = true
|
||||
settingsRepository.updateSettings(
|
||||
current.copy(lastSupportReminderEpochMs = now))
|
||||
}
|
||||
}
|
||||
|
||||
// The app always renders the shell + NavDisplay; onboarding is just the start destination.
|
||||
// When it is completed the destination flips to Home and the navigation state is rebuilt.
|
||||
val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home
|
||||
|
||||
val navigationState = rememberNavigationState(
|
||||
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||
startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||
)
|
||||
val navigator = remember {
|
||||
val navigator = remember(navigationState) {
|
||||
Navigator(navigationState)
|
||||
}
|
||||
|
||||
SpotubeTheme(settings = userSettings) {
|
||||
val baseUITheme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
|
||||
CompositionLocalProvider(LocalSupportPrompt provides {
|
||||
supportDialogIsReminder = false
|
||||
showSupportDialog = true
|
||||
}) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
val currentRoute =
|
||||
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppShell(navigator, navigationState) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
LocalSharedTransitionScope provides this@SharedTransitionLayout,
|
||||
LocalSharedTransitionScope provides this@SharedTransitionLayout
|
||||
) {
|
||||
Column {
|
||||
NavDisplay(
|
||||
@ -184,8 +231,7 @@ fun App(
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||
appUpdateService.dismissUpdate()
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
availablePluginUpdate?.let { update ->
|
||||
PluginUpdateDialog(
|
||||
@ -195,9 +241,12 @@ fun App(
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
||||
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 kotlin.time.Duration
|
||||
|
||||
|
||||
interface AudioPlayerInterface {
|
||||
// // Playback
|
||||
suspend fun play()
|
||||
|
||||
@ -30,8 +30,7 @@ data class MediaItem(
|
||||
val duration: Duration,
|
||||
val coverURL: String,
|
||||
val url: String,
|
||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||
)
|
||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE)
|
||||
|
||||
enum class LoopState {
|
||||
NONE, ONE, ALL;
|
||||
@ -60,8 +59,7 @@ sealed interface QueueEntry {
|
||||
val track: MetadataTrack,
|
||||
override val url: String,
|
||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||
override val addedBy: String = "",
|
||||
) : QueueEntry
|
||||
override val addedBy: String = "") : QueueEntry
|
||||
|
||||
@Serializable
|
||||
@SerialName("local")
|
||||
@ -72,8 +70,7 @@ sealed interface QueueEntry {
|
||||
val album: String?,
|
||||
val coverBytes: ByteArray?,
|
||||
override val url: String,
|
||||
override val addedBy: String = "",
|
||||
) : QueueEntry {
|
||||
override val addedBy: String = "") : QueueEntry {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
@ -111,14 +108,12 @@ sealed interface QueueCollectionEntry {
|
||||
@Serializable
|
||||
@SerialName("playlist")
|
||||
data class Playlist(
|
||||
override val id: String,
|
||||
) : QueueCollectionEntry
|
||||
override val id: String) : QueueCollectionEntry
|
||||
|
||||
@Serializable
|
||||
@SerialName("album")
|
||||
data class Album(
|
||||
override val id: String,
|
||||
) : QueueCollectionEntry
|
||||
override val id: String) : QueueCollectionEntry
|
||||
|
||||
@Serializable
|
||||
@SerialName("saved_tracks")
|
||||
@ -132,5 +127,4 @@ data class PersistedQueueState(
|
||||
val entries: List<QueueEntry>,
|
||||
val currentIndex: Int,
|
||||
val currentCollectionEntry: QueueCollectionEntry? = null,
|
||||
val collectionHistory: List<QueueCollectionEntry> = emptyList(),
|
||||
)
|
||||
val collectionHistory: List<QueueCollectionEntry> = emptyList())
|
||||
|
||||
@ -29,8 +29,7 @@ interface AudioPlayerQueue {
|
||||
entries: List<QueueEntry>,
|
||||
autoPlay: Boolean = true,
|
||||
startPosition: Int = 0,
|
||||
collectionEntry: QueueCollectionEntry? = null,
|
||||
)
|
||||
collectionEntry: QueueCollectionEntry? = null)
|
||||
suspend fun addToQueue(entry: QueueEntry)
|
||||
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
|
||||
suspend fun addAllAfterCurrent(entries: List<QueueEntry>)
|
||||
|
||||
@ -45,8 +45,7 @@ class DeviceAudioPlayerQueue(
|
||||
private val settingsProvider: SettingsProvider,
|
||||
private val repository: QueueStateRepository,
|
||||
private val pluginProvider: PluginProvider,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) : AudioPlayerQueue, KoinComponent {
|
||||
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||
|
||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||
|
||||
@ -60,8 +59,7 @@ class DeviceAudioPlayerQueue(
|
||||
|
||||
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
||||
audioPlayer.playlistFlow,
|
||||
entryByMediaUrl,
|
||||
) { playlist, knownEntries ->
|
||||
entryByMediaUrl) { playlist, knownEntries ->
|
||||
playlist.map { mediaItem ->
|
||||
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
||||
}
|
||||
@ -69,8 +67,7 @@ class DeviceAudioPlayerQueue(
|
||||
|
||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
entryByMediaUrl,
|
||||
) { mediaItem, knownEntries ->
|
||||
entryByMediaUrl) { mediaItem, knownEntries ->
|
||||
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
@ -107,8 +104,7 @@ class DeviceAudioPlayerQueue(
|
||||
combine(
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
audioPlayer.playlistFlow,
|
||||
audioPlayer.loopStateFlow,
|
||||
) { currentMedia, playlist, loopState ->
|
||||
audioPlayer.loopStateFlow) { currentMedia, playlist, loopState ->
|
||||
Triple(currentMedia, playlist, loopState)
|
||||
}.collect { (currentMedia, playlist, loopState) ->
|
||||
if (currentMedia == null || playlist.isEmpty()) return@collect
|
||||
@ -129,8 +125,7 @@ class DeviceAudioPlayerQueue(
|
||||
entries: List<QueueEntry>,
|
||||
autoPlay: Boolean,
|
||||
startPosition: Int,
|
||||
collectionEntry: QueueCollectionEntry?,
|
||||
) {
|
||||
collectionEntry: QueueCollectionEntry?) {
|
||||
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
||||
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
||||
if (mediaItems.isEmpty()) {
|
||||
@ -436,8 +431,7 @@ class DeviceAudioPlayerQueue(
|
||||
entries = queueEntries,
|
||||
currentIndex = normalizedCurrentIndex,
|
||||
currentCollectionEntry = currentCollectionEntryState.value,
|
||||
collectionHistory = collectionHistoryState.value,
|
||||
)
|
||||
collectionHistory = collectionHistoryState.value)
|
||||
)
|
||||
}
|
||||
|
||||
@ -471,8 +465,7 @@ class DeviceAudioPlayerQueue(
|
||||
duration = track.durationMs.milliseconds,
|
||||
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
|
||||
url = resolvedUrl,
|
||||
protocol = protocol,
|
||||
)
|
||||
protocol = protocol)
|
||||
}
|
||||
|
||||
is QueueEntry.LocalTrack -> {
|
||||
@ -483,8 +476,7 @@ class DeviceAudioPlayerQueue(
|
||||
album = album.orEmpty(),
|
||||
duration = duration.milliseconds,
|
||||
coverURL = "",
|
||||
url = url,
|
||||
)
|
||||
url = url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -496,8 +488,7 @@ class DeviceAudioPlayerQueue(
|
||||
duration = duration.inWholeMilliseconds,
|
||||
album = album.ifBlank { null },
|
||||
coverBytes = null,
|
||||
url = url,
|
||||
)
|
||||
url = url)
|
||||
}
|
||||
|
||||
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.stringPreferencesKey
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
import okio.SYSTEM
|
||||
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
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 {
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() }
|
||||
@ -61,6 +76,18 @@ class Database(val paths: Paths) {
|
||||
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 {
|
||||
|
||||
@ -86,6 +86,8 @@ import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||
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.dsl.bind
|
||||
import org.koin.core.module.dsl.createdAtStart
|
||||
@ -141,6 +143,10 @@ val sharedModules = module {
|
||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||
viewModelOf(::JamSettingsViewModel)
|
||||
|
||||
// Welcome / Onboarding
|
||||
singleOf(::WelcomeRepository)
|
||||
viewModelOf(::WelcomeViewModel)
|
||||
|
||||
// Updates
|
||||
single { AppUpdateService(get()) } withOptions { createdAtStart() }
|
||||
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
|
||||
@ -162,8 +168,7 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Saved Tracks
|
||||
@ -177,8 +182,7 @@ val sharedModules = module {
|
||||
libraryRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Artist
|
||||
@ -193,8 +197,7 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Blacklist
|
||||
@ -206,8 +209,7 @@ val sharedModules = module {
|
||||
JamViewModel(
|
||||
jamRoomService = get(),
|
||||
shareService = get(),
|
||||
settingsProvider = get(),
|
||||
)
|
||||
settingsProvider = get())
|
||||
}
|
||||
|
||||
// Album
|
||||
@ -223,8 +225,7 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Lyrics
|
||||
@ -238,8 +239,7 @@ val sharedModules = module {
|
||||
savedTracksRepository = get(),
|
||||
artistRepository = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
blacklistRepository = get())
|
||||
}
|
||||
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
|
||||
single {
|
||||
@ -247,8 +247,7 @@ val sharedModules = module {
|
||||
pluginManager = get(),
|
||||
audioPlayerQueue = get(),
|
||||
matchedTracksRepository = get(),
|
||||
settingsRepository = get(),
|
||||
)
|
||||
settingsRepository = get())
|
||||
}
|
||||
singleOf(::CacheManager)
|
||||
singleOf(::AlternativeTracksRepository)
|
||||
|
||||
@ -34,8 +34,7 @@ import kotlin.time.Clock
|
||||
|
||||
class DiscordRpcService(
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val settingsProvider: SettingsProvider,
|
||||
) : KoinComponent, AutoCloseable {
|
||||
private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable {
|
||||
|
||||
private val logger by injectLogger<DiscordRpcService>()
|
||||
|
||||
@ -61,8 +60,7 @@ class DiscordRpcService(
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
audioPlayer.positionFlow,
|
||||
audioPlayer.durationFlow,
|
||||
) { settings, playerState, mediaItem, position, duration ->
|
||||
audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration ->
|
||||
DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration)
|
||||
}.collect { state ->
|
||||
handleStateChange(state)
|
||||
@ -86,8 +84,7 @@ class DiscordRpcService(
|
||||
album = mediaItem.album,
|
||||
coverUrl = mediaItem.coverURL,
|
||||
positionMs = state.position.inWholeMilliseconds,
|
||||
durationMs = state.duration.inWholeMilliseconds,
|
||||
)
|
||||
durationMs = state.duration.inWholeMilliseconds)
|
||||
}
|
||||
PlayerState.PAUSED -> {
|
||||
val mediaItem = state.mediaItem ?: return
|
||||
@ -98,8 +95,7 @@ class DiscordRpcService(
|
||||
album = mediaItem.album,
|
||||
coverUrl = mediaItem.coverURL,
|
||||
positionMs = state.position.inWholeMilliseconds,
|
||||
durationMs = state.duration.inWholeMilliseconds,
|
||||
)
|
||||
durationMs = state.duration.inWholeMilliseconds)
|
||||
}
|
||||
PlayerState.IDLE, PlayerState.COMPLETED -> {
|
||||
clearPresence()
|
||||
@ -158,8 +154,7 @@ class DiscordRpcService(
|
||||
album: String,
|
||||
coverUrl: String,
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
) {
|
||||
durationMs: Long) {
|
||||
val client = rpcClient ?: return
|
||||
try {
|
||||
client.updatePresence(
|
||||
@ -168,8 +163,7 @@ class DiscordRpcService(
|
||||
album = album,
|
||||
coverUrl = coverUrl,
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
durationMs = durationMs)
|
||||
} catch (e: Exception) {
|
||||
rpcClient = null
|
||||
connectionFailed = true
|
||||
@ -210,6 +204,5 @@ class DiscordRpcService(
|
||||
val playerState: PlayerState,
|
||||
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||
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 host: String,
|
||||
val port: Int,
|
||||
val deviceId: String,
|
||||
) {
|
||||
val deviceId: String) {
|
||||
val key: String get() = "$name$type".replace(".", "")
|
||||
}
|
||||
|
||||
@ -55,8 +54,7 @@ class DeviceDiscoveryService {
|
||||
type = event.service.type,
|
||||
host = event.service.host,
|
||||
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)
|
||||
}
|
||||
|
||||
@ -66,8 +64,7 @@ class DeviceDiscoveryService {
|
||||
type = event.service.type,
|
||||
host = event.service.host,
|
||||
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)
|
||||
}
|
||||
|
||||
@ -77,8 +74,7 @@ class DeviceDiscoveryService {
|
||||
type = event.service.type,
|
||||
host = event.service.host,
|
||||
port = event.service.port,
|
||||
deviceId = "",
|
||||
)
|
||||
deviceId = "")
|
||||
DiscoveryState.Removed(device = device)
|
||||
}
|
||||
}
|
||||
@ -88,8 +84,7 @@ class DeviceDiscoveryService {
|
||||
name: String,
|
||||
port: Int,
|
||||
deviceId: String,
|
||||
registerTimeoutMs: Long = 5_000,
|
||||
): NetService {
|
||||
registerTimeoutMs: Long = 5_000): NetService {
|
||||
val service = createService(name, port, deviceId)
|
||||
service.register(timeoutInMs = registerTimeoutMs)
|
||||
return service
|
||||
@ -98,11 +93,9 @@ class DeviceDiscoveryService {
|
||||
fun createService(
|
||||
name: String,
|
||||
port: Int,
|
||||
deviceId: String,
|
||||
): NetService = createNetService(
|
||||
deviceId: String): NetService = createNetService(
|
||||
type = SERVICE_TYPE,
|
||||
name = name,
|
||||
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,
|
||||
/** Host's live play state — late-joining guests start with it. */
|
||||
val isPlaying: Boolean = false,
|
||||
) : JamMessage()
|
||||
val isPlaying: Boolean = false) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("playbackCommand")
|
||||
@ -57,22 +56,19 @@ sealed class JamMessage {
|
||||
@SerialName("suggestTrack")
|
||||
data class SuggestTrack(
|
||||
val mediaItem: JamMediaItem,
|
||||
val addedBy: String = "",
|
||||
) : JamMessage()
|
||||
val addedBy: String = "") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("suggestPlaylist")
|
||||
data class SuggestPlaylist(
|
||||
val tracks: List<JamMediaItem>,
|
||||
val addedBy: String = "",
|
||||
) : JamMessage()
|
||||
val addedBy: String = "") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("kick")
|
||||
data class Kick(
|
||||
val participantId: String,
|
||||
val reason: String = "kicked",
|
||||
) : JamMessage()
|
||||
val reason: String = "kicked") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("leave")
|
||||
@ -104,8 +100,7 @@ data class JamPresence(
|
||||
val clientId: String,
|
||||
val displayName: String,
|
||||
val isHost: Boolean,
|
||||
val left: Boolean = false,
|
||||
)
|
||||
val left: Boolean = false)
|
||||
|
||||
@Serializable
|
||||
data class JamMediaItem(
|
||||
@ -117,8 +112,7 @@ data class JamMediaItem(
|
||||
val durationMs: Long,
|
||||
val coverUrl: String,
|
||||
val protocol: String,
|
||||
val addedBy: String = "",
|
||||
) {
|
||||
val addedBy: String = "") {
|
||||
companion object {
|
||||
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
||||
is QueueEntry.StreamingTrack -> JamMediaItem(
|
||||
@ -132,8 +126,7 @@ data class JamMediaItem(
|
||||
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
.orEmpty(),
|
||||
protocol = entry.protocol.name,
|
||||
addedBy = entry.addedBy,
|
||||
)
|
||||
addedBy = entry.addedBy)
|
||||
|
||||
is QueueEntry.LocalTrack -> JamMediaItem(
|
||||
url = entry.url,
|
||||
@ -144,8 +137,7 @@ data class JamMediaItem(
|
||||
durationMs = entry.duration,
|
||||
coverUrl = "",
|
||||
protocol = "PROGRESSIVE",
|
||||
addedBy = entry.addedBy,
|
||||
)
|
||||
addedBy = entry.addedBy)
|
||||
}
|
||||
|
||||
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
|
||||
@ -158,8 +150,7 @@ data class JamMediaItem(
|
||||
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
.orEmpty(),
|
||||
protocol = "PROGRESSIVE",
|
||||
)
|
||||
protocol = "PROGRESSIVE")
|
||||
|
||||
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
|
||||
url = item.url,
|
||||
@ -168,8 +159,7 @@ data class JamMediaItem(
|
||||
album = item.album,
|
||||
durationMs = item.duration.inWholeMilliseconds,
|
||||
coverUrl = item.coverURL,
|
||||
protocol = item.protocol.name,
|
||||
)
|
||||
protocol = item.protocol.name)
|
||||
|
||||
fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
|
||||
title = item.title,
|
||||
@ -179,8 +169,7 @@ data class JamMediaItem(
|
||||
coverURL = item.coverUrl,
|
||||
url = item.url,
|
||||
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(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val isHost: Boolean,
|
||||
)
|
||||
val isHost: Boolean)
|
||||
|
||||
@Serializable
|
||||
enum class JamRole {
|
||||
|
||||
@ -30,10 +30,8 @@ import com.ditchoom.mqtt.controlpacket.TopicName
|
||||
import com.ditchoom.mqtt.controlpacket.WillConfig
|
||||
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
|
||||
import dev.krtirtho.spotube.modules.settings.JamBroker
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -47,6 +45,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
|
||||
@ -114,8 +113,7 @@ class JamRoomClient : KoinComponent {
|
||||
code: String,
|
||||
clientId: String,
|
||||
displayName: String,
|
||||
isHost: Boolean,
|
||||
): Result<Unit> {
|
||||
isHost: Boolean): Result<Unit> {
|
||||
disconnect()
|
||||
if (broker.host.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("No jam broker configured"))
|
||||
@ -126,8 +124,7 @@ class JamRoomClient : KoinComponent {
|
||||
clientId = clientId,
|
||||
displayName = displayName,
|
||||
isHost = isHost,
|
||||
left = false,
|
||||
)
|
||||
left = false)
|
||||
|
||||
val mqtt = startClient(broker, clientId)
|
||||
client = mqtt
|
||||
@ -167,8 +164,7 @@ class JamRoomClient : KoinComponent {
|
||||
publishJson(
|
||||
presenceTopic(code, presence.clientId),
|
||||
json.encodeToString(JamPresence.serializer(), presence),
|
||||
retain = true,
|
||||
)
|
||||
retain = true)
|
||||
}
|
||||
|
||||
/** Marks us as the host in presence (host takeover). */
|
||||
@ -186,8 +182,7 @@ class JamRoomClient : KoinComponent {
|
||||
publishJson(
|
||||
presenceTopic(code, presence.clientId),
|
||||
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
|
||||
retain = true,
|
||||
)
|
||||
retain = true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -208,26 +203,22 @@ class JamRoomClient : KoinComponent {
|
||||
host = broker.host,
|
||||
port = broker.port,
|
||||
tlsEnabled = broker.useTls,
|
||||
connectionTimeout = broker.connectionTimeoutSeconds.seconds,
|
||||
)
|
||||
connectionTimeout = broker.connectionTimeoutSeconds.seconds)
|
||||
val code = roomCode ?: "unset"
|
||||
val will = WillConfig.Enabled(
|
||||
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
|
||||
payload = json.encodeToString(
|
||||
JamPresence.serializer(),
|
||||
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true),
|
||||
).toReadBuffer(Charset.UTF8),
|
||||
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8),
|
||||
qos = QualityOfService.AT_LEAST_ONCE,
|
||||
retain = true,
|
||||
)
|
||||
retain = true)
|
||||
val request = ConnectionRequest(
|
||||
clientId = clientId,
|
||||
keepAliveSeconds = broker.keepAliveSeconds,
|
||||
cleanStart = true,
|
||||
userName = broker.username,
|
||||
password = broker.password,
|
||||
will = will,
|
||||
)
|
||||
will = will)
|
||||
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
|
||||
val brokerRef = persistence.addBroker(connection, request)
|
||||
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
|
||||
@ -265,8 +256,7 @@ class JamRoomClient : KoinComponent {
|
||||
val operation = mqtt.subscribe(
|
||||
roomFilter(code),
|
||||
OpaquePublishPayloadCodec,
|
||||
QualityOfService.AT_LEAST_ONCE,
|
||||
) { publish, payload ->
|
||||
QualityOfService.AT_LEAST_ONCE) { publish, payload ->
|
||||
route(publish.topic.toString(), payload)
|
||||
}
|
||||
runCatching { operation.subAck.await() }
|
||||
@ -309,8 +299,7 @@ class JamRoomClient : KoinComponent {
|
||||
topicName = topic,
|
||||
qos = QualityOfService.AT_LEAST_ONCE,
|
||||
payload = payload.toReadBuffer(Charset.UTF8),
|
||||
retain = retain,
|
||||
)
|
||||
retain = retain)
|
||||
}.onFailure { e ->
|
||||
log.w(e) { "Failed to publish to $topic" }
|
||||
}
|
||||
|
||||
@ -59,15 +59,13 @@ private data class PlaybackBroadcast(
|
||||
val queue: List<QueueEntry>,
|
||||
val current: QueueEntry?,
|
||||
val shuffle: Boolean,
|
||||
val playerState: PlayerState,
|
||||
)
|
||||
val playerState: PlayerState)
|
||||
|
||||
class JamRoomService(
|
||||
private val jamClient: JamRoomClient,
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
private val settingsRepository: SettingsRepository) {
|
||||
private val log = Logger.withTag("JamRoomService")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@ -171,8 +169,7 @@ class JamRoomService(
|
||||
code = code,
|
||||
clientId = localClientId,
|
||||
displayName = name,
|
||||
isHost = true,
|
||||
).map {
|
||||
isHost = true).map {
|
||||
_role.value = JamRole.Host
|
||||
_roomCode.value = code
|
||||
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
|
||||
@ -206,8 +203,7 @@ class JamRoomService(
|
||||
code = normalized,
|
||||
clientId = localClientId,
|
||||
displayName = name,
|
||||
isHost = false,
|
||||
).map {
|
||||
isHost = false).map {
|
||||
_role.value = JamRole.Guest
|
||||
_roomCode.value = normalized
|
||||
_participants.value = emptyList()
|
||||
@ -261,8 +257,7 @@ class JamRoomService(
|
||||
jamClient.publishCommand(
|
||||
JamMessage.SuggestTrack(
|
||||
mediaItem = JamMediaItem.fromTrack(track),
|
||||
addedBy = localClientId,
|
||||
)
|
||||
addedBy = localClientId)
|
||||
)
|
||||
}
|
||||
|
||||
@ -271,8 +266,7 @@ class JamRoomService(
|
||||
jamClient.publishCommand(
|
||||
JamMessage.SuggestPlaylist(
|
||||
tracks = tracks.map(JamMediaItem::fromTrack),
|
||||
addedBy = localClientId,
|
||||
)
|
||||
addedBy = localClientId)
|
||||
)
|
||||
}
|
||||
|
||||
@ -296,8 +290,7 @@ class JamRoomService(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
audioPlayer.shuffleModeFlow,
|
||||
audioPlayer.playerStateFlow,
|
||||
) { queue, current, shuffle, playerState ->
|
||||
audioPlayer.playerStateFlow) { queue, current, shuffle, playerState ->
|
||||
PlaybackBroadcast(queue, current, shuffle, playerState)
|
||||
}
|
||||
.onEach { broadcast ->
|
||||
@ -314,8 +307,7 @@ class JamRoomService(
|
||||
currentIndex = index.coerceAtLeast(0),
|
||||
shuffleEnabled = broadcast.shuffle,
|
||||
follow = now() > autoAdvanceDeadlineMs,
|
||||
isPlaying = broadcast.playerState == PlayerState.PLAYING,
|
||||
)
|
||||
isPlaying = broadcast.playerState == PlayerState.PLAYING)
|
||||
)
|
||||
}
|
||||
.launchIn(this)
|
||||
@ -374,8 +366,7 @@ class JamRoomService(
|
||||
audioPlayerQueue.load(
|
||||
entries = items.map { it.toQueueEntry() },
|
||||
autoPlay = wasPlaying,
|
||||
startPosition = startIndex,
|
||||
)
|
||||
startPosition = startIndex)
|
||||
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
||||
return
|
||||
}
|
||||
@ -524,13 +515,11 @@ class JamRoomService(
|
||||
explicit = null,
|
||||
popularity = null,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
url = "",
|
||||
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
||||
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
||||
addedBy = addedBy,
|
||||
)
|
||||
addedBy = addedBy)
|
||||
|
||||
else -> QueueEntry.LocalTrack(
|
||||
name = title,
|
||||
@ -539,8 +528,7 @@ class JamRoomService(
|
||||
album = album.ifBlank { null },
|
||||
coverBytes = null,
|
||||
url = url,
|
||||
addedBy = addedBy,
|
||||
)
|
||||
addedBy = addedBy)
|
||||
}
|
||||
|
||||
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.settings.SettingsScreen
|
||||
import dev.krtirtho.spotube.modules.settings.JamSettingsScreen
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
@ -91,6 +92,9 @@ sealed interface Routes : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object JamSettings : Routes
|
||||
|
||||
@Serializable
|
||||
data object Welcome : Routes
|
||||
}
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@ -121,8 +125,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Artist> {
|
||||
ArtistScreen(
|
||||
@ -132,8 +135,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Album> {
|
||||
AlbumScreen(
|
||||
@ -144,8 +146,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Lyrics> {
|
||||
LyricsScreen(viewModel = koinViewModel())
|
||||
@ -158,8 +159,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Blacklist> {
|
||||
BlacklistScreen()
|
||||
@ -178,4 +178,7 @@ val navigationModule = module {
|
||||
navigation<Routes.JamSettings> {
|
||||
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.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
@ -45,8 +44,9 @@ val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.Home,
|
||||
Routes.Search,
|
||||
Routes.Library,
|
||||
Routes.Settings
|
||||
)
|
||||
Routes.Settings,
|
||||
// Bootstrap destination: needs its own back stack when the app starts on it.
|
||||
Routes.Welcome)
|
||||
|
||||
val serializersConfig = SavedStateConfiguration {
|
||||
serializersModule = SerializersModule {
|
||||
@ -63,6 +63,7 @@ val serializersConfig = SavedStateConfiguration {
|
||||
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
||||
subclass(Routes.Devices::class, Routes.Devices.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>> {
|
||||
val decoratedEntries = backStacks.mapValues { (_, stack) ->
|
||||
val decorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
)
|
||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>())
|
||||
rememberDecoratedNavEntries(
|
||||
backStack = stack,
|
||||
entryDecorators = decorators,
|
||||
|
||||
@ -49,5 +49,4 @@ data class MediaStream(
|
||||
val container: String,
|
||||
val bitrate: Int,
|
||||
val isAudio: Boolean,
|
||||
val isVideo: Boolean,
|
||||
)
|
||||
val isVideo: Boolean)
|
||||
@ -34,8 +34,7 @@ class CollectionPlaybackHelper(
|
||||
private val savedTracksRepository: SavedTracksRepository,
|
||||
private val artistRepository: ArtistRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) {
|
||||
private val blacklistRepository: BlacklistRepository) {
|
||||
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
|
||||
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
|
||||
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
|
||||
@ -44,8 +43,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,8 +52,7 @@ class CollectionPlaybackHelper(
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllToQueue(
|
||||
entries,
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,8 +84,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||
}
|
||||
|
||||
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
|
||||
@ -99,8 +95,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,8 +104,7 @@ class CollectionPlaybackHelper(
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllToQueue(
|
||||
entries,
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,8 +136,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||
}
|
||||
|
||||
suspend fun playSavedTracks(shuffle: Boolean = false) {
|
||||
@ -154,8 +147,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,8 +158,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -190,8 +181,7 @@ class CollectionPlaybackHelper(
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllToQueue(
|
||||
entries,
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||
}
|
||||
}
|
||||
|
||||
@ -216,8 +206,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||
}
|
||||
|
||||
private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> {
|
||||
@ -313,8 +302,7 @@ class CollectionPlaybackHelper(
|
||||
private fun isTrackBlacklisted(
|
||||
track: MetadataTrack,
|
||||
blacklistedTrackIds: Set<String>,
|
||||
blacklistedArtistIds: Set<String>,
|
||||
): Boolean {
|
||||
blacklistedArtistIds: Set<String>): Boolean {
|
||||
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
|
||||
@ -17,17 +17,24 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.remote
|
||||
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.LaunchedEffect
|
||||
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.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
|
||||
|
||||
@Composable
|
||||
@ -42,50 +49,45 @@ fun ConnectionRequestDialogHost() {
|
||||
}
|
||||
|
||||
pendingRequest?.let { request ->
|
||||
AdaptiveDialogBottomSheet(
|
||||
onDismiss = {
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||
pendingRequest = null
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Remote Control Request",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
content = {
|
||||
Text(
|
||||
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
Button(
|
||||
title = "Remote Control Request") {
|
||||
Text(
|
||||
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Start)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||
pendingRequest = null
|
||||
},
|
||||
) {
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Deny")
|
||||
}
|
||||
Button(
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
||||
pendingRequest = null
|
||||
},
|
||||
) {
|
||||
}) {
|
||||
Text("Allow")
|
||||
}
|
||||
Button(
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
||||
pendingRequest = null
|
||||
},
|
||||
) {
|
||||
style = MillenniumButtonStyle.Quiet) {
|
||||
Text("Allow Always")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -162,8 +162,7 @@ class RemoteControlClient {
|
||||
|
||||
val envelope = CommandEnvelope(
|
||||
commandId = randomShortId(),
|
||||
command = command,
|
||||
)
|
||||
command = command)
|
||||
|
||||
try {
|
||||
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)
|
||||
|
||||
@ -48,13 +48,11 @@ class RemoteControlHandler(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
) : KoinComponent {
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent {
|
||||
val logger by injectLogger<RemoteControlHandler>()
|
||||
|
||||
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
|
||||
extraBufferCapacity = 16,
|
||||
)
|
||||
extraBufferCapacity = 16)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@ -88,8 +86,7 @@ class RemoteControlHandler(
|
||||
deviceId = deviceId ?: "unknown",
|
||||
deviceName = deviceName,
|
||||
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)
|
||||
val response = waitForRequestResolution(request.sessionId)
|
||||
if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) {
|
||||
@ -225,8 +222,7 @@ class RemoteControlHandler(
|
||||
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
is RemoteControlCommand.AddTrackToQueue -> {
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
|
||||
@ -300,8 +296,7 @@ class RemoteControlHandler(
|
||||
private suspend fun handleCollectionSource(
|
||||
source: String,
|
||||
action: RemoteCollectionAction,
|
||||
shuffle: Boolean = false,
|
||||
) {
|
||||
shuffle: Boolean = false) {
|
||||
logger.d { "Remote collection $action for source: $source" }
|
||||
when {
|
||||
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
|
||||
@ -359,8 +354,7 @@ class RemoteControlHandler(
|
||||
currentTrackTitle = current?.titleOrNull(),
|
||||
currentTrackArtists = current?.artistsOrNull(),
|
||||
currentTrackAlbum = current?.albumOrNull(),
|
||||
currentTrackCoverUrl = current?.coverUrlOrNull(),
|
||||
)
|
||||
currentTrackCoverUrl = current?.coverUrlOrNull())
|
||||
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
@ -375,8 +369,7 @@ class RemoteControlHandler(
|
||||
}
|
||||
val event = RemoteControlEvent.QueueUpdated(
|
||||
entries = queue.map { it.toRemoteQueueEntry() },
|
||||
currentIndex = currentIndex,
|
||||
)
|
||||
currentIndex = currentIndex)
|
||||
val text = json.encodeToString(RemoteControlEvent.serializer(), event)
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
@ -403,8 +396,7 @@ class RemoteControlHandler(
|
||||
artists = track.artists.joinToString(", ") { artist -> artist.name },
|
||||
album = track.album?.title,
|
||||
coverUrl = coverUrlOrNull(),
|
||||
durationMs = track.durationMs,
|
||||
)
|
||||
durationMs = track.durationMs)
|
||||
|
||||
is QueueEntry.LocalTrack -> RemoteQueueEntry(
|
||||
mediaUrl = url,
|
||||
@ -413,8 +405,7 @@ class RemoteControlHandler(
|
||||
artists = artists.joinToString(", "),
|
||||
album = album,
|
||||
coverUrl = null,
|
||||
durationMs = duration,
|
||||
)
|
||||
durationMs = duration)
|
||||
}
|
||||
|
||||
private fun QueueEntry.mediaKey(): String = when (this) {
|
||||
@ -447,8 +438,7 @@ class RemoteControlHandler(
|
||||
@Serializable
|
||||
data class CommandEnvelope(
|
||||
val commandId: String,
|
||||
val command: RemoteControlCommand,
|
||||
)
|
||||
val command: RemoteControlCommand)
|
||||
|
||||
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
|
||||
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
|
||||
@ -464,8 +454,7 @@ enum class RemoteCollectionAction {
|
||||
data class ConnectionRequest(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val sessionId: String,
|
||||
)
|
||||
val sessionId: String)
|
||||
|
||||
enum class ConnectionRequestResponse {
|
||||
Allow,
|
||||
|
||||
@ -119,15 +119,13 @@ sealed class RemoteControlEvent {
|
||||
val currentTrackTitle: String?,
|
||||
val currentTrackArtists: String?,
|
||||
val currentTrackAlbum: String?,
|
||||
val currentTrackCoverUrl: String?,
|
||||
) : RemoteControlEvent()
|
||||
val currentTrackCoverUrl: String?) : RemoteControlEvent()
|
||||
|
||||
@Serializable
|
||||
@SerialName("queueUpdated")
|
||||
data class QueueUpdated(
|
||||
val entries: List<RemoteQueueEntry>,
|
||||
val currentIndex: Int,
|
||||
) : RemoteControlEvent()
|
||||
val currentIndex: Int) : RemoteControlEvent()
|
||||
|
||||
@Serializable
|
||||
@SerialName("ack")
|
||||
@ -146,5 +144,4 @@ data class RemoteQueueEntry(
|
||||
val artists: String,
|
||||
val album: String?,
|
||||
val coverUrl: String?,
|
||||
val durationMs: Long,
|
||||
)
|
||||
val durationMs: Long)
|
||||
|
||||
@ -53,8 +53,7 @@ import kotlin.random.Random
|
||||
class RemoteControlService(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val discoveryService: DeviceDiscoveryService,
|
||||
private val localServer: LocalServer,
|
||||
) {
|
||||
private val localServer: LocalServer) {
|
||||
private val log = Logger.withTag("RemoteControlService")
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@ -78,8 +77,7 @@ class RemoteControlService(
|
||||
scope.launch {
|
||||
combine(
|
||||
settingsRepository.userSettings,
|
||||
localServer.port,
|
||||
) { settings, port -> settings to port }
|
||||
localServer.port) { settings, port -> settings to port }
|
||||
.distinctUntilChanged()
|
||||
.collect { (settings, port) ->
|
||||
if (settings.allowRemoteControl && port != null) {
|
||||
@ -139,8 +137,7 @@ class RemoteControlService(
|
||||
val service = discoveryService.createService(
|
||||
name = serviceName,
|
||||
port = port,
|
||||
deviceId = deviceId,
|
||||
)
|
||||
deviceId = deviceId)
|
||||
pendingService = service
|
||||
try {
|
||||
service.register(timeoutInMs = REGISTER_TIMEOUT_MS)
|
||||
|
||||
@ -64,20 +64,17 @@ sealed interface PlaybackDestinationRequest {
|
||||
val type: RemoteCollectionType,
|
||||
val id: String,
|
||||
val startTrack: MetadataTrack? = null,
|
||||
val shuffle: Boolean = false,
|
||||
) : PlaybackDestinationRequest
|
||||
val shuffle: Boolean = false) : PlaybackDestinationRequest
|
||||
|
||||
data class Track(
|
||||
override val title: String,
|
||||
override val action: PlaybackDestinationAction,
|
||||
val track: MetadataTrack,
|
||||
) : PlaybackDestinationRequest
|
||||
val track: MetadataTrack) : PlaybackDestinationRequest
|
||||
|
||||
data class Tracks(
|
||||
override val title: String,
|
||||
override val action: PlaybackDestinationAction,
|
||||
val tracks: List<MetadataTrack>,
|
||||
) : PlaybackDestinationRequest
|
||||
val tracks: List<MetadataTrack>) : PlaybackDestinationRequest
|
||||
}
|
||||
|
||||
/**
|
||||
@ -90,8 +87,7 @@ class RemotePlaybackController(
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val jamRoomService: JamRoomService,
|
||||
) : KoinComponent {
|
||||
private val jamRoomService: JamRoomService) : KoinComponent {
|
||||
private val logger = Logger.withTag("RemotePlaybackController")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@ -113,8 +109,7 @@ class RemotePlaybackController(
|
||||
id: String,
|
||||
title: String,
|
||||
startTrack: MetadataTrack? = null,
|
||||
shuffle: Boolean = false,
|
||||
) {
|
||||
shuffle: Boolean = false) {
|
||||
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
|
||||
}
|
||||
|
||||
@ -205,8 +200,7 @@ class RemotePlaybackController(
|
||||
QueueEntry.StreamingTrack(
|
||||
track = track,
|
||||
url = "",
|
||||
addedBy = jamRoomService.participantClientId,
|
||||
)
|
||||
addedBy = jamRoomService.participantClientId)
|
||||
}
|
||||
)
|
||||
|
||||
@ -291,8 +285,7 @@ class RemotePlaybackController(
|
||||
entries = listOf(entry),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> {
|
||||
@ -319,8 +312,7 @@ class RemotePlaybackController(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> {
|
||||
|
||||
@ -52,8 +52,7 @@ class ScrobbleManager(
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val scrobblePluginSource: ScrobblePluginSource,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) : KoinComponent {
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<ScrobbleManager>()
|
||||
|
||||
@ -69,14 +68,12 @@ class ScrobbleManager(
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.positionFlow,
|
||||
audioPlayer.durationFlow,
|
||||
) { entry, playerState, position, duration ->
|
||||
audioPlayer.durationFlow) { entry, playerState, position, duration ->
|
||||
PlaybackSnapshot(
|
||||
entry = entry,
|
||||
playerState = playerState,
|
||||
positionMs = position.inWholeMilliseconds,
|
||||
playerDurationMs = duration.inWholeMilliseconds,
|
||||
)
|
||||
playerDurationMs = duration.inWholeMilliseconds)
|
||||
}.collect(::onPlaybackSnapshot)
|
||||
}
|
||||
}
|
||||
@ -137,8 +134,7 @@ class ScrobbleManager(
|
||||
scrobbled = true
|
||||
val track = entry.toScrobbleTrack(
|
||||
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
|
||||
durationMs = durationMs,
|
||||
)
|
||||
durationMs = durationMs)
|
||||
scope.launch { submitScrobble(track) }
|
||||
}
|
||||
|
||||
@ -197,8 +193,7 @@ class ScrobbleManager(
|
||||
artistName = track.artists.joinToString(", ") { it.name },
|
||||
albumName = track.album?.title,
|
||||
streamingProvider = resolveStreamingProvider(track),
|
||||
durationMs = durationMs,
|
||||
)
|
||||
durationMs = durationMs)
|
||||
|
||||
is QueueEntry.LocalTrack -> ScrobbleTrack(
|
||||
timestamp = timestamp,
|
||||
@ -209,8 +204,7 @@ class ScrobbleManager(
|
||||
artistName = artists.joinToString(", "),
|
||||
albumName = album,
|
||||
streamingProvider = LOCAL_PROVIDER,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
durationMs = durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,8 +239,7 @@ class ScrobbleManager(
|
||||
val entry: QueueEntry?,
|
||||
val playerState: PlayerState,
|
||||
val positionMs: Long,
|
||||
val playerDurationMs: Long,
|
||||
)
|
||||
val playerDurationMs: Long)
|
||||
|
||||
companion object {
|
||||
/** Tracks shorter than this are never scrobbled (Last.fm rule). */
|
||||
|
||||
@ -29,8 +29,7 @@ class AlternativeTracksRepository(
|
||||
private val matchedTracksRepository: TrackSourceRepository,
|
||||
private val streamingUrlRepository: StreamingUrlRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val cacheManager: CacheManager,
|
||||
) : KoinComponent {
|
||||
private val cacheManager: CacheManager) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<AlternativeTracksRepository>()
|
||||
|
||||
|
||||
@ -51,13 +51,11 @@ data class CacheEntry(
|
||||
val filename: String,
|
||||
val sizeBytes: Long,
|
||||
val createdAtMs: Long,
|
||||
val contentType: String = "application/octet-stream",
|
||||
)
|
||||
val contentType: String = "application/octet-stream")
|
||||
|
||||
class CacheManager(
|
||||
private val paths: Paths,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
private val settingsRepository: SettingsRepository) {
|
||||
private val fileSystem: FileSystem = FileSystem.SYSTEM
|
||||
private val cacheMutex: Mutex = Mutex()
|
||||
private val json: Json = Json { ignoreUnknownKeys = true }
|
||||
@ -160,15 +158,13 @@ class CacheManager(
|
||||
trackId: String,
|
||||
filename: String,
|
||||
allBytes: ByteArray,
|
||||
contentType: String,
|
||||
) {
|
||||
contentType: String) {
|
||||
val entry = CacheEntry(
|
||||
trackId = trackId,
|
||||
filename = filename,
|
||||
sizeBytes = allBytes.size.toLong(),
|
||||
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
||||
contentType = contentType,
|
||||
)
|
||||
contentType = contentType)
|
||||
cacheMutex.withLock {
|
||||
val index = readCacheIndex()
|
||||
writeCacheIndex(CacheIndex(
|
||||
|
||||
@ -60,8 +60,7 @@ class LocalServer(
|
||||
private val streamingUrlRepository: StreamingUrlRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val cacheManager: CacheManager,
|
||||
private val remoteControlHandler: RemoteControlHandler,
|
||||
) : KoinComponent {
|
||||
private val remoteControlHandler: RemoteControlHandler) : KoinComponent {
|
||||
|
||||
val logger by injectLogger<LocalServer>()
|
||||
private val httpClient = HttpClient {
|
||||
@ -99,8 +98,7 @@ class LocalServer(
|
||||
audioPlayerQueue = audioPlayerQueue,
|
||||
isCachingEnabled = { cachedCacheEnabled.value },
|
||||
activePort = { activePort.value },
|
||||
scope = scope,
|
||||
)
|
||||
scope = scope)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@ -37,8 +37,7 @@ import kotlinx.serialization.json.Json
|
||||
@Serializable
|
||||
data class PluginTaggedAudioSource(
|
||||
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. */
|
||||
interface TrackSourceRepository {
|
||||
|
||||
@ -62,8 +62,7 @@ internal class StreamProxy(
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val isCachingEnabled: () -> Boolean,
|
||||
private val activePort: () -> Int?,
|
||||
private val scope: CoroutineScope,
|
||||
): KoinComponent {
|
||||
private val scope: CoroutineScope): KoinComponent {
|
||||
val logger by injectLogger<StreamProxy>()
|
||||
|
||||
companion object {
|
||||
@ -414,8 +413,7 @@ internal class StreamProxy(
|
||||
trackId = trackIdForCache,
|
||||
filename = cacheFilename,
|
||||
allBytes = allBytes,
|
||||
contentType = cacheContentType,
|
||||
)
|
||||
contentType = cacheContentType)
|
||||
scope.launch {
|
||||
cacheManager.evictIfNeeded()
|
||||
}
|
||||
|
||||
@ -48,28 +48,24 @@ data class CachedStreamUrl(
|
||||
// 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
|
||||
// audio plugin, which would otherwise play back completely unrelated content.
|
||||
val pluginId: String,
|
||||
)
|
||||
val pluginId: String)
|
||||
|
||||
private data class CachedAlternatives(
|
||||
val pluginId: String,
|
||||
val sources: List<AudioSource>,
|
||||
)
|
||||
val sources: List<AudioSource>)
|
||||
|
||||
data class StreamInfo(
|
||||
val url: String,
|
||||
val protocol: StreamProtocol,
|
||||
val codec: String,
|
||||
val container: String,
|
||||
)
|
||||
val container: String)
|
||||
|
||||
class StreamingUrlRepository(
|
||||
private val pluginManager: AudioPluginSource,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val matchedTracksRepository: TrackSourceRepository,
|
||||
private val settingsRepository: UserSettingsSource,
|
||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
|
||||
) : KoinComponent {
|
||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<StreamingUrlRepository>()
|
||||
private val streamUrlCacheMutex = Mutex()
|
||||
@ -133,8 +129,7 @@ class StreamingUrlRepository(
|
||||
val resolvedStream = selectPreferredAudioStream(
|
||||
stream.streams,
|
||||
preferredFormat,
|
||||
preferredQuality,
|
||||
)
|
||||
preferredQuality)
|
||||
if (resolvedStream != null) {
|
||||
val info = resolvedStream.toStreamInfo()
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||
@ -193,8 +188,7 @@ class StreamingUrlRepository(
|
||||
val resolvedStream = selectPreferredAudioStream(
|
||||
stream.streams,
|
||||
preferredFormat,
|
||||
preferredQuality,
|
||||
)
|
||||
preferredQuality)
|
||||
if (resolvedStream != null) {
|
||||
val info = resolvedStream.toStreamInfo()
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||
@ -207,15 +201,13 @@ class StreamingUrlRepository(
|
||||
url = normalizeManifestUrl(url, protocol),
|
||||
protocol = protocol,
|
||||
codec = codec,
|
||||
container = container,
|
||||
)
|
||||
container = container)
|
||||
|
||||
private suspend fun getCachedStreamUrl(
|
||||
trackId: String,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality,
|
||||
pluginId: String,
|
||||
): StreamInfo? {
|
||||
pluginId: String): StreamInfo? {
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
return streamUrlCacheMutex.withLock {
|
||||
val cached = streamUrlCache[trackId] ?: return@withLock null
|
||||
@ -241,8 +233,7 @@ class StreamingUrlRepository(
|
||||
preferredQuality: AudioQuality,
|
||||
source: AudioSource.Streamed,
|
||||
selectedStream: AudioStream,
|
||||
pluginId: String,
|
||||
) {
|
||||
pluginId: String) {
|
||||
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
|
||||
streamUrlCacheMutex.withLock {
|
||||
streamUrlCache[trackId] = CachedStreamUrl(
|
||||
@ -255,8 +246,7 @@ class StreamingUrlRepository(
|
||||
selectedStream = selectedStream,
|
||||
preferredFormat = preferredFormat,
|
||||
preferredQuality = preferredQuality,
|
||||
pluginId = pluginId,
|
||||
)
|
||||
pluginId = pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,8 +316,7 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
|
||||
internal fun selectPreferredAudioStream(
|
||||
streams: List<AudioStream>,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality,
|
||||
): AudioStream? = streams.minWithOrNull(
|
||||
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
|
||||
compareBy<AudioStream> { stream ->
|
||||
formatMismatchCount(stream, preferredFormat)
|
||||
}.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
|
||||
|
||||
|
||||
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.Box
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import com.composeunstyled.SheetDetent
|
||||
import com.composeunstyled.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
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.Iconsax
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdaptiveDialogBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
breakpointDp: Float = 600f,
|
||||
actions: @Composable (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||
content: @Composable () -> Unit) {
|
||||
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
|
||||
|
||||
if (isLargeScreen) {
|
||||
ThemedDialog(
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = onDismiss,
|
||||
title = if (title != null) {
|
||||
{
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Modifier.weight(1f)) { title() }
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Iconsax.FluentDismiss,
|
||||
contentDescription = "Close dialog",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
actions = actions,
|
||||
modifier = modifier,
|
||||
) {
|
||||
title = "",
|
||||
modifier = modifier) {
|
||||
title?.let { it() }
|
||||
content()
|
||||
actions?.let {
|
||||
MillenniumSeparator()
|
||||
it()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
dragHandle = null,
|
||||
modifier = modifier,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||
LaunchedEffect(sheetState.currentDetent) {
|
||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
MillenniumModalBottomSheet(state = sheetState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 16.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
horizontalAlignment = Alignment.Start) {
|
||||
title?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@ -101,11 +87,12 @@ fun AdaptiveDialogBottomSheet(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Modifier.weight(1f)) { it() }
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
Iconsax.FluentDismiss,
|
||||
contentDescription = "Close dialog",
|
||||
)
|
||||
contentDescription = "Close dialog")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@ -113,7 +100,7 @@ fun AdaptiveDialogBottomSheet(
|
||||
content()
|
||||
actions?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
MillenniumSeparator()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
it()
|
||||
}
|
||||
|
||||
@ -17,13 +17,12 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
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
|
||||
@ -34,20 +33,12 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -55,25 +46,37 @@ 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.graphics.vector.ImageVector
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
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.Check
|
||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||
import dev.krtirtho.spotube.core.ui.base.DropdownMenu
|
||||
import dev.krtirtho.spotube.core.ui.base.DropdownMenuItem
|
||||
import dev.krtirtho.spotube.core.ui.base.DropdownMenuDivider
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
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.IconsaxShare
|
||||
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(
|
||||
val icon: ImageVector? = null,
|
||||
@ -82,7 +85,8 @@ data class AdaptiveMenuItem(
|
||||
val enabled: Boolean = true,
|
||||
val selected: Boolean = false,
|
||||
val dividerBefore: Boolean = false,
|
||||
)
|
||||
val subtitle: String? = null,
|
||||
val leadingContent: (@Composable () -> Unit)? = null)
|
||||
|
||||
enum class HeaderDisplayMode {
|
||||
Always,
|
||||
@ -95,71 +99,129 @@ fun AdaptiveDropdownBottomSheet(
|
||||
items: List<AdaptiveMenuItem>,
|
||||
trigger: @Composable (onClick: () -> Unit) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
breakpointDp: Float = 600f,
|
||||
menuMinWidth: Dp = 200.dp,
|
||||
header: @Composable (() -> Unit)? = null,
|
||||
headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always,
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
|
||||
) {
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
|
||||
|
||||
Box(modifier = modifier) {
|
||||
trigger { expanded = true }
|
||||
|
||||
if (isLargeScreen) {
|
||||
DropdownMenu(
|
||||
UnstyledDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
||||
header()
|
||||
}
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
if (filter != null) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
},
|
||||
placeholder = { Text("Search...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items.forEach { item ->
|
||||
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
||||
return@forEach
|
||||
onExpandedChange = { expanded = it },
|
||||
panel = {
|
||||
DropdownMenuPanel(
|
||||
Modifier
|
||||
.width(menuMinWidth)
|
||||
.millenniumSurface(
|
||||
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||
shape = AppTheme.shapes.medium,
|
||||
borderColor = AppTheme.colors.outlineVariant,
|
||||
borderWidth = 1.dp)
|
||||
.padding(4.dp)) {
|
||||
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
||||
header()
|
||||
}
|
||||
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 {
|
||||
trigger { expanded = true }
|
||||
if (expanded) {
|
||||
AdaptiveBottomSheetContent(
|
||||
onDismiss = { expanded = false },
|
||||
@ -169,143 +231,124 @@ fun AdaptiveDropdownBottomSheet(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
filter = filter,
|
||||
)
|
||||
filter = filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AdaptiveBottomSheetContent(
|
||||
onDismiss: () -> Unit,
|
||||
items: List<AdaptiveMenuItem>,
|
||||
header: @Composable (() -> Unit)?,
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
|
||||
) {
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
|
||||
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()) {
|
||||
items.filter { filter(it, query) }
|
||||
} else {
|
||||
items
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
dragHandle = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||
LaunchedEffect(sheetState.currentDetent) {
|
||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
MillenniumModalBottomSheet(state = sheetState) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
header?.invoke()
|
||||
|
||||
if (filter != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
},
|
||||
placeholder = { Text("Search...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
MillenniumTextField(
|
||||
state = queryState,
|
||||
label = "",
|
||||
placeholder = "Search...",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp))
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
items(
|
||||
count = filteredItems.size,
|
||||
) { index ->
|
||||
count = filteredItems.size) { index ->
|
||||
val item = filteredItems[index]
|
||||
|
||||
if (item.dividerBefore) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clip(AppTheme.shapes.medium)
|
||||
.clickable(
|
||||
enabled = item.enabled,
|
||||
onClick = {
|
||||
item.onClick()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
})
|
||||
.background(
|
||||
color = if (item.selected) {
|
||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f)
|
||||
AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
shape = AppTheme.shapes.medium)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (item.icon != null) {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
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
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
when {
|
||||
item.leadingContent != null -> item.leadingContent.invoke()
|
||||
item.icon != null -> MillenniumIcon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.enabled) {
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
}
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
|
||||
if (item.selected && item.icon != null) {
|
||||
Icon(
|
||||
item.selected -> MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
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
|
||||
@Composable
|
||||
private fun AdaptiveDropdownBottomSheetPreview() {
|
||||
Scaffold {
|
||||
Box(modifier = Modifier.background(AppTheme.colors.background)) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = listOf(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxHeart,
|
||||
label = "Favorite",
|
||||
onClick = {},
|
||||
),
|
||||
onClick = {}),
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxShare,
|
||||
label = "Share",
|
||||
onClick = {},
|
||||
),
|
||||
onClick = {}),
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxTrash,
|
||||
label = "Delete",
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
),
|
||||
),
|
||||
enabled = false)),
|
||||
trigger = { onClick ->
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
shape = CircleShape,
|
||||
)
|
||||
color = AppTheme.colors.primary.copy(alpha = 0.1f),
|
||||
shape = CircleShape)
|
||||
.clip(CircleShape),
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
tint = AppTheme.colors.primary)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,8 +40,7 @@ fun AlbumCard(
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||
) {
|
||||
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
PlayableCard(
|
||||
@ -57,17 +56,14 @@ fun AlbumCard(
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.Album,
|
||||
album.id,
|
||||
album.title,
|
||||
)
|
||||
album.title)
|
||||
},
|
||||
onAddToQueue = {
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.Album,
|
||||
album.id,
|
||||
album.title,
|
||||
)
|
||||
album.title)
|
||||
},
|
||||
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
|
||||
|
||||
|
||||
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.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
||||
@ -30,12 +33,12 @@ import org.koin.compose.koinInject
|
||||
fun ApplicationBackButton() {
|
||||
val navigationCommands: NavigationCommands = koinInject()
|
||||
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
navigationCommands.pop()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.ArrowLeft3,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
@ -47,5 +50,5 @@ expect fun ApplicationMainBar(
|
||||
title: @Composable () -> Unit = {},
|
||||
subtitle: @Composable () -> 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.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -52,8 +52,7 @@ import org.koin.compose.koinInject
|
||||
fun ArtistCard(
|
||||
artist: MetadataArtist.Basic,
|
||||
modifier: Modifier = Modifier,
|
||||
isBlacklisted: Boolean = false,
|
||||
) {
|
||||
isBlacklisted: Boolean = false) {
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
|
||||
AvatarCard(
|
||||
@ -64,7 +63,6 @@ fun ArtistCard(
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
modifier = modifier,
|
||||
isBlacklisted = isBlacklisted,
|
||||
)
|
||||
isBlacklisted = isBlacklisted)
|
||||
}
|
||||
|
||||
|
||||
@ -17,13 +17,24 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
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.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
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.unit.dp
|
||||
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.shimmerApply
|
||||
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.painterResource
|
||||
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun CollectionDetails(
|
||||
@ -85,10 +89,9 @@ fun CollectionDetails(
|
||||
onFollowClick: () -> Unit = { },
|
||||
showFollowButton: Boolean = true,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
sharedElementKey: String? = null,
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
val isCompact = maxWidth < 600.dp
|
||||
sharedElementKey: String? = null) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
||||
|
||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||
@ -98,95 +101,86 @@ fun CollectionDetails(
|
||||
if (onAddToJam != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
PrimaryButton(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
MillenniumButton(
|
||||
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
|
||||
onClick = onAddToJam!!,
|
||||
) {
|
||||
Icon(
|
||||
onClick = onAddToJam!!) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to Jam",
|
||||
)
|
||||
TextWithShimmer(
|
||||
text = "Add to Jam",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
contentDescription = "Add to Jam")
|
||||
TextWithShimmer(text = "Add to Jam")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val modifier = if (isCompact) {
|
||||
Modifier.weight(1f)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
PrimaryButton(
|
||||
MillenniumButton(
|
||||
modifier = modifier,
|
||||
onClick = onPlay,
|
||||
) {
|
||||
Icon(
|
||||
onClick = onPlay) {
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
)
|
||||
TextWithShimmer(
|
||||
text = if (isPlaying) "Pause" else "Play",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
contentDescription = if (isPlaying) "Pause" else "Play")
|
||||
TextWithShimmer(text = if (isPlaying) "Pause" else "Play")
|
||||
}
|
||||
OutlineButton(
|
||||
MillenniumButton(
|
||||
modifier = modifier,
|
||||
onClick = onShufflePlay
|
||||
onClick = onShufflePlay,
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||
TextWithShimmer(
|
||||
text = "Shuffle",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||
TextWithShimmer(text = "Shuffle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val actions = @Composable {
|
||||
ButtonGroup {
|
||||
GroupIconButton(onClick = onAddToQueue) {
|
||||
Icon(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumIconButton(
|
||||
onClick = onAddToQueue,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to queue"
|
||||
)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||
if (showFollowButton) {
|
||||
if (isFollowing) {
|
||||
GroupIconButton(onClick = onFollowClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onFollowClick,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxHeart2,
|
||||
contentDescription = "Unfollow",
|
||||
)
|
||||
contentDescription = "Unfollow")
|
||||
}
|
||||
} else {
|
||||
GroupIconButton(onClick = onFollowClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onFollowClick,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxHeart,
|
||||
contentDescription = "Follow",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onEdit != null) {
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = onEdit) {
|
||||
Icon(
|
||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||
MillenniumIconButton(
|
||||
onClick = onEdit,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxEdit,
|
||||
contentDescription = "Edit playlist",
|
||||
)
|
||||
contentDescription = "Edit playlist")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -195,8 +189,7 @@ fun CollectionDetails(
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onOwnerClick),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (ownerImageURL != null) {
|
||||
AsyncImage(
|
||||
model = ownerImageURL,
|
||||
@ -205,27 +198,24 @@ fun CollectionDetails(
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.shimmerApply(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = ownerName,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
AppTheme.colors.primaryContainer,
|
||||
CircleShape
|
||||
)
|
||||
.padding(6.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
tint = AppTheme.colors.onPrimaryContainer)
|
||||
}
|
||||
TextWithShimmer(
|
||||
text = "By $ownerName",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@ -237,79 +227,68 @@ fun CollectionDetails(
|
||||
.sharedElementOrNone(
|
||||
key = sharedElementKey,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope)
|
||||
.shimmerApply(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
if (imageURL.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else if (imageResource != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(imageResource),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Text(
|
||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
style = AppTheme.typography.displaySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val metadata = @Composable {
|
||||
Column {
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.headlineLarge,
|
||||
style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
|
||||
if (description.isNotBlank()) {
|
||||
TextWithShimmer(
|
||||
text = description,
|
||||
style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
if (isCompact) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
artwork()
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
metadata()
|
||||
ownerInfo()
|
||||
actions()
|
||||
@ -323,14 +302,12 @@ fun CollectionDetails(
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
verticalAlignment = Alignment.Top) {
|
||||
artwork()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
metadata()
|
||||
ownerInfo()
|
||||
actions()
|
||||
@ -354,6 +331,5 @@ fun CollectionDetailsPreview() {
|
||||
onOwnerClick = {},
|
||||
onPlay = {},
|
||||
onShufflePlay = {},
|
||||
onAddToQueue = {},
|
||||
)
|
||||
onAddToQueue = {})
|
||||
}
|
||||
|
||||
@ -17,18 +17,22 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import 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.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -93,8 +97,7 @@ fun CollectionView(
|
||||
onAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||
footerContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: @Composable () -> Unit = {},
|
||||
) {
|
||||
trailingContent: @Composable () -> Unit = {}) {
|
||||
val listState = rememberLazyListState()
|
||||
val isCollapsed by remember {
|
||||
derivedStateOf {
|
||||
@ -103,45 +106,42 @@ fun CollectionView(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = if (isCollapsed) {
|
||||
{
|
||||
CollapsedCollectionTitle(
|
||||
title = title,
|
||||
imageURL = imageURL,
|
||||
imageResource = imageResource,
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = if (isCollapsed) {
|
||||
{
|
||||
CollapsedCollectionTitle(
|
||||
title = title,
|
||||
imageURL = imageURL,
|
||||
imageResource = imageResource)
|
||||
}
|
||||
} else {
|
||||
{}
|
||||
},
|
||||
actions = if (isCollapsed && !isJamGuest) {
|
||||
{
|
||||
MillenniumIconButton(
|
||||
onClick = onPlay,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play")
|
||||
}
|
||||
} else {
|
||||
{}
|
||||
},
|
||||
actions = if (isCollapsed && !isJamGuest) {
|
||||
{
|
||||
IconButton(onClick = onPlay) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
{}
|
||||
},
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
}
|
||||
} else {
|
||||
{}
|
||||
})
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
|
||||
if (error != null && tracks.isEmpty() && !isLoading) {
|
||||
ErrorDisplay(
|
||||
errorMessage = error,
|
||||
onRetry = onRetry ?: {},
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
TrackList(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
headerContent = {
|
||||
if (isLoading && tracks.isEmpty()) {
|
||||
@ -164,8 +164,7 @@ fun CollectionView(
|
||||
onEdit = onEdit,
|
||||
sharedElementKey = sharedElementKey,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = { onAddToJam(tracks) },
|
||||
)
|
||||
onAddToJam = { onAddToJam(tracks) })
|
||||
}
|
||||
} else {
|
||||
CollectionDetails(
|
||||
@ -186,8 +185,7 @@ fun CollectionView(
|
||||
onEdit = onEdit,
|
||||
sharedElementKey = sharedElementKey,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = { onAddToJam(tracks) },
|
||||
)
|
||||
onAddToJam = { onAddToJam(tracks) })
|
||||
}
|
||||
},
|
||||
footerContent = footerContent,
|
||||
@ -211,11 +209,11 @@ fun CollectionView(
|
||||
onBulkAddToJam = onBulkAddToJam,
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
trackOptionsState = trackOptionsState,
|
||||
)
|
||||
trackOptionsState = trackOptionsState)
|
||||
}
|
||||
|
||||
trailingContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,46 +221,39 @@ fun CollectionView(
|
||||
private fun CollapsedCollectionTitle(
|
||||
title: String,
|
||||
imageURL: String,
|
||||
imageResource: DrawableResource? = null,
|
||||
) {
|
||||
imageResource: DrawableResource? = null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.shimmerApply(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
if (imageURL.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(36.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else if (imageResource != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(imageResource),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(36.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Text(
|
||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
style = AppTheme.typography.labelMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
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
|
||||
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import 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.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
|
||||
import com.composeunstyled.Text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -67,8 +70,7 @@ import compose.icons.feathericons.RefreshCw
|
||||
fun ErrorDisplay(
|
||||
errorMessage: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
var showFullLog by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
@ -77,117 +79,99 @@ fun ErrorDisplay(
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Icon(
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.AlertCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
|
||||
)
|
||||
tint = AppTheme.colors.error.copy(alpha = 0.7f))
|
||||
|
||||
Text(
|
||||
text = "We're Truly Sorry",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
style = AppTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
color = AppTheme.colors.onSurface,
|
||||
textAlign = TextAlign.Center)
|
||||
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2,
|
||||
)
|
||||
lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2)
|
||||
|
||||
Card(
|
||||
MillenniumCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f),
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Error Details",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
style = AppTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
IconButton(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Copy,
|
||||
contentDescription = "Copy to clipboard",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
modifier = Modifier.size(16.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = { showFullLog = true },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Maximize2,
|
||||
contentDescription = "View full log",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
MillenniumCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f),
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace),
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Button(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
MillenniumButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.height(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.RefreshCw,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp).size(18.dp),
|
||||
)
|
||||
Text("Retry")
|
||||
modifier = Modifier.height(40.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.RefreshCw,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp).size(18.dp))
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -195,93 +179,85 @@ fun ErrorDisplay(
|
||||
if (showFullLog) {
|
||||
ErrorLogDialog(
|
||||
errorMessage = errorMessage,
|
||||
onDismiss = { showFullLog = false },
|
||||
)
|
||||
onDismiss = { showFullLog = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorLogDialog(
|
||||
errorMessage: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
onDismiss: () -> Unit) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
AlertDialog(
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Full Error Log",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
title = "") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Full Error Log",
|
||||
style = AppTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.Copy,
|
||||
contentDescription = "Copy to clipboard",
|
||||
)
|
||||
}
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||
},
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Copy,
|
||||
contentDescription = "Copy to clipboard")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp)
|
||||
.clip(AppTheme.shapes.small)
|
||||
.background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = AppTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace),
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End) {
|
||||
MillenniumButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumButtonStyle.Quiet) {
|
||||
Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ErrorDisplayPreview() {
|
||||
Surface {
|
||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||
ErrorDisplay(
|
||||
errorMessage = "This is an error message",
|
||||
onRetry = {},
|
||||
)
|
||||
onRetry = {})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ErrorLogDialogPreview() {
|
||||
Scaffold {
|
||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||
ErrorLogDialog(
|
||||
errorMessage = "This is an error message",
|
||||
onDismiss = {},
|
||||
)
|
||||
onDismiss = {})
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
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.tween
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@ -83,8 +87,7 @@ import org.koin.compose.koinInject
|
||||
@Composable
|
||||
fun FeaturedCarousel(
|
||||
items: List<MetadataBrowseItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
val filteredItems = remember(items) {
|
||||
items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track }
|
||||
}
|
||||
@ -92,20 +95,17 @@ fun FeaturedCarousel(
|
||||
if (filteredItems.isEmpty()) return
|
||||
|
||||
FeaturedCarouselContent(
|
||||
items = filteredItems,
|
||||
)
|
||||
items = filteredItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeaturedCarouselContent(
|
||||
items: List<MetadataBrowseItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
val centerIndex = items.size / 2
|
||||
var selectedIndex by remember { mutableIntStateOf(centerIndex) }
|
||||
val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index }
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isSmallScreen = adaptiveInfo.windowSizeClass.minWidthDp <= 600
|
||||
val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@ -113,8 +113,7 @@ private fun FeaturedCarouselContent(
|
||||
.height(
|
||||
if (isSmallScreen) 200.dp else 300.dp
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
Coverflow(
|
||||
state = state,
|
||||
params = CoverflowParams(
|
||||
@ -123,21 +122,17 @@ private fun FeaturedCarouselContent(
|
||||
angle = 12f,
|
||||
shift = 0.255f,
|
||||
zoom = 0.7864f,
|
||||
mirror = false,
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
mirror = false),
|
||||
modifier = modifier) {
|
||||
items(
|
||||
items = items,
|
||||
key = { index: Int -> items[index].hashCode() },
|
||||
) { item: MetadataBrowseItem ->
|
||||
key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem ->
|
||||
FeaturedCarouselCard(
|
||||
item = item,
|
||||
isCentered = selectedIndex == items.indexOf(item),
|
||||
onScrollToCenter = {
|
||||
state.scrollToItem(items.indexOf(item))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -148,8 +143,7 @@ private fun FeaturedCarouselCard(
|
||||
item: MetadataBrowseItem,
|
||||
isCentered: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onScrollToCenter: () -> Unit = { },
|
||||
) {
|
||||
onScrollToCenter: () -> Unit = { }) {
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
val audioPlayerQueue = koinInject<AudioPlayerQueue>()
|
||||
|
||||
@ -204,25 +198,21 @@ private fun FeaturedCarouselCard(
|
||||
}
|
||||
}
|
||||
.aspectRatio(1f)
|
||||
.hazeSource(hazeState),
|
||||
) {
|
||||
.hazeSource(hazeState)) {
|
||||
AsyncImage(
|
||||
model = thumbnailUrl,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = dimAlpha)),
|
||||
)
|
||||
.background(Color.Black.copy(alpha = dimAlpha)))
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
contentAlignment = Alignment.BottomCenter) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -231,43 +221,35 @@ private fun FeaturedCarouselCard(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.9f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Color.Black.copy(alpha = 0.9f)))))
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
contentAlignment = Alignment.BottomCenter) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
.padding(16.dp)) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = AppTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
@ -306,8 +288,7 @@ private fun FeaturedPlayButton(
|
||||
navigationCommands: NavigationCommands,
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
@ -320,7 +301,8 @@ private fun FeaturedPlayButton(
|
||||
modifier = modifier.size(44.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = {
|
||||
if (isPlaying) {
|
||||
navigationCommands.navigateTo(Routes.Album(item.data.id))
|
||||
@ -330,7 +312,7 @@ private fun FeaturedPlayButton(
|
||||
},
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
@ -347,7 +329,8 @@ private fun FeaturedPlayButton(
|
||||
modifier = modifier.size(44.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = {
|
||||
if (isPlaying) {
|
||||
navigationCommands.navigateTo(Routes.Playlist(item.data.id))
|
||||
@ -357,7 +340,7 @@ private fun FeaturedPlayButton(
|
||||
},
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
@ -377,20 +360,20 @@ private fun FeaturedPlayButton(
|
||||
modifier = modifier.size(44.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
audioPlayerQueue.load(
|
||||
entries = listOf(queueEntry),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
},
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
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.ExperimentalSharedTransitionApi
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@ -76,8 +78,7 @@ fun LikedTracksCard(
|
||||
modifier: Modifier = Modifier,
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
) {
|
||||
navigationCommands: NavigationCommands = koinInject()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
@ -92,8 +93,7 @@ fun LikedTracksCard(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
|
||||
.width(160.dp)
|
||||
.hoverable(interactionSource = interactionSource),
|
||||
) {
|
||||
.hoverable(interactionSource = interactionSource)) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
androidx.compose.foundation.Image(
|
||||
@ -106,10 +106,8 @@ fun LikedTracksCard(
|
||||
.sharedElementOrNone(
|
||||
key = "saved_tracks_art",
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope),
|
||||
contentScale = ContentScale.Crop)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
|
||||
@ -120,22 +118,24 @@ fun LikedTracksCard(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
|
||||
modifier = Modifier.size(30.dp)
|
||||
modifier = Modifier.size(30.dp),
|
||||
style = MillenniumIconButtonStyle.Secondary
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to queue",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FilledIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = { scope.launch { playbackHelper.playSavedTracks() } },
|
||||
modifier = Modifier.size(30.dp)
|
||||
modifier = Modifier.size(30.dp),
|
||||
style = MillenniumIconButtonStyle.Primary
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) {
|
||||
Iconsax.IconsaxPauseCircle
|
||||
} else Iconsax.Play,
|
||||
@ -152,18 +152,16 @@ fun LikedTracksCard(
|
||||
) {
|
||||
Text(
|
||||
text = "Liked Tracks",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Your saved songs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,8 +40,7 @@ fun PlaylistCard(
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||
) {
|
||||
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
@ -58,17 +57,14 @@ fun PlaylistCard(
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.Playlist,
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
)
|
||||
playlist.title)
|
||||
},
|
||||
onAddToQueue = {
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.Playlist,
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
)
|
||||
playlist.title)
|
||||
},
|
||||
modifier = modifier,
|
||||
sharedElementKey = "playlist_art_${playlist.id}",
|
||||
)
|
||||
sharedElementKey = "playlist_art_${playlist.id}")
|
||||
}
|
||||
|
||||
@ -35,13 +35,11 @@ val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?>
|
||||
fun Modifier.sharedElementOrNone(
|
||||
key: String?,
|
||||
sharedTransitionScope: SharedTransitionScope?,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?,
|
||||
): Modifier {
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?): Modifier {
|
||||
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
|
||||
with(sharedTransitionScope) {
|
||||
return this@sharedElementOrNone.sharedElement(
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -54,8 +51,7 @@ import org.koin.compose.koinInject
|
||||
fun TrackCard(
|
||||
track: MetadataTrack,
|
||||
modifier: Modifier = Modifier,
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
) {
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val queueEntry = QueueEntry.StreamingTrack(track = track, url = "")
|
||||
|
||||
@ -69,8 +65,7 @@ fun TrackCard(
|
||||
entries = listOf(queueEntry),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
},
|
||||
onAddToQueue = {
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
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.fadeIn
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import org.spotube.millennium.composables.MillenniumCard
|
||||
import org.spotube.millennium.composables.MillenniumCheckbox
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
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.LaunchedEffect
|
||||
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.layout.ContentScale
|
||||
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.TextDecoration
|
||||
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.common.Thumbnail
|
||||
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.TextWithShimmer
|
||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||
@ -122,7 +124,7 @@ enum class TrackSortOption(val label: String) {
|
||||
Duration("Duration"),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TrackList(
|
||||
tracks: List<MetadataTrack> = emptyList(),
|
||||
@ -153,9 +155,9 @@ fun TrackList(
|
||||
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
|
||||
simplified: Boolean = false,
|
||||
scrollable: Boolean = true,
|
||||
state: LazyListState? = null,
|
||||
) {
|
||||
var filterQuery by rememberSaveable { mutableStateOf("") }
|
||||
state: LazyListState? = null) {
|
||||
val filterState = rememberTextFieldState()
|
||||
val filterQuery = filterState.text.toString()
|
||||
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
||||
var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) }
|
||||
var isSelectionMode by rememberSaveable { mutableStateOf(false) }
|
||||
@ -207,20 +209,19 @@ fun TrackList(
|
||||
}
|
||||
}
|
||||
|
||||
val windowInfo = LocalWindowInfo.current
|
||||
val maxWidth = with(density) { windowInfo.containerSize.width.toDp() }
|
||||
val isCompact = maxWidth < CompactTrackListBreakpoint
|
||||
val widthBreakpoint = currentWindowWidthBreakpoint()
|
||||
val isCompact = widthBreakpoint isBelow WindowBreakpoints.Medium
|
||||
val isDesktop = remember { getPlatform().isDesktop() }
|
||||
val showIndex = maxWidth >= LargeTrackListBreakpoint
|
||||
val showIndex = widthBreakpoint isAtLeast WindowBreakpoints.Expanded
|
||||
val showAlbum = !isCompact
|
||||
val useDropdownForOptions = isDesktop && !isCompact
|
||||
|
||||
val trackCardContent: @Composable () -> Unit = {
|
||||
Card(
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -228,9 +229,7 @@ fun TrackList(
|
||||
) {
|
||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||
if (displayedIndex > 0) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator()
|
||||
}
|
||||
TrackListRow(
|
||||
index = displayedIndex + 1,
|
||||
@ -284,38 +283,31 @@ fun TrackList(
|
||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
|
||||
)
|
||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
|
||||
}
|
||||
|
||||
if (isLoading && tracks.isEmpty()) {
|
||||
repeat(ShimmerRowCount) { shimmerIndex ->
|
||||
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
ShimmerTrackListRow(
|
||||
index = shimmerIndex + 1,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions,
|
||||
)
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
TrackListFeedbackRow(
|
||||
message = error,
|
||||
isError = true,
|
||||
)
|
||||
isError = true)
|
||||
}
|
||||
|
||||
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
||||
@ -324,17 +316,14 @@ fun TrackList(
|
||||
|
||||
if (isLoadingNextPage) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
ShimmerTrackListRow(
|
||||
index = visibleTracks.size + 1,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions,
|
||||
)
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -345,19 +334,18 @@ fun TrackList(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||
.heightIn(max = 60.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) {
|
||||
if (showIndex || isSelectionMode) {
|
||||
val allSelected =
|
||||
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
|
||||
val anySelected = selectedTrackIds.isNotEmpty()
|
||||
val headerState = when {
|
||||
allSelected -> CheckBoxState.SELECTED
|
||||
anySelected -> CheckBoxState.INDETERMINATE
|
||||
else -> CheckBoxState.UNSELECTED
|
||||
allSelected -> ToggleableState.On
|
||||
anySelected -> ToggleableState.Indeterminate
|
||||
else -> ToggleableState.Off
|
||||
}
|
||||
CheckBox(
|
||||
state = headerState,
|
||||
MillenniumTriStateCheckbox(
|
||||
value = headerState,
|
||||
onClick = {
|
||||
if (allSelected) {
|
||||
isSelectionMode = false
|
||||
@ -367,58 +355,45 @@ fun TrackList(
|
||||
selectedTrackIds = visibleTracks.map { it.id }.toSet()
|
||||
}
|
||||
},
|
||||
)
|
||||
label = "")
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
TextField(
|
||||
value = filterQuery,
|
||||
onValueChange = { filterQuery = it },
|
||||
MillenniumTextField(
|
||||
state = filterState,
|
||||
label = "",
|
||||
placeholder = "Filter",
|
||||
modifier = Modifier
|
||||
.widthIn(max = 400.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
placeholder = { TextWithShimmer("Filter") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Filter",
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
.align(Alignment.CenterEnd))
|
||||
}
|
||||
|
||||
ButtonGroup {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = TrackSortOption.entries.map { option ->
|
||||
AdaptiveMenuItem(
|
||||
label = option.label,
|
||||
onClick = { sortBy = option },
|
||||
selected = sortBy == option,
|
||||
)
|
||||
selected = sortBy == option)
|
||||
},
|
||||
trigger = { onClick ->
|
||||
GroupIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxSort,
|
||||
contentDescription = "Sort",
|
||||
)
|
||||
contentDescription = "Sort")
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
|
||||
visibleTracks.filter { selectedTrackIds.contains(it.id) }
|
||||
} else {
|
||||
@ -433,54 +408,42 @@ fun TrackList(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxDirectboxReceive,
|
||||
label = if (isAll) "Download All" else "Download $trackCount",
|
||||
onClick = { onBulkDownload(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkDownload(targetTracks) }))
|
||||
if (!isJamGuest) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||
onClick = { onBulkAddToQueue(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkAddToQueue(targetTracks) }))
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||
onClick = { onBulkPlayNext(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkPlayNext(targetTracks) }))
|
||||
}
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicPlaylist,
|
||||
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||
onClick = { onBulkAddToPlaylist(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkAddToPlaylist(targetTracks) }))
|
||||
} + if (isInJam) {
|
||||
listOf(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
|
||||
onClick = { onBulkAddToJam(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkAddToJam(targetTracks) }))
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
trigger = { onClick ->
|
||||
GroupIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "Bulk actions",
|
||||
)
|
||||
contentDescription = "Bulk actions")
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -495,8 +458,7 @@ fun TrackList(
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (headerContent != null) {
|
||||
item {
|
||||
headerContent()
|
||||
@ -525,8 +487,7 @@ fun TrackList(
|
||||
},
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
)
|
||||
isJamGuest = isJamGuest)
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
||||
@ -536,8 +497,7 @@ fun TrackList(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.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) {
|
||||
headerContent()
|
||||
}
|
||||
@ -560,8 +520,7 @@ fun TrackList(
|
||||
onTrackOptionsAction(track, action)
|
||||
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,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val rowTheme = LocalBaseUITheme.current.listRowTile
|
||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
||||
modifier: Modifier = Modifier) {
|
||||
val artworkInteractionSource = remember { MutableInteractionSource() }
|
||||
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
|
||||
val rowInteractionSource = remember { MutableInteractionSource() }
|
||||
val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
|
||||
val rowBackgroundColor = when {
|
||||
isSelected -> rowTheme.background.selected
|
||||
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
|
||||
isCurrentTrack -> rowTheme.background.focused
|
||||
trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f))
|
||||
else -> rowTheme.background.normal
|
||||
isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||
isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer
|
||||
isCurrentTrack -> AppTheme.colors.surfaceVariant
|
||||
trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val highlightColor = Color.White.copy(
|
||||
alpha = if (isLight) 0.9f else 0.06f
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.hoverable(rowInteractionSource)
|
||||
.combinedClickable(
|
||||
onClick = onTrackClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.background(rowBackgroundColor)
|
||||
.then(
|
||||
if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) {
|
||||
Modifier.highlight(highlightColor)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.padding(end = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (isSelectionMode) {
|
||||
CheckBox(
|
||||
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
||||
onClick = { onSelectionToggle(!isSelected) },
|
||||
modifier = Modifier.shimmerApply()
|
||||
)
|
||||
} else if (showIndex) {
|
||||
Box(
|
||||
modifier = Modifier.width(30.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = isRowHovered,
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
label = "index-checkbox",
|
||||
) { hovered ->
|
||||
if (hovered) {
|
||||
CheckBox(
|
||||
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
||||
onClick = { onSelectionToggle(!isSelected) },
|
||||
modifier = Modifier.shimmerApply()
|
||||
)
|
||||
} else {
|
||||
TextWithShimmer(
|
||||
text = index.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.hoverable(interactionSource = artworkInteractionSource),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
||||
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
|
||||
val platformContext = LocalPlatformContext.current
|
||||
val imageRequest = remember(imageUrl) {
|
||||
ImageRequest.Builder(platformContext)
|
||||
.data(imageUrl)
|
||||
.size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size.
|
||||
.crossfade(false) // Crucial for scroll performance
|
||||
.build()
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = track.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().shimmerApply(),
|
||||
)
|
||||
|
||||
if (isArtworkHovered || isCurrentTrack) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.10f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp).shimmerApply(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1.2f)) {
|
||||
MillenniumListTile(
|
||||
modifier = modifier,
|
||||
selected = isSelected || isCurrentTrack,
|
||||
onClick = onTrackClick,
|
||||
title = {
|
||||
TextWithShimmer(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
subtitle = {
|
||||
ArtistChips(
|
||||
artists = track.artists,
|
||||
onArtistClick = onArtistClick,
|
||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||
)
|
||||
}
|
||||
|
||||
if (showAlbum && track.album != null) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
onArtistsOverflowClick = onArtistsOverflowClick)
|
||||
},
|
||||
leading = {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
TextWithShimmer(
|
||||
text = track.album!!.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.hoverable(interactionSource)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = interactionSource
|
||||
) { onAlbumClick(track.album!!) }
|
||||
// Hide ripple and show underline on hover/click
|
||||
.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (isSelectionMode) {
|
||||
MillenniumCheckbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onSelectionToggle(it) },
|
||||
label = "",
|
||||
modifier = Modifier.shimmerApply())
|
||||
} else if (showIndex) {
|
||||
Box(
|
||||
modifier = Modifier.width(30.dp),
|
||||
contentAlignment = Alignment.Center) {
|
||||
AnimatedContent(
|
||||
targetState = isRowHovered,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "index-checkbox") { hovered ->
|
||||
if (hovered) {
|
||||
MillenniumCheckbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onSelectionToggle(it) },
|
||||
label = "",
|
||||
modifier = Modifier.shimmerApply())
|
||||
} else {
|
||||
Modifier
|
||||
TextWithShimmer(
|
||||
text = index.toString(),
|
||||
style = AppTheme.typography.labelMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextWithShimmer(
|
||||
text = track.durationMs.toDurationString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.End,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(56.dp),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(AppTheme.shapes.small)
|
||||
.hoverable(interactionSource = artworkInteractionSource),
|
||||
contentAlignment = Alignment.Center) {
|
||||
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
|
||||
val platformContext = LocalPlatformContext.current
|
||||
val imageRequest = remember(imageUrl) {
|
||||
ImageRequest.Builder(platformContext)
|
||||
.data(imageUrl)
|
||||
.size(128)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
if (useDropdownForOptions) {
|
||||
TrackOptions(
|
||||
track = track,
|
||||
state = trackOptionsState,
|
||||
onAction = onTrackOptionsAction,
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
)
|
||||
} else {
|
||||
GhostIconButton(onClick = onShowOptionsClick) {
|
||||
Icon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "Track options",
|
||||
)
|
||||
AsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = track.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().shimmerApply())
|
||||
|
||||
if (isArtworkHovered || isCurrentTrack) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.10f)),
|
||||
contentAlignment = Alignment.Center) {
|
||||
MillenniumIcon(
|
||||
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp).shimmerApply())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
trailing = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (showAlbum && track.album != null) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val colorScheme = AppTheme.colors
|
||||
|
||||
TextWithShimmer(
|
||||
text = track.album!!.title,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 220.dp)
|
||||
.hoverable(interactionSource)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = interactionSource) { onAlbumClick(track.album!!) }
|
||||
.then(
|
||||
if (isHovered || isPressed) {
|
||||
Modifier.drawWithCache {
|
||||
val underlineHeight = 1.dp.toPx()
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawLine(
|
||||
color = colorScheme.primary,
|
||||
start = Offset(0f, size.height - underlineHeight),
|
||||
end = Offset(size.width, size.height - underlineHeight),
|
||||
strokeWidth = underlineHeight)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}))
|
||||
}
|
||||
|
||||
TextWithShimmer(
|
||||
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
|
||||
private fun ArtistChips(
|
||||
artists: List<MetadataArtist.Basic>,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onArtistsOverflowClick: () -> Unit,
|
||||
) {
|
||||
onArtistsOverflowClick: () -> Unit) {
|
||||
val visibleArtists = artists.take(2)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
visibleArtists.forEachIndexed { index, artist ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
TextWithShimmer(
|
||||
text = artist.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null,
|
||||
@ -827,13 +749,12 @@ private fun ArtistChips(
|
||||
indication = null,
|
||||
interactionSource = interactionSource
|
||||
) { onArtistClick(artist) }
|
||||
,
|
||||
)
|
||||
)
|
||||
if (index < visibleArtists.size - 1)
|
||||
Text(
|
||||
",",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -842,12 +763,11 @@ private fun ArtistChips(
|
||||
if (remaining > 0) {
|
||||
TextWithShimmer(
|
||||
text = "+$remaining more",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtistsOverflowClick),
|
||||
)
|
||||
modifier = Modifier.clickable(onClick = onArtistsOverflowClick))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -857,8 +777,7 @@ private fun ShimmerTrackListRow(
|
||||
index: Int,
|
||||
showIndex: Boolean,
|
||||
showAlbum: Boolean,
|
||||
useDropdownForOptions: Boolean,
|
||||
) {
|
||||
useDropdownForOptions: Boolean) {
|
||||
val dummyTrack = remember(index) { dummyTrackForShimmer(index) }
|
||||
SkeletonTree(true) {
|
||||
TrackListRow(
|
||||
@ -881,8 +800,7 @@ private fun ShimmerTrackListRow(
|
||||
onShowOptionsClick = {},
|
||||
onArtistClick = {},
|
||||
onAlbumClick = {},
|
||||
onArtistsOverflowClick = {},
|
||||
)
|
||||
onArtistsOverflowClick = {})
|
||||
}
|
||||
}
|
||||
|
||||
@ -891,8 +809,7 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
|
||||
id = "shimmer_artist_$index",
|
||||
name = "Artist Name",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
)
|
||||
externalUri = null)
|
||||
return MetadataTrack(
|
||||
id = "shimmer_track_$index",
|
||||
title = "Track Title Here",
|
||||
@ -913,29 +830,25 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
|
||||
thumbnails = emptyList(),
|
||||
albumType = MetadataAlbumType.Album,
|
||||
artists = listOf(artist),
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
thumbnails = null,
|
||||
explicit = false,
|
||||
popularity = 0,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
)
|
||||
externalUri = null)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackListFeedbackRow(
|
||||
message: String,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
isError: Boolean = false) {
|
||||
TextWithShimmer(
|
||||
text = message,
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp))
|
||||
}
|
||||
|
||||
private fun Long.toDurationString(): String {
|
||||
@ -951,21 +864,17 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
id = "artist_1",
|
||||
name = "Aria Vale",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
MetadataArtist.Basic(
|
||||
id = "artist_2",
|
||||
name = "Neon Harbor",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
MetadataArtist.Basic(
|
||||
id = "artist_3",
|
||||
name = "Echo Drift",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
),
|
||||
)
|
||||
externalUri = null))
|
||||
|
||||
val album = MetadataAlbum.Detailed(
|
||||
releaseDate = null,
|
||||
@ -978,13 +887,11 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
Thumbnail(
|
||||
url = "https://picsum.photos/120",
|
||||
width = 120,
|
||||
height = 120,
|
||||
)
|
||||
height = 120)
|
||||
),
|
||||
albumType = MetadataAlbumType.Album,
|
||||
artists = artists.take(2),
|
||||
externalUri = null,
|
||||
)
|
||||
externalUri = null)
|
||||
|
||||
return listOf(
|
||||
MetadataTrack(
|
||||
@ -999,8 +906,7 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
explicit = false,
|
||||
popularity = 92,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
MetadataTrack(
|
||||
id = "track_2",
|
||||
title = "Static Horizon",
|
||||
@ -1013,8 +919,7 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
popularity = 80,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
thumbnails = null,
|
||||
),
|
||||
thumbnails = null),
|
||||
MetadataTrack(
|
||||
id = "track_3",
|
||||
title = "Last Train Echo",
|
||||
@ -1027,19 +932,16 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
popularity = 77,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
thumbnails = null,
|
||||
),
|
||||
)
|
||||
thumbnails = null))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
private fun TrackListPreview() {
|
||||
Scaffold {
|
||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||
TrackList(
|
||||
tracks = previewTracks(),
|
||||
hasMore = true,
|
||||
isLoadingNextPage = true,
|
||||
)
|
||||
isLoadingNextPage = true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,13 @@
|
||||
|
||||
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.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -38,8 +44,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
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.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
@ -72,8 +76,7 @@ data class TrackOptionsState(
|
||||
val isInQueue: Boolean = false,
|
||||
val isCurrentlyPlaying: Boolean = false,
|
||||
val isFavorite: Boolean = false,
|
||||
val isBlacklisted: Boolean = false,
|
||||
)
|
||||
val isBlacklisted: Boolean = false)
|
||||
|
||||
data class TrackOptionsContext(
|
||||
val currentTrackId: String? = null,
|
||||
@ -81,15 +84,13 @@ data class TrackOptionsContext(
|
||||
val savedTrackIds: Set<String> = emptySet(),
|
||||
val blacklistedTrackIds: Set<String> = emptySet(),
|
||||
val blacklistedArtistIds: Set<String> = emptySet(),
|
||||
val forceFavorite: Boolean = false,
|
||||
) {
|
||||
val forceFavorite: Boolean = false) {
|
||||
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
|
||||
isInQueue = track.id in queueTrackIds,
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = forceFavorite || track.id in savedTrackIds,
|
||||
isBlacklisted = track.id in blacklistedTrackIds ||
|
||||
track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
track.artists.any { it.id in blacklistedArtistIds })
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -100,8 +101,7 @@ fun TrackOptions(
|
||||
onAlbumClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
) {
|
||||
isJamGuest: Boolean = false) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = buildTrackMenuItems(
|
||||
track = track,
|
||||
@ -109,11 +109,12 @@ fun TrackOptions(
|
||||
onAction = onAction,
|
||||
onAlbumClick = onAlbumClick,
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
),
|
||||
isJamGuest = isJamGuest),
|
||||
trigger = { onClick ->
|
||||
GhostIconButton(onClick = onClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "Track options",
|
||||
modifier = Modifier.shimmerApply()
|
||||
@ -122,11 +123,9 @@ fun TrackOptions(
|
||||
},
|
||||
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
|
||||
header = { TrackOptionsSheetHeader(track = track) },
|
||||
modifier = modifier,
|
||||
)
|
||||
modifier = modifier)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrackOptionsBottomSheet(
|
||||
track: MetadataTrack,
|
||||
@ -135,9 +134,14 @@ fun TrackOptionsBottomSheet(
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
isJamGuest: Boolean = false) {
|
||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||
LaunchedEffect(sheetState.currentDetent) {
|
||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
MillenniumModalBottomSheet(state = sheetState) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
TrackOptionsSheetHeader(track = track)
|
||||
|
||||
@ -145,8 +149,7 @@ fun TrackOptionsBottomSheet(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
buildTrackMenuItems(
|
||||
track = track,
|
||||
state = state,
|
||||
@ -159,40 +162,35 @@ fun TrackOptionsBottomSheet(
|
||||
onDismiss()
|
||||
},
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
).forEach { item ->
|
||||
isJamGuest = isJamGuest).forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
enabled = item.enabled,
|
||||
onClick = item.onClick,
|
||||
)
|
||||
onClick = item.onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item.icon?.let { icon ->
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
}
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -207,39 +205,34 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) {
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
AsyncImage(
|
||||
model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
)
|
||||
.clip(AppTheme.shapes.small))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = AppTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
text = track.artists.joinToString(", ") { it.name },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
track.album?.let { album ->
|
||||
Text(
|
||||
text = album.title,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -251,25 +244,20 @@ private fun buildTrackMenuItems(
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
): List<AdaptiveMenuItem> = buildList {
|
||||
isJamGuest: Boolean = false): List<AdaptiveMenuItem> = buildList {
|
||||
if (isInJam) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = "Add to Jam",
|
||||
onClick = { onAction(TrackOptionsAction.AddToJam) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.AddToJam) }))
|
||||
}
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicCircle,
|
||||
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
|
||||
// allowed, so queue actions are replaced by "Add to Jam".
|
||||
@ -279,17 +267,13 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = "Play next",
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) }))
|
||||
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = "Move to next",
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) }))
|
||||
}
|
||||
|
||||
if (!state.isInQueue) {
|
||||
@ -297,17 +281,13 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = "Add to queue",
|
||||
onClick = { onAction(TrackOptionsAction.AddToQueue) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.AddToQueue) }))
|
||||
} else {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicSquareRemove,
|
||||
label = "Remove from queue",
|
||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -315,42 +295,32 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,
|
||||
label = if (state.isFavorite) "Remove from favorites" else "Save as favorite",
|
||||
onClick = { onAction(TrackOptionsAction.ToggleFavorite) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.ToggleFavorite) }))
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicPlaylist,
|
||||
label = "Add to playlist",
|
||||
onClick = { onAction(TrackOptionsAction.AddToPlaylist) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.AddToPlaylist) }))
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxDirectboxReceive,
|
||||
label = "Download",
|
||||
onClick = { onAction(TrackOptionsAction.Download) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.Download) }))
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicSquareRemoveFilled,
|
||||
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
|
||||
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }))
|
||||
|
||||
if (!track.externalUri.isNullOrBlank()) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxShare,
|
||||
label = "Share",
|
||||
onClick = { onAction(TrackOptionsAction.Share) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.Share) }))
|
||||
}
|
||||
|
||||
if (track.album != null) {
|
||||
@ -358,8 +328,6 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxCd,
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -44,8 +44,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
|
||||
@Composable
|
||||
fun UserCard(
|
||||
user: MetadataUser,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
AvatarCard(
|
||||
title = user.displayName ?: user.username,
|
||||
subtitle = "User",
|
||||
@ -53,7 +52,6 @@ fun UserCard(
|
||||
onClick = {
|
||||
// TODO: Route to user details screen.
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
modifier = modifier)
|
||||
}
|
||||
|
||||
|
||||
@ -28,8 +28,7 @@ import dev.krtirtho.spotube.isDesktop
|
||||
@Composable
|
||||
expect fun VerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
modifier: Modifier = Modifier)
|
||||
|
||||
fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -58,10 +58,9 @@ fun AvatarCard(
|
||||
imageURL: String? = null,
|
||||
onClick: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
isBlacklisted: Boolean = false,
|
||||
) {
|
||||
isBlacklisted: Boolean = false) {
|
||||
val backgroundColor = if (isBlacklisted) {
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
|
||||
AppTheme.colors.error.copy(alpha = 0.1f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
@ -71,20 +70,17 @@ fun AvatarCard(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
||||
.width(160.dp),
|
||||
) {
|
||||
.width(160.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CircleShape)
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
@ -93,27 +89,24 @@ fun AvatarCard(
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.shimmerApply(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
||||
) {
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
subtitle?.let {
|
||||
TextWithShimmer(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
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.ExperimentalSharedTransitionApi
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.unit.dp
|
||||
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.LocalSharedTransitionScope
|
||||
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.IconsaxPause
|
||||
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)
|
||||
@Composable
|
||||
@ -75,8 +79,7 @@ fun PlayableCard(
|
||||
onPlay: (() -> Unit)? = null,
|
||||
onAddToQueue: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
sharedElementKey: String? = null,
|
||||
) {
|
||||
sharedElementKey: String? = null) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||
@ -86,8 +89,7 @@ fun PlayableCard(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = { onClick?.invoke() }, enabled = true)
|
||||
.width(160.dp)
|
||||
.hoverable(interactionSource = interactionSource),
|
||||
) {
|
||||
.hoverable(interactionSource = interactionSource)) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
|
||||
AsyncImage(
|
||||
@ -100,10 +102,8 @@ fun PlayableCard(
|
||||
.sharedElementOrNone(
|
||||
key = sharedElementKey,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope),
|
||||
contentScale = ContentScale.Crop)
|
||||
|
||||
if (onPlay != null || onAddToQueue != null)
|
||||
Box(
|
||||
@ -112,48 +112,45 @@ fun PlayableCard(
|
||||
) {
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
this@Column.AnimatedVisibility(
|
||||
visible = isHovered,
|
||||
visible = isHovered || (isPlaying && onPlay != null),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
exit = fadeOut()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
onAddToQueue?.let {
|
||||
SecondaryIconButton(
|
||||
// While this collection is playing the play/pause toggle stays
|
||||
// visible and the add-to-queue action is hidden entirely.
|
||||
onAddToQueue?.takeIf { !isPlaying }?.let {
|
||||
MillenniumIconButton(
|
||||
onClick = onAddToQueue,
|
||||
modifier = Modifier.size(30.dp),
|
||||
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
||||
shape = BaseUITheme.InteractionState.fromSingleValue(
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
modifier = Modifier.size(34.dp),
|
||||
style = MillenniumIconButtonStyle.Secondary
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
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 {
|
||||
PrimaryIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onPlay,
|
||||
modifier = Modifier.size(30.dp),
|
||||
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
||||
shape = BaseUITheme.InteractionState.fromSingleValue(
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
modifier = Modifier.size(34.dp),
|
||||
style = MillenniumIconButtonStyle.Primary
|
||||
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) {
|
||||
Iconsax.IconsaxPause
|
||||
} 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(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
subtitle?.let {
|
||||
TextWithShimmer(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -185,11 +180,10 @@ fun PlayableCard(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun PlayableCardPreview() {
|
||||
Scaffold {
|
||||
Box {
|
||||
PlayableCard(
|
||||
title = "Sample Title",
|
||||
subtitle = "Sample Subtitle",
|
||||
|
||||
@ -45,8 +45,7 @@ fun Coverflow(
|
||||
modifier: Modifier = Modifier,
|
||||
state: CoverflowState = rememberCoverflowState(),
|
||||
params: CoverflowParams = CoverflowParams(),
|
||||
content: CoverflowScope.() -> Unit,
|
||||
) {
|
||||
content: CoverflowScope.() -> Unit) {
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
var geometry by remember { mutableStateOf<Geometry?>(null) }
|
||||
|
||||
@ -55,8 +54,7 @@ fun Coverflow(
|
||||
val g = geometry ?: return@LaunchedEffect
|
||||
state.lazyListState.scrollToItem(
|
||||
state.selectedIndex + 1,
|
||||
-g.spacerWidth,
|
||||
)
|
||||
-g.spacerWidth)
|
||||
state.initialScrollDone = true
|
||||
}
|
||||
|
||||
@ -69,8 +67,7 @@ fun Coverflow(
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
state = state.lazyListState,
|
||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState),
|
||||
) {
|
||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) {
|
||||
if (size != IntSize.Zero) {
|
||||
val g = Geometry(params, size)
|
||||
geometry = g
|
||||
@ -79,8 +76,7 @@ fun Coverflow(
|
||||
val coverflowScope = CoverflowScopeImpl(
|
||||
geometry = g,
|
||||
lazyListScope = this,
|
||||
coverflowState = state,
|
||||
)
|
||||
coverflowState = state)
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() }))
|
||||
@ -102,15 +98,13 @@ fun Coverflow(
|
||||
if (state.selectedIndex > 0) {
|
||||
state.scrollToItem(state.selectedIndex - 1)
|
||||
}
|
||||
},
|
||||
) {}
|
||||
}) {}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.CenterEnd)
|
||||
.clickable {
|
||||
state.scrollToItem(state.selectedIndex + 1)
|
||||
},
|
||||
) {}
|
||||
}) {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,8 +25,7 @@ import kotlin.math.min
|
||||
@Stable
|
||||
internal class Geometry(
|
||||
internal val params: CoverflowParams = CoverflowParams(),
|
||||
private val size: IntSize = IntSize.Zero,
|
||||
) {
|
||||
private val size: IntSize = IntSize.Zero) {
|
||||
@Stable
|
||||
private val shortEdge
|
||||
get() = min(size.width, size.height)
|
||||
|
||||
@ -41,8 +41,7 @@ internal fun CoverflowItem(
|
||||
geometry: Geometry,
|
||||
onClickHandler: () -> Unit = {},
|
||||
onSelectedHandler: (Boolean) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
content: @Composable () -> Unit) {
|
||||
var horizontalPosition by remember { mutableStateOf<Float?>(null) }
|
||||
val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f)
|
||||
|
||||
@ -53,8 +52,7 @@ internal fun CoverflowItem(
|
||||
.onGloballyPositioned { coordinates ->
|
||||
horizontalPosition = coordinates.positionInParent().x
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
if (horizontalPosition != null) {
|
||||
onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f))
|
||||
|
||||
@ -65,20 +63,16 @@ internal fun CoverflowItem(
|
||||
rotationY = geometry.rotation(distanceToCenter),
|
||||
translationX = geometry.translationX(distanceToCenter),
|
||||
scaleY = geometry.scale(distanceToCenter),
|
||||
scaleX = geometry.scale(distanceToCenter),
|
||||
),
|
||||
propagateMinConstraints = true,
|
||||
) {
|
||||
scaleX = geometry.scale(distanceToCenter)),
|
||||
propagateMinConstraints = true) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
if (geometry.params.mirror) {
|
||||
Mirror(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClickHandler,
|
||||
),
|
||||
coverSize = geometry.coverSize,
|
||||
) {
|
||||
onClick = onClickHandler),
|
||||
coverSize = geometry.coverSize) {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
@ -86,9 +80,7 @@ internal fun CoverflowItem(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClickHandler,
|
||||
),
|
||||
) {
|
||||
onClick = onClickHandler)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,5 +26,4 @@ data class CoverflowParams(
|
||||
val angle: Float = 45f,
|
||||
val shift: Float = 0.4f,
|
||||
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 -> },
|
||||
key: ((index: Int) -> Any)? = null,
|
||||
contentType: (index: Int) -> Any? = { null },
|
||||
itemContent: @Composable ((item: T) -> Unit),
|
||||
)
|
||||
itemContent: @Composable ((item: T) -> Unit))
|
||||
|
||||
fun items(
|
||||
count: Int,
|
||||
onSelectHandler: (index: Int) -> Unit = {},
|
||||
key: ((index: Int) -> Any)? = null,
|
||||
contentType: (index: Int) -> Any? = { null },
|
||||
itemContent: @Composable ((index: Int) -> Unit),
|
||||
)
|
||||
itemContent: @Composable ((index: Int) -> Unit))
|
||||
}
|
||||
|
||||
internal class CoverflowScopeImpl(
|
||||
private val geometry: Geometry,
|
||||
private val lazyListScope: LazyListScope,
|
||||
private val coverflowState: CoverflowState,
|
||||
) : CoverflowScope {
|
||||
private val coverflowState: CoverflowState) : CoverflowScope {
|
||||
override fun <T> items(
|
||||
items: List<T>,
|
||||
onSelectHandler: (item: T, index: Int) -> Unit,
|
||||
key: ((index: Int) -> Any)?,
|
||||
contentType: (index: Int) -> Any?,
|
||||
itemContent: @Composable (item: T) -> Unit,
|
||||
) = items(
|
||||
itemContent: @Composable (item: T) -> Unit) = items(
|
||||
count = items.size,
|
||||
onSelectHandler = {
|
||||
onSelectHandler(items[it], it)
|
||||
},
|
||||
key = key,
|
||||
contentType = contentType,
|
||||
) {
|
||||
contentType = contentType) {
|
||||
itemContent(items[it])
|
||||
}
|
||||
|
||||
@ -65,12 +60,10 @@ internal class CoverflowScopeImpl(
|
||||
onSelectHandler: (index: Int) -> Unit,
|
||||
key: ((index: Int) -> Any)?,
|
||||
contentType: (index: Int) -> Any?,
|
||||
itemContent: @Composable (index: Int) -> Unit,
|
||||
) = lazyListScope.items(
|
||||
itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items(
|
||||
count,
|
||||
key,
|
||||
contentType,
|
||||
) {
|
||||
contentType) {
|
||||
CoverflowItem(
|
||||
onClickHandler = { coverflowState.scrollToItem(it) },
|
||||
onSelectedHandler = { isSelected: Boolean ->
|
||||
@ -78,16 +71,14 @@ internal class CoverflowScopeImpl(
|
||||
onSelectHandler(it)
|
||||
}
|
||||
},
|
||||
geometry = geometry,
|
||||
) {
|
||||
geometry = geometry) {
|
||||
itemContent(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectHandler(
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
): Boolean {
|
||||
isSelected: Boolean): Boolean {
|
||||
if (!isSelected && index == coverflowState.selectedIndex) {
|
||||
coverflowState.selectedIndex = -1
|
||||
} else if (isSelected && index != coverflowState.selectedIndex) {
|
||||
|
||||
@ -28,19 +28,16 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun rememberCoverflowState(
|
||||
initialIndex: Int = 0,
|
||||
onSelectHandler: (Int) -> Unit = {},
|
||||
): CoverflowState {
|
||||
onSelectHandler: (Int) -> Unit = {}): CoverflowState {
|
||||
val lazyListState: LazyListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = initialIndex + 1,
|
||||
)
|
||||
initialFirstVisibleItemIndex = initialIndex + 1)
|
||||
val coroutineScope: CoroutineScope = rememberCoroutineScope()
|
||||
return remember {
|
||||
CoverflowState(
|
||||
lazyListState,
|
||||
coroutineScope,
|
||||
onSelectHandler,
|
||||
initialIndex,
|
||||
)
|
||||
initialIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,8 +45,7 @@ class CoverflowState internal constructor(
|
||||
internal val lazyListState: LazyListState,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val onSelectHandler: (Int) -> Unit = {},
|
||||
initialIndex: Int = 0,
|
||||
) {
|
||||
initialIndex: Int = 0) {
|
||||
internal var geometry: Geometry? = null
|
||||
internal var initialScrollDone: Boolean = false
|
||||
|
||||
@ -65,8 +61,7 @@ class CoverflowState internal constructor(
|
||||
coroutineScope.launch {
|
||||
lazyListState.animateScrollToItem(
|
||||
index + 1,
|
||||
-geometryCopy.spacerWidth,
|
||||
)
|
||||
-geometryCopy.spacerWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,23 +48,20 @@ import androidx.compose.ui.unit.times
|
||||
internal fun Mirror(
|
||||
modifier: Modifier = Modifier,
|
||||
coverSize: Float,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
content: @Composable () -> Unit) {
|
||||
val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(top = coverSizeDp)
|
||||
.fillMaxWidth()
|
||||
.requiredHeight(2 * coverSizeDp),
|
||||
) {
|
||||
.requiredHeight(2 * coverSizeDp)) {
|
||||
Box(
|
||||
propagateMinConstraints = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.height(coverSizeDp),
|
||||
) {
|
||||
.height(coverSizeDp)) {
|
||||
content()
|
||||
}
|
||||
Box(
|
||||
@ -75,34 +72,27 @@ internal fun Mirror(
|
||||
.height(coverSizeDp)
|
||||
.graphicsLayer(
|
||||
compositingStrategy = CompositingStrategy.Offscreen,
|
||||
rotationX = 180f,
|
||||
)
|
||||
rotationX = 180f)
|
||||
.clip(HalfSizeShape)
|
||||
.drawWithContent {
|
||||
val colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Transparent,
|
||||
Color.Black,
|
||||
)
|
||||
Color.Black)
|
||||
drawContent()
|
||||
drawRect(
|
||||
topLeft = Offset(
|
||||
0f,
|
||||
this.size.height / 2,
|
||||
),
|
||||
this.size.height / 2),
|
||||
size = Size(
|
||||
this.size.width,
|
||||
this.size.height / 2,
|
||||
),
|
||||
this.size.height / 2),
|
||||
brush = Brush.verticalGradient(colors),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
blendMode = BlendMode.DstIn)
|
||||
}
|
||||
.blur(
|
||||
radiusX = 1.dp,
|
||||
radiusY = 3.dp,
|
||||
),
|
||||
) {
|
||||
radiusY = 3.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@ -112,17 +102,12 @@ private object HalfSizeShape : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline = Outline.Rectangle(
|
||||
density: Density): Outline = Outline.Rectangle(
|
||||
Rect(
|
||||
Offset(
|
||||
0f,
|
||||
size.height / 2,
|
||||
),
|
||||
size.height / 2),
|
||||
Size(
|
||||
size.width,
|
||||
size.height,
|
||||
),
|
||||
),
|
||||
)
|
||||
size.height)))
|
||||
}
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.misc
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import com.composeunstyled.LocalTextStyle
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import com.eygraber.compose.placeholder.material3.placeholder
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
@ -84,11 +84,7 @@ fun Modifier.shimmerApply(
|
||||
): Modifier = composed {
|
||||
val isTreeLoading = LocalSkeletonLoading.current
|
||||
|
||||
this.placeholder(
|
||||
visible = isTreeLoading,
|
||||
color = color,
|
||||
shape = null,
|
||||
)
|
||||
if (isTreeLoading) this.background(color) else this
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -110,23 +106,23 @@ fun TextWithShimmer(
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
minLines: Int = 1,
|
||||
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,
|
||||
color = color,
|
||||
color = if (isLoadingSkeleton) Color.Transparent else color,
|
||||
modifier = Modifier.shimmerApply().then(modifier),
|
||||
autoSize = autoSize,
|
||||
fontSize = fontSize,
|
||||
fontStyle = fontStyle,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
letterSpacing = letterSpacing,
|
||||
textDecoration = textDecoration,
|
||||
textAlign = textAlign,
|
||||
textAlign = textAlign ?: TextAlign.Start,
|
||||
lineHeight = lineHeight,
|
||||
overflow = overflow,
|
||||
softWrap = softWrap,
|
||||
maxLines = maxLines,
|
||||
minLines = minLines,
|
||||
onTextLayout = onTextLayout,
|
||||
|
||||
@ -18,39 +18,202 @@
|
||||
package dev.krtirtho.spotube.core.ui.theming
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.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
|
||||
fun SpotubeTheme(
|
||||
settings: UserSettings,
|
||||
content: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val isDarkTheme = when (settings.theme) {
|
||||
Theme.LIGHT -> false
|
||||
Theme.DARK -> true
|
||||
Theme.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
val palette: MillenniumPalette = settings.palette
|
||||
|
||||
val accent = if (isDarkTheme) {
|
||||
settings.accentColor.toDarkColor()
|
||||
} else {
|
||||
settings.accentColor.toLightColor()
|
||||
}
|
||||
|
||||
val colorScheme = rememberDynamicColorScheme(
|
||||
seedColor = accent,
|
||||
isDark = isDarkTheme,
|
||||
)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
) {
|
||||
Surface(content = content)
|
||||
MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) {
|
||||
val colors = appColorsFrom(MillenniumTheme.colors)
|
||||
MillenniumTooltipHost {
|
||||
CompositionLocalProvider(
|
||||
LocalAppColors provides colors,
|
||||
LocalAppTypography provides AppDefaultTypography,
|
||||
LocalAppShapes provides AppDefaultShapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the active Millennium appearance is light. */
|
||||
@Composable
|
||||
fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f
|
||||
|
||||
@ -1,221 +1,230 @@
|
||||
/*
|
||||
* 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.webview
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronLeft
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.X
|
||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
||||
import dev.nucleusframework.webview.jsbridge.JsMessage
|
||||
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
||||
import dev.nucleusframework.webview.web.WebView
|
||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
||||
|
||||
class PostMessageHandler(
|
||||
private val onMessageReceived: (String) -> Unit = {}
|
||||
) : IJsMessageHandler {
|
||||
override fun methodName(): String {
|
||||
return "sendMessage"
|
||||
}
|
||||
|
||||
override fun handle(
|
||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||
) {
|
||||
onMessageReceived(message.params)
|
||||
callback(message.params)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
if (webViewController.getContent() == null) {
|
||||
// This should never happen, but just in case
|
||||
Text("No URL to load")
|
||||
return
|
||||
}
|
||||
|
||||
val state = remember {
|
||||
WebViewState(
|
||||
webViewController.getWebContent(
|
||||
additionalHttpHeaders = mapOf(
|
||||
"User-Agent" to UserAgents.random()
|
||||
)
|
||||
)
|
||||
)
|
||||
}.apply {
|
||||
this.content = webViewController.getWebContent()
|
||||
platformWebviewConfig(this, webViewController.currentPluginId)
|
||||
}
|
||||
|
||||
val navigator = rememberWebViewNavigator()
|
||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||
|
||||
val bridgeBootstrapScript = remember {
|
||||
"""
|
||||
(function() {
|
||||
if (typeof window.sendMessage !== "function") {
|
||||
window.sendMessage = function(message) {
|
||||
if (typeof message !== "string") {
|
||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||
}
|
||||
window.kmpJsBridge.callNative("sendMessage", message);
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.bridgeReady) {
|
||||
const event = new CustomEvent("onBridgeReady");
|
||||
window.dispatchEvent(event);
|
||||
window.bridgeReady = true;
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
||||
if (url != null) {
|
||||
webViewController.emitUrlChange(url)
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
webViewController.emitWebViewCreated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.cookieManager, navigator) {
|
||||
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
||||
webViewController.webViewNavigator = navigator
|
||||
}
|
||||
|
||||
LaunchedEffect(webViewBridge) {
|
||||
webViewBridge.register(PostMessageHandler { message ->
|
||||
webViewController.emitPostMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webViewController.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier.height(56.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator.navigateBack()
|
||||
}, enabled = navigator.canGoBack
|
||||
) {
|
||||
Icon(
|
||||
FeatherIcons.ChevronLeft,
|
||||
contentDescription = "Go back to browser history"
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator.navigateForward()
|
||||
}, enabled = navigator.canGoForward
|
||||
) {
|
||||
Icon(
|
||||
FeatherIcons.ChevronRight,
|
||||
contentDescription = "Go forward to browser history"
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
||||
) {
|
||||
BasicTextField(
|
||||
value = state.lastLoadedUrl ?: "",
|
||||
onValueChange = {}, // Read-only
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
{ innerPadding ->
|
||||
WebView(
|
||||
state = state,
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||
navigator = navigator,
|
||||
webViewJsBridge = webViewBridge,
|
||||
onCreated = { webView ->
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
},
|
||||
)
|
||||
}
|
||||
/*
|
||||
* 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.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.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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronLeft
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.X
|
||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
||||
import dev.nucleusframework.webview.jsbridge.JsMessage
|
||||
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
||||
import dev.nucleusframework.webview.web.WebView
|
||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
||||
|
||||
class PostMessageHandler(
|
||||
private val onMessageReceived: (String) -> Unit = {}
|
||||
) : IJsMessageHandler {
|
||||
override fun methodName(): String {
|
||||
return "sendMessage"
|
||||
}
|
||||
|
||||
override fun handle(
|
||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||
) {
|
||||
onMessageReceived(message.params)
|
||||
callback(message.params)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
if (webViewController.getContent() == null) {
|
||||
// This should never happen, but just in case
|
||||
Text("No URL to load")
|
||||
return
|
||||
}
|
||||
|
||||
val state = remember {
|
||||
WebViewState(
|
||||
webViewController.getWebContent(
|
||||
additionalHttpHeaders = mapOf(
|
||||
"User-Agent" to UserAgents.random()
|
||||
)
|
||||
)
|
||||
)
|
||||
}.apply {
|
||||
this.content = webViewController.getWebContent()
|
||||
platformWebviewConfig(this, webViewController.currentPluginId)
|
||||
}
|
||||
|
||||
val navigator = rememberWebViewNavigator()
|
||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||
|
||||
val bridgeBootstrapScript = remember {
|
||||
"""
|
||||
(function() {
|
||||
if (typeof window.sendMessage !== "function") {
|
||||
window.sendMessage = function(message) {
|
||||
if (typeof message !== "string") {
|
||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||
}
|
||||
window.kmpJsBridge.callNative("sendMessage", message);
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.bridgeReady) {
|
||||
const event = new CustomEvent("onBridgeReady");
|
||||
window.dispatchEvent(event);
|
||||
window.bridgeReady = true;
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
||||
if (url != null) {
|
||||
webViewController.emitUrlChange(url)
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
webViewController.emitWebViewCreated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.cookieManager, navigator) {
|
||||
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
||||
webViewController.webViewNavigator = navigator
|
||||
}
|
||||
|
||||
LaunchedEffect(webViewBridge) {
|
||||
webViewBridge.register(PostMessageHandler { message ->
|
||||
webViewController.emitPostMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webViewController.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier.height(56.dp)
|
||||
) {
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
navigator.navigateBack()
|
||||
},
|
||||
enabled = navigator.canGoBack,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
FeatherIcons.ChevronLeft,
|
||||
contentDescription = "Go back to browser history"
|
||||
)
|
||||
}
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
navigator.navigateForward()
|
||||
},
|
||||
enabled = navigator.canGoForward,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
FeatherIcons.ChevronRight,
|
||||
contentDescription = "Go forward to browser history"
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(36.dp)
|
||||
.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(
|
||||
value = state.lastLoadedUrl ?: "",
|
||||
onValueChange = {}, // Read-only
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
textStyle = AppTheme.typography.bodyMedium.copy(
|
||||
color = AppTheme.colors.onSurface,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
WebView(
|
||||
state = state,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
navigator = navigator,
|
||||
webViewJsBridge = webViewBridge,
|
||||
onCreated = { webView ->
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -38,8 +38,7 @@ import org.koin.core.component.KoinComponent
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class BuiltInPluginService(
|
||||
private val pluginInfo: PluginEntry,
|
||||
) : PluginService, KoinComponent {
|
||||
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
||||
|
||||
override val pluginId: String = pluginInfo.id
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
@ -58,7 +57,6 @@ class BuiltInPluginService(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override suspend fun start() {
|
||||
when (pluginInfo) {
|
||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {
|
||||
|
||||
@ -1,347 +1,344 @@
|
||||
/*
|
||||
* 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.zipline
|
||||
|
||||
import app.cash.zipline.Zipline
|
||||
import app.cash.zipline.ZiplineService
|
||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||
import app.cash.zipline.loader.LoadResult
|
||||
import app.cash.zipline.loader.ManifestVerifier
|
||||
import app.cash.zipline.loader.ZiplineLoader
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.decodeURLQueryComponent
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of a Zipline plugin.
|
||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||
* Anything it provides, must be called within that dispatcher context.
|
||||
* The [use] function must be used.
|
||||
*
|
||||
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
||||
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
||||
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
||||
*
|
||||
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
||||
*/
|
||||
open class ZiplinePluginService(
|
||||
val applicationName: String,
|
||||
private val manifestUrl: String,
|
||||
private val pluginInfo: PluginEntry,
|
||||
) : PluginService, KoinComponent {
|
||||
|
||||
override val pluginId: String = pluginInfo.id
|
||||
|
||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
||||
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||
// instance is created, before any modules are loaded.
|
||||
private val ziplineDispatcher = createZiplineDispatcher()
|
||||
|
||||
private fun trace(event: String) {
|
||||
logger.d { "[$applicationName] $event" }
|
||||
}
|
||||
|
||||
private val logger by injectLogger<ZiplinePluginService>()
|
||||
private val webViewController: WebViewController by inject()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
logger.e(throwable) { "Zipline Engine Error" }
|
||||
}
|
||||
private val lifecycleMutex = Mutex()
|
||||
private var ziplineLoader: ZiplineLoader
|
||||
private var ziplineInstance: Zipline? = null
|
||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||
|
||||
init {
|
||||
val manifestPath = URLBuilder(manifestUrl)
|
||||
val baseDir =
|
||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||
ziplineLoader = ZiplineLoader(
|
||||
dispatcher = ziplineDispatcher.dispatcher,
|
||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||
httpClient = FileSystemHTTPClient(baseDir)
|
||||
)
|
||||
}
|
||||
|
||||
private val realHttpClientAPI = RealHttpClientAPI()
|
||||
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
|
||||
|
||||
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
||||
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
||||
private val systemInformationAPI = RealSystemInformationAPI()
|
||||
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||
|
||||
private fun bindHostServices(zipline: Zipline) {
|
||||
trace("initializer(): binding host APIs")
|
||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||
try {
|
||||
// Basic APIs
|
||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||
zipline.bind<SystemInformationAPI>(
|
||||
SystemInformationAPI_SERVICE_NAME,
|
||||
systemInformationAPI
|
||||
)
|
||||
|
||||
// Conditional APIs based on plugin capabilities
|
||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||
zipline.bind<HttpClientAPI>(
|
||||
HttpClientAPI_SERVICE_NAME,
|
||||
realHttpClientAPI
|
||||
)
|
||||
}
|
||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||
}
|
||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||
zipline.bind<PersistedStorageAPI>(
|
||||
PersistedStorageAPI_SERVICE_NAME,
|
||||
persistedStorageAPI
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumePluginServices(result: LoadResult.Success) {
|
||||
trace("start(): loadOnce success")
|
||||
val apiMap =
|
||||
buildMap<KClass<*>, ZiplineService> {
|
||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||
|
||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||
put(
|
||||
MetadataUserAPI::class,
|
||||
result.zipline.take<MetadataUserAPI>(
|
||||
MetadataUserAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataTrackAPI::class,
|
||||
result.zipline.take<MetadataTrackAPI>(
|
||||
MetadataTrackAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataAlbumAPI::class,
|
||||
result.zipline.take<MetadataAlbumAPI>(
|
||||
MetadataAlbumAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataArtistAPI::class,
|
||||
result.zipline.take<MetadataArtistAPI>(
|
||||
MetadataArtistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataPlaylistAPI::class,
|
||||
result.zipline.take<MetadataPlaylistAPI>(
|
||||
MetadataPlaylistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataBrowseAPI::class,
|
||||
result.zipline.take<MetadataBrowseAPI>(
|
||||
MetadataBrowseAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataSearchAPI::class,
|
||||
result.zipline.take<MetadataSearchAPI>(
|
||||
MetadataSearchAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||
put(
|
||||
AudioAPI::class,
|
||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||
put(
|
||||
LyricsAPI::class,
|
||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||
put(
|
||||
ScrobbleAPI::class,
|
||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serviceRegistry.putAll(apiMap)
|
||||
trace("start(): API ready")
|
||||
}
|
||||
|
||||
private fun runLogInFlowObservers() = scope.launch {
|
||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||
loggedInStateFlow.value = isLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("start(): entered")
|
||||
if (serviceRegistry.isNotEmpty()) {
|
||||
trace("start(): already started, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
trace("start(): inside zipline dispatcher before loadOnce")
|
||||
val result = ziplineLoader.loadOnce(
|
||||
applicationName = applicationName,
|
||||
manifestUrl = manifestUrl,
|
||||
freshnessChecker = DefaultFreshnessCheckerNotFresh,
|
||||
)
|
||||
when (result) {
|
||||
is LoadResult.Success -> {
|
||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||
ziplineInstance = result.zipline
|
||||
// Now we consume the initializer
|
||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||
// Bind host services before initialization, so plugins can use them in their initializer
|
||||
bindHostServices(result.zipline)
|
||||
trace("start(): calling initializer.initialize()")
|
||||
runCatching { initializer.initialize() }
|
||||
.onSuccess {
|
||||
consumePluginServices(result)
|
||||
runLogInFlowObservers()
|
||||
}
|
||||
.onFailure { e ->
|
||||
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
is LoadResult.Failure -> {
|
||||
trace("start(): loadOnce failure: ${result.exception}")
|
||||
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
||||
throw result.exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("stop(): entered")
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
ziplineInstance?.close()
|
||||
ziplineInstance = null
|
||||
for (service in serviceRegistry.values) {
|
||||
try {
|
||||
trace("stop(): closing service ${service::class.simpleName}")
|
||||
service.close()
|
||||
} catch (_: Exception) {
|
||||
trace("stop(): error closing service ${service::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
serviceRegistry.clear()
|
||||
scope.cancel()
|
||||
loggedInStateFlow.value = false
|
||||
ziplineDispatcher.close()
|
||||
trace("stop(): completed")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
||||
// Create the scope with the current registry
|
||||
val scope = PluginServiceScope(serviceRegistry)
|
||||
// Execute the block with 'scope' as 'this'
|
||||
scope.block()
|
||||
}
|
||||
}
|
||||
/*
|
||||
* 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.zipline
|
||||
|
||||
import app.cash.zipline.Zipline
|
||||
import app.cash.zipline.ZiplineService
|
||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||
import app.cash.zipline.loader.LoadResult
|
||||
import app.cash.zipline.loader.ManifestVerifier
|
||||
import app.cash.zipline.loader.ZiplineLoader
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.decodeURLQueryComponent
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of a Zipline plugin.
|
||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||
* Anything it provides, must be called within that dispatcher context.
|
||||
* The [use] function must be used.
|
||||
*
|
||||
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
||||
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
||||
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
||||
*
|
||||
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
||||
*/
|
||||
open class ZiplinePluginService(
|
||||
val applicationName: String,
|
||||
private val manifestUrl: String,
|
||||
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
||||
|
||||
override val pluginId: String = pluginInfo.id
|
||||
|
||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
||||
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||
// instance is created, before any modules are loaded.
|
||||
private val ziplineDispatcher = createZiplineDispatcher()
|
||||
|
||||
private fun trace(event: String) {
|
||||
logger.d { "[$applicationName] $event" }
|
||||
}
|
||||
|
||||
private val logger by injectLogger<ZiplinePluginService>()
|
||||
private val webViewController: WebViewController by inject()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
logger.e(throwable) { "Zipline Engine Error" }
|
||||
}
|
||||
private val lifecycleMutex = Mutex()
|
||||
private var ziplineLoader: ZiplineLoader
|
||||
private var ziplineInstance: Zipline? = null
|
||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||
|
||||
init {
|
||||
val manifestPath = URLBuilder(manifestUrl)
|
||||
val baseDir =
|
||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||
ziplineLoader = ZiplineLoader(
|
||||
dispatcher = ziplineDispatcher.dispatcher,
|
||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||
httpClient = FileSystemHTTPClient(baseDir)
|
||||
)
|
||||
}
|
||||
|
||||
private val realHttpClientAPI = RealHttpClientAPI()
|
||||
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
|
||||
|
||||
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
||||
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
||||
private val systemInformationAPI = RealSystemInformationAPI()
|
||||
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||
|
||||
private fun bindHostServices(zipline: Zipline) {
|
||||
trace("initializer(): binding host APIs")
|
||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||
try {
|
||||
// Basic APIs
|
||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||
zipline.bind<SystemInformationAPI>(
|
||||
SystemInformationAPI_SERVICE_NAME,
|
||||
systemInformationAPI
|
||||
)
|
||||
|
||||
// Conditional APIs based on plugin capabilities
|
||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||
zipline.bind<HttpClientAPI>(
|
||||
HttpClientAPI_SERVICE_NAME,
|
||||
realHttpClientAPI
|
||||
)
|
||||
}
|
||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||
}
|
||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||
zipline.bind<PersistedStorageAPI>(
|
||||
PersistedStorageAPI_SERVICE_NAME,
|
||||
persistedStorageAPI
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumePluginServices(result: LoadResult.Success) {
|
||||
trace("start(): loadOnce success")
|
||||
val apiMap =
|
||||
buildMap<KClass<*>, ZiplineService> {
|
||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||
|
||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||
put(
|
||||
MetadataUserAPI::class,
|
||||
result.zipline.take<MetadataUserAPI>(
|
||||
MetadataUserAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataTrackAPI::class,
|
||||
result.zipline.take<MetadataTrackAPI>(
|
||||
MetadataTrackAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataAlbumAPI::class,
|
||||
result.zipline.take<MetadataAlbumAPI>(
|
||||
MetadataAlbumAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataArtistAPI::class,
|
||||
result.zipline.take<MetadataArtistAPI>(
|
||||
MetadataArtistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataPlaylistAPI::class,
|
||||
result.zipline.take<MetadataPlaylistAPI>(
|
||||
MetadataPlaylistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataBrowseAPI::class,
|
||||
result.zipline.take<MetadataBrowseAPI>(
|
||||
MetadataBrowseAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataSearchAPI::class,
|
||||
result.zipline.take<MetadataSearchAPI>(
|
||||
MetadataSearchAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||
put(
|
||||
AudioAPI::class,
|
||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||
put(
|
||||
LyricsAPI::class,
|
||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||
put(
|
||||
ScrobbleAPI::class,
|
||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serviceRegistry.putAll(apiMap)
|
||||
trace("start(): API ready")
|
||||
}
|
||||
|
||||
private fun runLogInFlowObservers() = scope.launch {
|
||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||
loggedInStateFlow.value = isLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("start(): entered")
|
||||
if (serviceRegistry.isNotEmpty()) {
|
||||
trace("start(): already started, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
trace("start(): inside zipline dispatcher before loadOnce")
|
||||
val result = ziplineLoader.loadOnce(
|
||||
applicationName = applicationName,
|
||||
manifestUrl = manifestUrl,
|
||||
freshnessChecker = DefaultFreshnessCheckerNotFresh)
|
||||
when (result) {
|
||||
is LoadResult.Success -> {
|
||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||
ziplineInstance = result.zipline
|
||||
// Now we consume the initializer
|
||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||
// Bind host services before initialization, so plugins can use them in their initializer
|
||||
bindHostServices(result.zipline)
|
||||
trace("start(): calling initializer.initialize()")
|
||||
runCatching { initializer.initialize() }
|
||||
.onSuccess {
|
||||
consumePluginServices(result)
|
||||
runLogInFlowObservers()
|
||||
}
|
||||
.onFailure { e ->
|
||||
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
is LoadResult.Failure -> {
|
||||
trace("start(): loadOnce failure: ${result.exception}")
|
||||
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
||||
throw result.exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("stop(): entered")
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
ziplineInstance?.close()
|
||||
ziplineInstance = null
|
||||
for (service in serviceRegistry.values) {
|
||||
try {
|
||||
trace("stop(): closing service ${service::class.simpleName}")
|
||||
service.close()
|
||||
} catch (_: Exception) {
|
||||
trace("stop(): error closing service ${service::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
serviceRegistry.clear()
|
||||
scope.cancel()
|
||||
loggedInStateFlow.value = false
|
||||
ziplineDispatcher.close()
|
||||
trace("stop(): completed")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
||||
// Create the scope with the current registry
|
||||
val scope = PluginServiceScope(serviceRegistry)
|
||||
// Execute the block with 'scope' as 'this'
|
||||
scope.block()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -185,7 +185,6 @@ fun AESEncodingFormats.format(): AES.Key.Format {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
private val provider = CryptographyProvider.Default
|
||||
|
||||
@ -193,7 +192,6 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
return CryptographyRandom.nextBytes(size)
|
||||
}
|
||||
|
||||
|
||||
override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray {
|
||||
val hasher = provider.get(algorithm.algorithm()).hasher()
|
||||
return hasher.hash(data)
|
||||
@ -301,8 +299,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
val keyPair = provider.get(RSA.PSS).keyPairGenerator(
|
||||
algorithm.keySizeBits.bits,
|
||||
algorithm.hashAlgorithm.algorithm(),
|
||||
algorithm.publicExponent.toBigInt(),
|
||||
).generateKey()
|
||||
algorithm.publicExponent.toBigInt()).generateKey()
|
||||
|
||||
Pair(
|
||||
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
|
||||
@ -366,8 +363,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.ECDSA ->
|
||||
provider.get(ECDSA).privateKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.privateKeyFormat(),
|
||||
privateKey
|
||||
).signatureGenerator(
|
||||
@ -377,8 +373,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.EdDSA ->
|
||||
provider.get(EdDSA).privateKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.privateKeyFormat(),
|
||||
privateKey
|
||||
).signatureGenerator().generateSignature(data)
|
||||
@ -422,8 +417,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.ECDSA ->
|
||||
provider.get(ECDSA).publicKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.publicKeyFormat(),
|
||||
publicKey
|
||||
).signatureVerifier(
|
||||
@ -436,8 +430,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.EdDSA ->
|
||||
provider.get(EdDSA).publicKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.publicKeyFormat(),
|
||||
publicKey
|
||||
).signatureVerifier().verifySignature(
|
||||
|
||||
@ -28,7 +28,6 @@ import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.takeFrom
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
|
||||
class RealHttpClientAPI : HttpClientAPI, KoinComponent {
|
||||
val logger by injectLogger<RealHttpClientAPI>()
|
||||
val httpClient = HttpClient()
|
||||
|
||||
@ -1,74 +1,73 @@
|
||||
/*
|
||||
* 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.zipline.host_apis
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RealWebViewAPI(
|
||||
private val scope: CoroutineScope,
|
||||
private val webViewController: WebViewController,
|
||||
private val pluginId: String,
|
||||
) : WebViewAPI {
|
||||
override fun navigateTo(url: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.navigateTo(url, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToHTML(html: String) {
|
||||
scope.launch {
|
||||
webViewController.navigateToHTML(html, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCookies(url: String): List<Cookie> {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.getCookies(url)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun evaluateJavaScript(script: String): String? {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.evaluateJavascript(script)
|
||||
}
|
||||
}
|
||||
|
||||
override fun urlChangeFlow(): Flow<String> {
|
||||
return webViewController.urlChangedFlow
|
||||
}
|
||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||
return webViewController.webviewCreatedFlow
|
||||
}
|
||||
override fun postMessagesFlow(): Flow<String> {
|
||||
return webViewController.postMessagesFlow
|
||||
}
|
||||
|
||||
override fun exitWebView() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.closeWebview()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 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.zipline.host_apis
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RealWebViewAPI(
|
||||
private val scope: CoroutineScope,
|
||||
private val webViewController: WebViewController,
|
||||
private val pluginId: String) : WebViewAPI {
|
||||
override fun navigateTo(url: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.navigateTo(url, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToHTML(html: String) {
|
||||
scope.launch {
|
||||
webViewController.navigateToHTML(html, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCookies(url: String): List<Cookie> {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.getCookies(url)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun evaluateJavaScript(script: String): String? {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.evaluateJavascript(script)
|
||||
}
|
||||
}
|
||||
|
||||
override fun urlChangeFlow(): Flow<String> {
|
||||
return webViewController.urlChangedFlow
|
||||
}
|
||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||
return webViewController.webviewCreatedFlow
|
||||
}
|
||||
override fun postMessagesFlow(): Flow<String> {
|
||||
return webViewController.postMessagesFlow
|
||||
}
|
||||
|
||||
override fun exitWebView() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.closeWebview()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -45,8 +45,7 @@ data class LRCLibResponse(
|
||||
val artistName: String,
|
||||
val albumName: String,
|
||||
val plainLyrics: String?,
|
||||
val syncedLyrics: String?,
|
||||
)
|
||||
val syncedLyrics: String?)
|
||||
|
||||
class RealLRCLibLyricsAPI : LyricsAPI {
|
||||
private val httpClient = HttpClient {
|
||||
@ -95,7 +94,6 @@ class RealLRCLibLyricsAPI : LyricsAPI {
|
||||
val text = line.substring(match.range.last + 1).trim()
|
||||
LyricsLine(time = timeMs, text = text)
|
||||
},
|
||||
plainLyrics = response.plainLyrics,
|
||||
)
|
||||
plainLyrics = response.plainLyrics)
|
||||
}
|
||||
}
|
||||
@ -124,16 +124,14 @@ data class MusicGatewayResponse(
|
||||
val externalIds: ExternalIds,
|
||||
val id: String,
|
||||
val name: String,
|
||||
val popularity: Int,
|
||||
) {
|
||||
val popularity: Int) {
|
||||
@Serializable
|
||||
data class Album(
|
||||
@SerialName("album_type")
|
||||
val albumType: String,
|
||||
val artists: List<Artist>,
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
val name: String)
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
|
||||
@ -57,7 +57,6 @@ class ISRCProviders {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var ifpiSessionToken: String? = null
|
||||
private val ifpiDefaultHeaders = mapOf(
|
||||
"Content-Type" to "application/json",
|
||||
@ -76,7 +75,6 @@ class ISRCProviders {
|
||||
"User-Agent" to UserAgents.random()
|
||||
)
|
||||
|
||||
|
||||
private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) {
|
||||
if (ifpiSessionToken != null) return@withContext ifpiSessionToken
|
||||
|
||||
|
||||
@ -65,12 +65,10 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
Thumbnail(
|
||||
url = video.thumbnailUrl,
|
||||
width = 300,
|
||||
height = 300,
|
||||
)
|
||||
height = 300)
|
||||
),
|
||||
externalUri = "https://www.youtube.com/watch?v=${video.id}",
|
||||
confidence = confidence,
|
||||
)
|
||||
confidence = confidence)
|
||||
}
|
||||
|
||||
override val supportedQualities: List<AudioFormat> = listOf(
|
||||
@ -79,15 +77,13 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
AudioQuality.Lossy(bitrate = 44_000),
|
||||
AudioQuality.Lossy(bitrate = 96_000),
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
AudioQuality.Lossy(bitrate = 256_000),
|
||||
)
|
||||
AudioQuality.Lossy(bitrate = 256_000))
|
||||
), AudioFormat(
|
||||
codec = "aac", container = "mp4", qualities = listOf(
|
||||
AudioQuality.Lossy(bitrate = 44_000),
|
||||
AudioQuality.Lossy(bitrate = 96_000),
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
AudioQuality.Lossy(bitrate = 256_000),
|
||||
)
|
||||
AudioQuality.Lossy(bitrate = 256_000))
|
||||
)
|
||||
)
|
||||
|
||||
@ -98,7 +94,6 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
))
|
||||
val isYouTubeID = track.id.matches(youtubeIDRegex)
|
||||
|
||||
|
||||
logger.i {
|
||||
"Searching streams for track(${track.id}): ${track.title} by ${
|
||||
track.artists.joinToString(
|
||||
@ -119,13 +114,11 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
Thumbnail(
|
||||
url = videoInfo.thumbnailUrl,
|
||||
width = 300,
|
||||
height = 300,
|
||||
)
|
||||
height = 300)
|
||||
),
|
||||
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
|
||||
confidence = 1.0f,
|
||||
streams = videoInfo.audioStreams,
|
||||
)
|
||||
streams = videoInfo.audioStreams)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -165,8 +158,7 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
Thumbnail(
|
||||
url = videoInfo.thumbnailUrl,
|
||||
width = 300,
|
||||
height = 300,
|
||||
)
|
||||
height = 300)
|
||||
),
|
||||
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
|
||||
confidence = source.confidence,
|
||||
|
||||