Compare commits

...

15 Commits

Author SHA1 Message Date
dependabot[bot]
cf1aba3676
Merge d48b0a2f65 into d720467338 2026-10-14 04:53:46 +00:00
Kingkor Roy Tirtho
d720467338 feat(support): implement support reminder dialog and donation options 2026-10-14 09:51:07 +06:00
Kingkor Roy Tirtho
8efcbd4af4 feat(player): add content descriptions for play, pause, and add to queue actions 2026-10-13 13:06:22 +06:00
Kingkor Roy Tirtho
76518a01a1 feat(onboarding): enhance welcome screen with structured guide steps and improved layout 2026-10-13 13:03:27 +06:00
Kingkor Roy Tirtho
caad76ba95 refactor: replace currentWindowAdaptiveInfo with currentWindowWidthBreakpoint for adaptive layouts 2026-10-11 21:08:25 +06:00
Kingkor Roy Tirtho
bb9f225fd7
Merge pull request #3136 from henrik242/kmp-zip-1-0-0
Upgrade kmp-zip to 1.0.0
2026-10-11 19:51:34 +06:00
Kingkor Roy Tirtho
cfd472016e refactor: integrate millennium UI 2026-10-11 17:17:34 +06:00
Kingkor Roy Tirtho
963b360a81 feat(ui): new custom surface integration on all components 2026-10-09 14:51:30 +06:00
Kingkor Roy Tirtho
9f96b07ebd feat(ui): replace Scaffold with BaseScaffold for consistent surface theming across screens 2026-10-07 18:45:22 +06:00
Kingkor Roy Tirtho
4da5b25a5c feat(surface): implement Frutiger-Aero themed surfaces with glass and acrylic styles 2026-10-07 15:29:25 +06:00
Kingkor Roy Tirtho
fa9ad9981c feat(settings): add 'About' section and improve header visibility in settings sections 2026-10-07 14:07:09 +06:00
Kingkor Roy Tirtho
5c0652b395 feat(previews): add comprehensive previews for UI components including sliders, toggles, checkboxes, and text fields 2026-10-07 10:39:26 +06:00
Kingkor Roy Tirtho
71d7649db6 feat(welcome): implement onboarding flow with multiple pages and donation options 2026-10-06 15:29:27 +06:00
Henrik Brautaset Aronsen
0def3e2e49
Upgrade kmp-zip to 1.0.0
https://github.com/henrik242/kmp-zip/blob/main/CHANGELOG.md
2026-09-30 10:06:42 +02:00
dependabot[bot]
d48b0a2f65
chore(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 23:42:25 +00:00
241 changed files with 9413 additions and 12822 deletions

View File

@ -425,7 +425,7 @@ jobs:
done
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.vars.outputs.tag }}
name: ${{ steps.vars.outputs.release_name }}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View File

@ -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)

View File

@ -26,7 +26,6 @@ import org.schabi.newpipe.extractor.ServiceList.YouTube
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
import org.schabi.newpipe.extractor.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,

View File

@ -45,7 +45,6 @@ actual val platformModules = module {
collectionPlaybackHelper = get(),
audioPlayerQueue = get(),
settingsRepository = get(),
blacklistRepository = get(),
)
blacklistRepository = get())
}
}

View File

@ -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)
}

View File

@ -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> {

View File

@ -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")
}
}

View File

@ -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) })
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
interface AudioPlayerInterface {
// // Playback
suspend fun play()

View File

@ -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())

View File

@ -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>)

View File

@ -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 {

View File

@ -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 {

View File

@ -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)

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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 {

View File

@ -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" }
}

View File

@ -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 {

View File

@ -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())
}
}

View File

@ -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,

View File

@ -49,5 +49,4 @@ data class MediaStream(
val container: String,
val bitrate: Int,
val isAudio: Boolean,
val isVideo: Boolean,
)
val isVideo: Boolean)

View File

@ -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 }
}

View File

@ -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")
}
},
)
}
}
}
}
}

View File

@ -162,8 +162,7 @@ class RemoteControlClient {
val envelope = CommandEnvelope(
commandId = randomShortId(),
command = command,
)
command = command)
try {
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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 -> {

View File

@ -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). */

View File

@ -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>()

View File

@ -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(

View File

@ -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 {

View File

@ -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 {

View File

@ -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()
}

View File

@ -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 ->

View File

@ -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,
)
}
},
)
}
}
}
}

View File

@ -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,
)
}
}

View File

@ -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))
}
}
}
}
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -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,
)
}
}

View File

@ -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,
)
}
}
}
}
}
}

View File

@ -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)),
)
}
}

View File

@ -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()
}
}
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -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,
),
)
}
}

View File

@ -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",
)
},
)
}
}
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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,
)
}
}

View File

@ -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()
}

View File

@ -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)
}
},
)
})
}
}

View File

@ -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}")
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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 = {})
}

View File

@ -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))
}
}

View File

@ -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 = {})
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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}")
}

View File

@ -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)
}
}

View File

@ -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 = {

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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)
}

View File

@ -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) {

View File

@ -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())
}
}
}

View File

@ -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",

View File

@ -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)
},
) {}
}) {}
}
}

View File

@ -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)

View File

@ -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()
}
}

View File

@ -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)

View File

@ -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) {

View File

@ -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)
}
}
}

View File

@ -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)))
}

View File

@ -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,

View File

@ -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

View File

@ -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)
})
}
}

View File

@ -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 -> {

View File

@ -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()
}
}
}

View File

@ -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(

View File

@ -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()

View File

@ -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()
}
}
}

View File

@ -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)
}
}

View File

@ -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(

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More