Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
911d6babc5
Merge d48b0a2f65 into fab60107e3 2026-10-03 04:25:46 -07:00
240 changed files with 12840 additions and 9431 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -119,6 +119,7 @@ kotlin {
// Jetpack Compose dependencies // Jetpack Compose dependencies
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3) implementation(libs.coil.network.ktor3)
@ -131,9 +132,6 @@ kotlin {
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.dns.sd.kt) implementation(libs.dns.sd.kt)
// Millennium UI
implementation(libs.millennium)
// Navigation // Navigation
implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
@ -166,6 +164,12 @@ kotlin {
// icons // icons
implementation(libs.feather.icons) implementation(libs.feather.icons)
// Material3 adaptive
implementation(libs.jetbrains.material3.adaptive)
implementation(libs.jetbrains.material3.adaptive.layout)
implementation(libs.jetbrains.material3.adaptive.suite)
implementation(libs.jetbrains.material3.adaptiveNavigation3)
implementation(libs.jetbrains.material3.window.size)
// androidx-datastore // androidx-datastore
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
@ -180,6 +184,8 @@ kotlin {
implementation(libs.semver) implementation(libs.semver)
implementation(libs.material.kolor)
// crypto // crypto
implementation(libs.cryptography.core) implementation(libs.cryptography.core)
implementation(libs.cryptography.provider.optimal) implementation(libs.cryptography.provider.optimal)
@ -200,6 +206,7 @@ kotlin {
// Shimmer effect // Shimmer effect
implementation(libs.compose.shimmer) implementation(libs.compose.shimmer)
implementation(libs.compose.placeholder.material3)
// DLNA // DLNA
implementation(libs.dns.sd.kt) implementation(libs.dns.sd.kt)

View File

@ -26,6 +26,7 @@ import org.schabi.newpipe.extractor.ServiceList.YouTube
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
actual class NewPipeService { actual class NewPipeService {
actual suspend fun searchVideos(query: String): List<VideoSearchResult> { actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
@ -46,7 +47,8 @@ actual class NewPipeService {
url = item.url, url = item.url,
uploader = item.uploaderName, uploader = item.uploaderName,
durationMs = item.duration, durationMs = item.duration,
thumbnailUrl = item.thumbnails.first().url) thumbnailUrl = item.thumbnails.first().url,
)
} else { } else {
null null
} }
@ -65,7 +67,8 @@ actual class NewPipeService {
url = stream.content, url = stream.content,
codec = stream.codec, codec = stream.codec,
container = stream.format!!.suffix, container = stream.format!!.suffix,
bitrate = stream.bitrate) bitrate = stream.bitrate,
)
} + buildList { } + buildList {
if (extractor.dashMpdUrl.isNotBlank()) add( if (extractor.dashMpdUrl.isNotBlank()) add(
AudioStream.Lossy( AudioStream.Lossy(
@ -87,6 +90,7 @@ actual class NewPipeService {
) )
} }
val videoStreams = extractor.videoStreams val videoStreams = extractor.videoStreams
.filter { stream -> stream.isUrl && stream.format != null } .filter { stream -> stream.isUrl && stream.format != null }
.map { stream -> .map { stream ->
@ -94,7 +98,8 @@ actual class NewPipeService {
url = stream.content, url = stream.content,
codec = stream.codec, codec = stream.codec,
bitrate = stream.bitrate, bitrate = stream.bitrate,
container = stream.format!!.suffix) container = stream.format!!.suffix,
)
} }
VideoInfo( VideoInfo(
id = id, id = id,

View File

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

View File

@ -35,12 +35,14 @@ import androidx.core.content.ContextCompat
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit { actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ } ActivityResultContracts.RequestPermission(),
) { /* result is picked up by discovery/advertising retry loops */ }
return { return {
val granted = ContextCompat.checkSelfPermission( val granted = ContextCompat.checkSelfPermission(
context, context,
Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED Manifest.permission.NEARBY_WIFI_DEVICES,
) == PackageManager.PERMISSION_GRANTED
if (!granted) { if (!granted) {
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
} }

View File

@ -53,7 +53,8 @@ class MediaBrowseHelper(
private val collectionPlaybackHelper: CollectionPlaybackHelper, private val collectionPlaybackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val blacklistRepository: BlacklistRepository) { private val blacklistRepository: BlacklistRepository,
) {
companion object { companion object {
const val MEDIA_ID_ROOT = "root" const val MEDIA_ID_ROOT = "root"
@ -252,7 +253,8 @@ class MediaBrowseHelper(
.setIsPlayable(false) .setIsPlayable(false)
.build() .build()
) )
.build()) .build(),
)
} }
private suspend fun buildBrowseItems(): List<MediaItem> { private suspend fun buildBrowseItems(): List<MediaItem> {
@ -388,7 +390,8 @@ class MediaBrowseHelper(
.setIsBrowsable(true) .setIsBrowsable(true)
.build() .build()
) )
.build()) .build(),
)
} }
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> { private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
@ -472,7 +475,8 @@ class MediaBrowseHelper(
.setIsPlayable(false) .setIsPlayable(false)
.build() .build()
) )
.build()) .build(),
)
} }
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> { private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {

View File

@ -32,7 +32,8 @@ import java.io.File
import java.util.Locale import java.util.Locale
class AndroidLocalMediaDiscoveryService( class AndroidLocalMediaDiscoveryService(
private val context: Context) : LocalMediaDiscoveryService, KoinComponent { private val context: Context,
) : LocalMediaDiscoveryService, KoinComponent {
val logger by injectLogger<AndroidLocalMediaDiscoveryService>() val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> { override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
@ -58,7 +59,8 @@ class AndroidLocalMediaDiscoveryService(
MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) { MediaStore.Audio.Media.MIME_TYPE,
) + if (Build.VERSION.SDK_INT >= 29) {
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH) arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
} else { } else {
emptyArray() emptyArray()
@ -129,7 +131,8 @@ class AndroidLocalMediaDiscoveryService(
artists = listOfNotNull(artist), artists = listOfNotNull(artist),
durationMs = durationMs, durationMs = durationMs,
album = album, album = album,
coverBytes = null) coverBytes = null,
)
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
acceptedRows += 1 acceptedRows += 1
@ -147,7 +150,8 @@ class AndroidLocalMediaDiscoveryService(
LocalMediaFolder( LocalMediaFolder(
path = folderPath, path = folderPath,
name = normalizeFolderName(folderPath), name = normalizeFolderName(folderPath),
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
)
} }
.sortedBy { it.name.lowercase(Locale.getDefault()) } .sortedBy { it.name.lowercase(Locale.getDefault()) }
@ -168,7 +172,8 @@ class AndroidLocalMediaDiscoveryService(
override fun observeChanges( override fun observeChanges(
roots: List<String>, roots: List<String>,
onChanged: LocalMediaChangeCallback): LocalMediaObservation? { onChanged: LocalMediaChangeCallback,
): LocalMediaObservation? {
logger.i { "observeChanges: start roots=${roots.size}" } logger.i { "observeChanges: start roots=${roots.size}" }
if (!hasReadPermission()) { if (!hasReadPermission()) {
logger.w { "observeChanges: missing media permission, observer not registered" } logger.w { "observeChanges: missing media permission, observer not registered" }
@ -185,7 +190,8 @@ class AndroidLocalMediaDiscoveryService(
context.contentResolver.registerContentObserver( context.contentResolver.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
true, true,
observer) observer,
)
logger.i { "observeChanges: observer registered" } logger.i { "observeChanges: observer registered" }
@ -251,7 +257,8 @@ class AndroidLocalMediaDiscoveryService(
artists = emptyList(), artists = emptyList(),
durationMs = 0L, durationMs = 0L,
album = null, album = null,
coverBytes = null) coverBytes = null,
)
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
} }
@ -262,7 +269,8 @@ class AndroidLocalMediaDiscoveryService(
LocalMediaFolder( LocalMediaFolder(
path = folderPath, path = folderPath,
name = normalizeFolderName(folderPath), name = normalizeFolderName(folderPath),
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
)
} }
.sortedBy { it.name.lowercase(Locale.getDefault()) } .sortedBy { it.name.lowercase(Locale.getDefault()) }
@ -284,7 +292,8 @@ class AndroidLocalMediaDiscoveryService(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)) context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
)
return (configured + defaults) return (configured + defaults)
.map { it.absoluteFile } .map { it.absoluteFile }
@ -311,7 +320,8 @@ class AndroidLocalMediaDiscoveryService(
private fun mergeFolders( private fun mergeFolders(
mediaStoreFolders: List<LocalMediaFolder>, mediaStoreFolders: List<LocalMediaFolder>,
fallbackFolders: List<LocalMediaFolder>): List<LocalMediaFolder> { fallbackFolders: List<LocalMediaFolder>,
): List<LocalMediaFolder> {
if (fallbackFolders.isEmpty()) return mediaStoreFolders if (fallbackFolders.isEmpty()) return mediaStoreFolders
if (mediaStoreFolders.isEmpty()) return fallbackFolders if (mediaStoreFolders.isEmpty()) return fallbackFolders
@ -332,7 +342,8 @@ class AndroidLocalMediaDiscoveryService(
LocalMediaFolder( LocalMediaFolder(
path = path, path = path,
name = normalizeFolderName(path), name = normalizeFolderName(path),
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
)
} }
.sortedBy { it.name.lowercase(Locale.getDefault()) } .sortedBy { it.name.lowercase(Locale.getDefault()) }
} }
@ -346,6 +357,7 @@ class AndroidLocalMediaDiscoveryService(
"wav", "wav",
"ogg", "ogg",
"opus", "opus",
"wma") "wma",
)
} }
} }

View File

@ -42,16 +42,19 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState {
var isGranted by remember { var isGranted by remember {
mutableStateOf( mutableStateOf(
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED,
)
} }
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()) { granted -> contract = ActivityResultContracts.RequestPermission(),
) { granted ->
isGranted = granted || isGranted = granted ||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
} }
return LocalMediaPermissionState( return LocalMediaPermissionState(
isGranted = isGranted, isGranted = isGranted,
requestPermission = { launcher.launch(permission) }) requestPermission = { launcher.launch(permission) },
)
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<g id="Alapkör">
<circle id="XMLID_23_" fill="#CD201F" cx="50" cy="50" r="50"/>
</g>
<g id="Elemek">
<path id="XMLID_19_" fill="#FFFFFF" d="M47,28.2c-9-5.3-15.3-9-15.3-9v61.7c0,0,30.4-18,52.3-30.9C72.1,43,57.7,34.5,47,28.2z"/>
</g>
<g id="Fedő">
<path id="XMLID_5_" fill="#CD201F" d="M48.4,40.1c-4.1-2.4-7-4.1-7-4.1V64c0,0,13.9-8.2,23.8-14C59.8,46.8,53.3,42.9,48.4,40.1z"/>
<rect id="XMLID_4_" x="41.4" y="55.6" fill="#CD201F" width="6.2" height="21"/>
</g>
<g id="Vonalak">
</g>
</svg>

Before

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

View File

@ -16,23 +16,16 @@
--> -->
<resources> <resources>
<string name="queue_title">Queue</string>
<string name="queue_clear_queue">Clear Queue</string>
<string name="queue_filter_queue">Filter queue...</string>
<string name="queue_no_queue_entries">No queue entries</string>
<string name="settings_screen_title">Settings</string> <string name="settings_screen_title">Settings</string>
<string name="settings_section_plugins">Plugins</string> <string name="settings_section_plugins">Plugins</string>
<string name="settings_section_language_region">Language and Region</string> <string name="settings_section_language_region">Language and Region</string>
<string name="settings_section_appearance">Appearance</string> <string name="settings_section_appearance">Appearance</string>
<string name="settings_section_playback">Playback</string> <string name="settings_section_playback">Playback</string>
<string name="settings_section_downloads">Downloads</string>f <string name="settings_section_downloads">Downloads</string>
<string name="settings_section_caching">Caching</string> <string name="settings_section_caching">Caching</string>
<string name="settings_section_desktop">Desktop</string> <string name="settings_section_desktop">Desktop</string>
<string name="settings_section_updates">Updates</string> <string name="settings_section_updates">Updates</string>
<string name="settings_section_support">Support</string>
<string name="settings_section_about">About</string>
<string name="settings_action_close">Close</string> <string name="settings_action_close">Close</string>
<string name="settings_action_save">Save</string> <string name="settings_action_save">Save</string>
@ -50,14 +43,6 @@
<string name="settings_theme_dark">Dark</string> <string name="settings_theme_dark">Dark</string>
<string name="settings_theme_system">System</string> <string name="settings_theme_system">System</string>
<string name="settings_surface_theme_title">Surface</string>
<string name="settings_surface_theme_subtitle_current">Background material of the app. Current: %1$s</string>
<string name="settings_surface_watery">Watery</string>
<string name="settings_surface_icy">Icy</string>
<string name="settings_surface_chrome">Chrome</string>
<string name="settings_surface_acrylic">Acrylic</string>
<string name="settings_surface_glass">Glass</string>
<string name="settings_accent_title">Accent color</string> <string name="settings_accent_title">Accent color</string>
<string name="settings_accent_subtitle_current">Current palette: %1$s</string> <string name="settings_accent_subtitle_current">Current palette: %1$s</string>
<string name="settings_accent_dialog_title">Accent color</string> <string name="settings_accent_dialog_title">Accent color</string>
@ -74,24 +59,6 @@
<string name="settings_accent_midnight_blue">Midnight Blue</string> <string name="settings_accent_midnight_blue">Midnight Blue</string>
<string name="settings_accent_metallic_slate">Metallic Slate</string> <string name="settings_accent_metallic_slate">Metallic Slate</string>
<string name="settings_appearance_title">Look &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_title">Download folder override</string>
<string name="settings_download_folder_current">Current folder: %1$s</string> <string name="settings_download_folder_current">Current folder: %1$s</string>
<string name="settings_download_folder_default">Current folder: default music folder</string> <string name="settings_download_folder_default">Current folder: default music folder</string>
@ -231,123 +198,12 @@
<string name="player_go_to_album">Go to album</string> <string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</string> <string name="player_sleep_timer">Sleep timer</string>
<string name="player_track_details">Track details</string> <string name="player_track_details">Track details</string>
<string name="player_play_content_description">Play</string>
<string name="player_pause_content_description">Pause</string>
<string name="player_add_to_queue_content_description">Add to queue</string>
<string name="update_action_ignore">Ignore</string> <string name="update_action_ignore">Ignore</string>
<string name="update_action_update">Update</string> <string name="update_action_update">Update</string>
<string name="app_update_dialog_title">Spotube %1$s is available</string> <string name="app_update_dialog_title">Spotube %1$s is available</string>
<string name="app_update_dialog_no_notes">See what’s new in this release on the Spotube downloads page.</string> <string name="app_update_dialog_no_notes">See what\'s new in this release on the Spotube downloads page.</string>
<string name="plugin_update_dialog_title">%1$s %2$s is available</string> <string name="plugin_update_dialog_title">%1$s %2$s is available</string>
<string name="plugin_update_dialog_current_version">Currently installed: %1$s</string> <string name="plugin_update_dialog_current_version">Currently installed: %1$s</string>
<string name="plugin_update_dialog_no_notes">See what’s new in this update from the plugin’s page.</string> <string name="plugin_update_dialog_no_notes">See what\'s new in this update from the plugin\'s page.</string>
<string name="welcome_skip">Skip</string>
<string name="welcome_next">Next</string>
<string name="welcome_back">Back</string>
<string name="welcome_get_started">Get Started</string>
<string name="welcome_app_name">Spotube</string>
<string name="welcome_brand_freedom">Music, without the strings.</string>
<string name="welcome_brand_quiet_description">An open-source player for a world of music.\nYours to explore. Yours to make your own.</string>
<string name="welcome_donate_quiet_title">Keep this project alive.</string>
<string name="welcome_donate_letter">Spotube is built by people, not a subscription business. If it becomes part of your everyday life, a little support helps us give it the care it deserves.\n\nThank you for making room for independent software.</string>
<string name="welcome_plugins_setup_guide">Start with a catalog plugin to find songs and playlists. Choose an audio plugin to play them. Lyrics and listening history are optional. Discover a plugin below, review its permissions, install it, then select it here.</string>
<string name="welcome_plugins_discover">Discover plugins</string>
<string name="welcome_plugins_sources">Your sources</string>
<string name="welcome_plugins_retry">Try again</string>
<string name="welcome_plugins_more">More plugins</string>
<string name="welcome_plugins_discovery_empty">No new plugins found. You can also install one from a link or a .smplug file above.</string>
<string name="welcome_plugins_discovery_error">Plugin discovery is unavailable right now. Try again, or install from a link or file.</string>
<string name="welcome_step_welcome">Welcome</string>
<string name="welcome_step_support">Support</string>
<string name="welcome_step_plugins">Plugins</string>
<string name="welcome_step_guide">Guide</string>
<string name="welcome_guide_title">How to use Spotube</string>
<string name="welcome_guide_subtitle">Four quick steps and you’re listening.</string>
<string name="welcome_guide_signin_title">Sign in</string>
<string name="welcome_guide_signin_desc">Connect your Spotify account, or pick your own catalog plugin.</string>
<string name="welcome_guide_search_title">Search and play</string>
<string name="welcome_guide_search_desc">Find any song, album, or artist, then press play.</string>
<string name="welcome_guide_offline_title">Listen offline</string>
<string name="welcome_guide_offline_desc">Download tracks to keep playing without a connection.</string>
<string name="welcome_guide_lyrics_title">Follow the lyrics</string>
<string name="welcome_guide_lyrics_desc">Open synced lyrics from the player while a track plays.</string>
<!-- Page 1: branding -->
<string name="welcome_brand_tagline">Your music. Your sources. Your rules.</string>
<string name="welcome_brand_subtitle">A free, open-source music player built by a community that simply loves music.</string>
<string name="welcome_brand_chip_open_source">Open source</string>
<string name="welcome_brand_chip_no_ads">No ads</string>
<string name="welcome_brand_chip_no_tracking">No telemetry</string>
<string name="welcome_brand_chip_plugin_powered">Plugin powered</string>
<string name="welcome_lets_go">Let’s Go</string>
<!-- Page 2: donations -->
<string name="welcome_donate_eyebrow">Supporters keep us going</string>
<string name="welcome_donate_title">Built with passion. Kept alive by you.</string>
<string name="welcome_donate_message">Spotube is 100% free, libre, and open source — no ads, no subscriptions, no tracking. Every feature exists because people like you chose to help keep the code flowing.</string>
<string name="welcome_donate_patreon_title">Patreon</string>
<string name="welcome_donate_patreon_tag">Monthly Sponsor</string>
<string name="welcome_donate_patreon_subtitle">Support recurring development and infrastructure</string>
<string name="welcome_donate_bmac_title">Buy Me a Coffee</string>
<string name="welcome_donate_bmac_tag">One-Time Coffee</string>
<string name="welcome_donate_bmac_subtitle">Fuel a late-night coding session with a quick tip</string>
<string name="welcome_donate_opencollective_title">Open Collective</string>
<string name="welcome_donate_opencollective_tag">Community Fund</string>
<string name="welcome_donate_opencollective_subtitle">Transparent, community-governed financial backing</string>
<string name="welcome_donate_thanks">Thank you from the bottom of our hearts for believing in open music.</string>
<string name="welcome_donate_optional">Supporting is completely optional — Spotube stays free either way.</string>
<string name="welcome_donate_action">Sponsor</string>
<string name="welcome_donate_open_content_description">Open %1$s</string>
<!-- Support / donations -->
<string name="support_button_label">Support Spotube</string>
<string name="support_dialog_title">Support Spotube</string>
<string name="support_dialog_message">Spotube is free, libre and open source — no ads, no subscriptions, no tracking. If it brings you joy, consider supporting the project.</string>
<string name="support_dialog_reminder_hint">You can turn these reminders off in Settings → Support.</string>
<string name="support_dialog_dismiss">Maybe later</string>
<string name="settings_support_reminders_title">Support reminders</string>
<string name="settings_support_reminders_subtitle">Show a gentle reminder to support the project every 7 days.</string>
<!-- Page 3: plugins -->
<string name="welcome_plugins_eyebrow">Modular Architecture</string>
<string name="welcome_plugins_title">Choose where your music comes from.</string>
<string name="welcome_plugins_subtitle">Spotube separates catalog discovery, audio streaming, and synced lyrics. Configure your engines below or switch them anytime.</string>
<string name="welcome_plugins_metadata_title">Metadata Engine</string>
<string name="welcome_plugins_metadata_description">Powers music discovery, search, track listings, and playlists.</string>
<string name="welcome_plugins_audio_title">Audio Engine</string>
<string name="welcome_plugins_audio_description">Fetches and decodes the audio streams that play.</string>
<string name="welcome_plugins_lyrics_title">Lyrics Engine</string>
<string name="welcome_plugins_lyrics_description">Streams time-synced karaoke lyrics in real time.</string>
<string name="welcome_plugins_scrobble_title">Scrobble Engine</string>
<string name="welcome_plugins_scrobble_description">Records listening history to Last.fm or ListenBrainz.</string>
<string name="welcome_plugins_badge_active">Active</string>
<string name="welcome_plugins_badge_builtin">Built-in</string>
<string name="welcome_plugins_badge_ready">Ready</string>
<string name="welcome_plugins_badge_optional">Optional</string>
<string name="welcome_plugins_none_selected">Select an engine</string>
<string name="welcome_plugins_installed_section">Installed Engines</string>
<string name="welcome_plugins_set_default">Set as default</string>
<string name="welcome_plugins_builtin_note">NewPipe YouTube and LRCLib Lyrics are built-in and ready to stream out of the box.</string>
<string name="welcome_plugins_tip">Manage or add new community plugins anytime in Settings → Plugins.</string>
<!-- Page 4: how it works -->
<string name="welcome_how_eyebrow">Retro-Modern Architecture</string>
<string name="welcome_how_title">Pure Hi-Fi, zero middlemen</string>
<string name="welcome_how_subtitle">Here’s how Spotube orchestrates decentralized playback directly on your machine.</string>
<string name="welcome_how_status_badge">DIRECT PEER-TO-PEER • 256 KBPS OPUS</string>
<string name="welcome_how_mockup_title">Blue hour</string>
<string name="welcome_how_mockup_artist">Spotube · player demonstration</string>
<string name="welcome_how_mockup_lyric">A little music for the moments in between.</string>
<string name="welcome_how_step_search_title">1. Decentralized Discovery</string>
<string name="welcome_how_step_search_desc">Queries go straight to your chosen catalog plugin. Spotube never profiles your taste.</string>
<string name="welcome_how_step_stream_title">2. Direct Stream Resolution</string>
<string name="welcome_how_step_stream_desc">Audio streams directly from content servers to your machine. No proxy hops or remote data harvesting.</string>
<string name="welcome_how_step_playback_title">3. Local DSP &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> </resources>

View File

@ -24,36 +24,30 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import dev.krtirtho.spotube.core.DebugConfig
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.shell.AppShell import dev.krtirtho.spotube.modules.shell.AppShell
import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
import dev.krtirtho.spotube.modules.support.SupportDialog
import dev.krtirtho.spotube.modules.support.SupportReminderIntervalMillis
import dev.krtirtho.spotube.modules.update.AppUpdateDialog import dev.krtirtho.spotube.modules.update.AppUpdateDialog
import dev.krtirtho.spotube.modules.update.AppUpdateService import dev.krtirtho.spotube.modules.update.AppUpdateService
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
import dev.krtirtho.spotube.modules.update.PluginUpdateService import dev.krtirtho.spotube.modules.update.PluginUpdateService
import dev.krtirtho.spotube.modules.webview.WebViewScreen import dev.krtirtho.spotube.modules.webview.WebViewScreen
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
@ -66,7 +60,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlin.time.Clock
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.navigation3.koinEntryProvider import org.koin.compose.navigation3.koinEntryProvider
import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinExperimentalAPI
@ -84,7 +77,7 @@ interface NavigationItem {
private val tabs = listOf<NavigationItem>( private val tabs = listOf<NavigationItem>(
NavigationItem.Tab<Any>("Home", Iconsax.IconsaxHome, Routes.Home), NavigationItem.Tab<Any>("Home", Iconsax.IconsaxHome, Routes.Home),
NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search) NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search),
) )
val sidebarTabs = tabs + listOf( val sidebarTabs = tabs + listOf(
@ -98,7 +91,7 @@ val sidebarTabs = tabs + listOf(
LibraryTab.Playlists LibraryTab.Playlists
), ),
NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums), NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums),
NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists) NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists),
) )
), ),
NavigationItem.Group( NavigationItem.Group(
@ -115,23 +108,27 @@ val sidebarTabs = tabs + listOf(
Iconsax.IconsaxDirectboxReceive, Iconsax.IconsaxDirectboxReceive,
Routes.Library, Routes.Library,
LibraryTab.Downloads LibraryTab.Downloads
) ),
) )
), ),
NavigationItem.Group( NavigationItem.Group(
"Connect", "Connect",
items = listOf( items = listOf(
NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices), NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices),
NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam) NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam),
) ),
) ),
) )
val bottombarTabs = tabs + listOf( val bottombarTabs = tabs + listOf(
NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library), NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library),
NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings) NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings)
) )
@OptIn(KoinExperimentalAPI::class) @OptIn(
KoinExperimentalAPI::class,
ExperimentalCoroutinesApi::class,
ExperimentalSharedTransitionApi::class
)
@Composable @Composable
fun App( fun App(
content: @Composable () -> Unit = {} content: @Composable () -> Unit = {}
@ -143,68 +140,24 @@ fun App(
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle() val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle() val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
val welcomeRepository: WelcomeRepository = koinInject()
val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle()
var showSupportDialog by remember { mutableStateOf(false) }
var supportDialogIsReminder by remember { mutableStateOf(false) }
// Nudge the user to support the project at most once every seven days, unless they opted
// out in Settings. The first eligible launch after onboarding only records the baseline, so
// the prompt never appears right after the onboarding donation page.
LaunchedEffect(showOnboarding) {
if (showOnboarding) return@LaunchedEffect
// Read the persisted value rather than the collected state, whose initial value is the
// default (0) until DataStore has emitted once.
val current = settingsRepository.getCurrentSettings()
val now = Clock.System.now().toEpochMilliseconds()
// Development override: show the reminder on every launch, regardless of timing or the
// "Support reminders" toggle.
if (DebugConfig.FORCE_SUPPORT_REMINDER) {
supportDialogIsReminder = true
showSupportDialog = true
settingsRepository.updateSettings(
current.copy(lastSupportReminderEpochMs = now))
return@LaunchedEffect
}
if (!current.supportRemindersEnabled) return@LaunchedEffect
val last = current.lastSupportReminderEpochMs
if (last == 0L) {
settingsRepository.updateSettings(
current.copy(lastSupportReminderEpochMs = now))
} else if (now - last >= SupportReminderIntervalMillis) {
supportDialogIsReminder = true
showSupportDialog = true
settingsRepository.updateSettings(
current.copy(lastSupportReminderEpochMs = now))
}
}
// The app always renders the shell + NavDisplay; onboarding is just the start destination.
// When it is completed the destination flips to Home and the navigation state is rebuilt.
val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home
val navigationState = rememberNavigationState( val navigationState = rememberNavigationState(
startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
) )
val navigator = remember(navigationState) { val navigator = remember {
Navigator(navigationState) Navigator(navigationState)
} }
SpotubeTheme(settings = userSettings) { SpotubeTheme(settings = userSettings) {
CompositionLocalProvider(LocalSupportPrompt provides { val baseUITheme = rememberBaseUITheme()
supportDialogIsReminder = false CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
showSupportDialog = true
}) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
val currentRoute = val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
navigationState.backStacks[navigationState.topLevelRoute]?.last()
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
AppShell(navigator, navigationState) { AppShell(navigator, navigationState) {
SharedTransitionLayout { SharedTransitionLayout {
CompositionLocalProvider( CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout LocalSharedTransitionScope provides this@SharedTransitionLayout,
) { ) {
Column { Column {
NavDisplay( NavDisplay(
@ -231,7 +184,8 @@ fun App(
onUpdate = { onUpdate = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
appUpdateService.dismissUpdate() appUpdateService.dismissUpdate()
}) },
)
} }
availablePluginUpdate?.let { update -> availablePluginUpdate?.let { update ->
PluginUpdateDialog( PluginUpdateDialog(
@ -241,12 +195,9 @@ fun App(
onUpdate = { onUpdate = {
runCatching { openUrlInBrowser(update.info.directDownloadUrl) } runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
pluginUpdateService.dismissUpdate() pluginUpdateService.dismissUpdate()
}) },
)
} }
SupportDialog(
visible = showSupportDialog,
onDismissRequest = { showSupportDialog = false },
isReminder = supportDialogIsReminder)
} }
} }
} }

View File

@ -1,42 +0,0 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core
/**
* Development-only switches. These are intentionally kept out of [dev.krtirtho.spotube.modules.settings.UserSettings]
* so they can never leak into user-facing configuration.
*/
object DebugConfig {
/**
* Flip to `true` while developing to always open the Welcome/Onboarding screen, even after
* it has been completed (so you don't have to wipe app data to see it again). It still
* dismisses for the session when you finish or skip it.
*
* Keep it `false` when committing.
*/
const val FORCE_SHOW_ONBOARDING: Boolean = false
/**
* Flip to `true` while developing to show the support reminder dialog on the next eligible
* app launch, ignoring both the 7-day interval and the "Support reminders" opt-out toggle.
* It still waits until onboarding is dismissed, and only fires once per launch.
*
* Keep it `false` when committing.
*/
const val FORCE_SUPPORT_REMINDER: Boolean = false
}

View File

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

View File

@ -30,7 +30,8 @@ data class MediaItem(
val duration: Duration, val duration: Duration,
val coverURL: String, val coverURL: String,
val url: String, val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
)
enum class LoopState { enum class LoopState {
NONE, ONE, ALL; NONE, ONE, ALL;
@ -59,7 +60,8 @@ sealed interface QueueEntry {
val track: MetadataTrack, val track: MetadataTrack,
override val url: String, override val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
override val addedBy: String = "") : QueueEntry override val addedBy: String = "",
) : QueueEntry
@Serializable @Serializable
@SerialName("local") @SerialName("local")
@ -70,7 +72,8 @@ sealed interface QueueEntry {
val album: String?, val album: String?,
val coverBytes: ByteArray?, val coverBytes: ByteArray?,
override val url: String, override val url: String,
override val addedBy: String = "") : QueueEntry { override val addedBy: String = "",
) : QueueEntry {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other == null || this::class != other::class) return false if (other == null || this::class != other::class) return false
@ -108,12 +111,14 @@ sealed interface QueueCollectionEntry {
@Serializable @Serializable
@SerialName("playlist") @SerialName("playlist")
data class Playlist( data class Playlist(
override val id: String) : QueueCollectionEntry override val id: String,
) : QueueCollectionEntry
@Serializable @Serializable
@SerialName("album") @SerialName("album")
data class Album( data class Album(
override val id: String) : QueueCollectionEntry override val id: String,
) : QueueCollectionEntry
@Serializable @Serializable
@SerialName("saved_tracks") @SerialName("saved_tracks")
@ -127,4 +132,5 @@ data class PersistedQueueState(
val entries: List<QueueEntry>, val entries: List<QueueEntry>,
val currentIndex: Int, val currentIndex: Int,
val currentCollectionEntry: QueueCollectionEntry? = null, val currentCollectionEntry: QueueCollectionEntry? = null,
val collectionHistory: List<QueueCollectionEntry> = emptyList()) val collectionHistory: List<QueueCollectionEntry> = emptyList(),
)

View File

@ -29,7 +29,8 @@ interface AudioPlayerQueue {
entries: List<QueueEntry>, entries: List<QueueEntry>,
autoPlay: Boolean = true, autoPlay: Boolean = true,
startPosition: Int = 0, startPosition: Int = 0,
collectionEntry: QueueCollectionEntry? = null) collectionEntry: QueueCollectionEntry? = null,
)
suspend fun addToQueue(entry: QueueEntry) suspend fun addToQueue(entry: QueueEntry)
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null) suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
suspend fun addAllAfterCurrent(entries: List<QueueEntry>) suspend fun addAllAfterCurrent(entries: List<QueueEntry>)

View File

@ -45,7 +45,8 @@ class DeviceAudioPlayerQueue(
private val settingsProvider: SettingsProvider, private val settingsProvider: SettingsProvider,
private val repository: QueueStateRepository, private val repository: QueueStateRepository,
private val pluginProvider: PluginProvider, private val pluginProvider: PluginProvider,
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val blacklistRepository: BlacklistRepository,
) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>() private val logger by injectLogger<DeviceAudioPlayerQueue>()
@ -59,7 +60,8 @@ class DeviceAudioPlayerQueue(
override val queueFlow: StateFlow<List<QueueEntry>> = combine( override val queueFlow: StateFlow<List<QueueEntry>> = combine(
audioPlayer.playlistFlow, audioPlayer.playlistFlow,
entryByMediaUrl) { playlist, knownEntries -> entryByMediaUrl,
) { playlist, knownEntries ->
playlist.map { mediaItem -> playlist.map { mediaItem ->
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry() knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
} }
@ -67,7 +69,8 @@ class DeviceAudioPlayerQueue(
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine( override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
audioPlayer.currentMediaItemFlow, audioPlayer.currentMediaItemFlow,
entryByMediaUrl) { mediaItem, knownEntries -> entryByMediaUrl,
) { mediaItem, knownEntries ->
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() } mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
@ -104,7 +107,8 @@ class DeviceAudioPlayerQueue(
combine( combine(
audioPlayer.currentMediaItemFlow, audioPlayer.currentMediaItemFlow,
audioPlayer.playlistFlow, audioPlayer.playlistFlow,
audioPlayer.loopStateFlow) { currentMedia, playlist, loopState -> audioPlayer.loopStateFlow,
) { currentMedia, playlist, loopState ->
Triple(currentMedia, playlist, loopState) Triple(currentMedia, playlist, loopState)
}.collect { (currentMedia, playlist, loopState) -> }.collect { (currentMedia, playlist, loopState) ->
if (currentMedia == null || playlist.isEmpty()) return@collect if (currentMedia == null || playlist.isEmpty()) return@collect
@ -125,7 +129,8 @@ class DeviceAudioPlayerQueue(
entries: List<QueueEntry>, entries: List<QueueEntry>,
autoPlay: Boolean, autoPlay: Boolean,
startPosition: Int, startPosition: Int,
collectionEntry: QueueCollectionEntry?) { collectionEntry: QueueCollectionEntry?,
) {
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
val mediaItems = entries.mapNotNull { it.toMediaItem() } val mediaItems = entries.mapNotNull { it.toMediaItem() }
if (mediaItems.isEmpty()) { if (mediaItems.isEmpty()) {
@ -431,7 +436,8 @@ class DeviceAudioPlayerQueue(
entries = queueEntries, entries = queueEntries,
currentIndex = normalizedCurrentIndex, currentIndex = normalizedCurrentIndex,
currentCollectionEntry = currentCollectionEntryState.value, currentCollectionEntry = currentCollectionEntryState.value,
collectionHistory = collectionHistoryState.value) collectionHistory = collectionHistoryState.value,
)
) )
} }
@ -465,7 +471,8 @@ class DeviceAudioPlayerQueue(
duration = track.durationMs.milliseconds, duration = track.durationMs.milliseconds,
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(), coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
url = resolvedUrl, url = resolvedUrl,
protocol = protocol) protocol = protocol,
)
} }
is QueueEntry.LocalTrack -> { is QueueEntry.LocalTrack -> {
@ -476,7 +483,8 @@ class DeviceAudioPlayerQueue(
album = album.orEmpty(), album = album.orEmpty(),
duration = duration.milliseconds, duration = duration.milliseconds,
coverURL = "", coverURL = "",
url = url) url = url,
)
} }
} }
} }
@ -488,7 +496,8 @@ class DeviceAudioPlayerQueue(
duration = duration.inWholeMilliseconds, duration = duration.inWholeMilliseconds,
album = album.ifBlank { null }, album = album.ifBlank { null },
coverBytes = null, coverBytes = null,
url = url) url = url,
)
} }
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {

View File

@ -23,25 +23,10 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import okio.FileSystem
import okio.Path.Companion.toPath import okio.Path.Companion.toPath
import okio.SYSTEM
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
class Database(val paths: Paths) { class Database(val paths: Paths) {
/**
* True when Spotube had already written its own data before this process started. Captured
* synchronously at construction time — the earliest point, before any eager service can
* create a fresh store — so it reliably tells a brand-new install apart from an upgrade of
* an existing one (used to skip the first-run onboarding on upgrades).
*/
val isExistingInstall: Boolean = runCatching {
val dataDirPath = paths.getApplicationDataDirPath()
EXISTING_INSTALL_PROBE_FILES.any { fileName ->
FileSystem.SYSTEM.exists("$dataDirPath/$fileName".toPath())
}
}.getOrDefault(false)
val settingsDataStore: DataStore<Preferences> by lazy { val settingsDataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.createWithPath( PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() } produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() }
@ -76,18 +61,6 @@ class Database(val paths: Paths) {
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() } produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
) )
} }
private companion object {
/** Preference files written by previous launches; their presence means an upgrade. */
val EXISTING_INSTALL_PROBE_FILES = listOf(
"spotube.preferences_pb",
"spotube_plugins.preferences_pb",
"spotube_matched_tracks.preferences_pb",
"spotube_audio_player_queue.preferences_pb",
"spotube_local_media.preferences_pb",
"spotube_blacklist.preferences_pb",
"spotube_cookies.preferences_pb")
}
} }
object DatabaseKeys { object DatabaseKeys {

View File

@ -86,8 +86,6 @@ import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
import dev.krtirtho.spotube.modules.welcome.WelcomeViewModel
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.createdAtStart import org.koin.core.module.dsl.createdAtStart
@ -143,10 +141,6 @@ val sharedModules = module {
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() } viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel) viewModelOf(::JamSettingsViewModel)
// Welcome / Onboarding
singleOf(::WelcomeRepository)
viewModelOf(::WelcomeViewModel)
// Updates // Updates
single { AppUpdateService(get()) } withOptions { createdAtStart() } single { AppUpdateService(get()) } withOptions { createdAtStart() }
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
@ -168,7 +162,8 @@ val sharedModules = module {
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get()) remotePlaybackController = get(),
)
} }
// Saved Tracks // Saved Tracks
@ -182,7 +177,8 @@ val sharedModules = module {
libraryRepository = get(), libraryRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get()) remotePlaybackController = get(),
)
} }
// Artist // Artist
@ -197,7 +193,8 @@ val sharedModules = module {
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get()) remotePlaybackController = get(),
)
} }
// Blacklist // Blacklist
@ -209,7 +206,8 @@ val sharedModules = module {
JamViewModel( JamViewModel(
jamRoomService = get(), jamRoomService = get(),
shareService = get(), shareService = get(),
settingsProvider = get()) settingsProvider = get(),
)
} }
// Album // Album
@ -225,7 +223,8 @@ val sharedModules = module {
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get()) remotePlaybackController = get(),
)
} }
// Lyrics // Lyrics
@ -239,7 +238,8 @@ val sharedModules = module {
savedTracksRepository = get(), savedTracksRepository = get(),
artistRepository = get(), artistRepository = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
blacklistRepository = get()) blacklistRepository = get(),
)
} }
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() } singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
single { single {
@ -247,7 +247,8 @@ val sharedModules = module {
pluginManager = get(), pluginManager = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
matchedTracksRepository = get(), matchedTracksRepository = get(),
settingsRepository = get()) settingsRepository = get(),
)
} }
singleOf(::CacheManager) singleOf(::CacheManager)
singleOf(::AlternativeTracksRepository) singleOf(::AlternativeTracksRepository)

View File

@ -34,7 +34,8 @@ import kotlin.time.Clock
class DiscordRpcService( class DiscordRpcService(
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable { private val settingsProvider: SettingsProvider,
) : KoinComponent, AutoCloseable {
private val logger by injectLogger<DiscordRpcService>() private val logger by injectLogger<DiscordRpcService>()
@ -60,7 +61,8 @@ class DiscordRpcService(
audioPlayer.playerStateFlow, audioPlayer.playerStateFlow,
audioPlayer.currentMediaItemFlow, audioPlayer.currentMediaItemFlow,
audioPlayer.positionFlow, audioPlayer.positionFlow,
audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration -> audioPlayer.durationFlow,
) { settings, playerState, mediaItem, position, duration ->
DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration) DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration)
}.collect { state -> }.collect { state ->
handleStateChange(state) handleStateChange(state)
@ -84,7 +86,8 @@ class DiscordRpcService(
album = mediaItem.album, album = mediaItem.album,
coverUrl = mediaItem.coverURL, coverUrl = mediaItem.coverURL,
positionMs = state.position.inWholeMilliseconds, positionMs = state.position.inWholeMilliseconds,
durationMs = state.duration.inWholeMilliseconds) durationMs = state.duration.inWholeMilliseconds,
)
} }
PlayerState.PAUSED -> { PlayerState.PAUSED -> {
val mediaItem = state.mediaItem ?: return val mediaItem = state.mediaItem ?: return
@ -95,7 +98,8 @@ class DiscordRpcService(
album = mediaItem.album, album = mediaItem.album,
coverUrl = mediaItem.coverURL, coverUrl = mediaItem.coverURL,
positionMs = state.position.inWholeMilliseconds, positionMs = state.position.inWholeMilliseconds,
durationMs = state.duration.inWholeMilliseconds) durationMs = state.duration.inWholeMilliseconds,
)
} }
PlayerState.IDLE, PlayerState.COMPLETED -> { PlayerState.IDLE, PlayerState.COMPLETED -> {
clearPresence() clearPresence()
@ -154,7 +158,8 @@ class DiscordRpcService(
album: String, album: String,
coverUrl: String, coverUrl: String,
positionMs: Long, positionMs: Long,
durationMs: Long) { durationMs: Long,
) {
val client = rpcClient ?: return val client = rpcClient ?: return
try { try {
client.updatePresence( client.updatePresence(
@ -163,7 +168,8 @@ class DiscordRpcService(
album = album, album = album,
coverUrl = coverUrl, coverUrl = coverUrl,
positionMs = positionMs, positionMs = positionMs,
durationMs = durationMs) durationMs = durationMs,
)
} catch (e: Exception) { } catch (e: Exception) {
rpcClient = null rpcClient = null
connectionFailed = true connectionFailed = true
@ -204,5 +210,6 @@ class DiscordRpcService(
val playerState: PlayerState, val playerState: PlayerState,
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?, val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
val position: kotlin.time.Duration, val position: kotlin.time.Duration,
val duration: kotlin.time.Duration) val duration: kotlin.time.Duration,
)
} }

View File

@ -30,7 +30,8 @@ data class DiscoveredDevice(
val type: String, val type: String,
val host: String, val host: String,
val port: Int, val port: Int,
val deviceId: String) { val deviceId: String,
) {
val key: String get() = "$name$type".replace(".", "") val key: String get() = "$name$type".replace(".", "")
} }
@ -54,7 +55,8 @@ class DeviceDiscoveryService {
type = event.service.type, type = event.service.type,
host = event.service.host, host = event.service.host,
port = event.service.port, port = event.service.port,
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
)
DiscoveryState.Discovered(device = device, resolve = event.resolve) DiscoveryState.Discovered(device = device, resolve = event.resolve)
} }
@ -64,7 +66,8 @@ class DeviceDiscoveryService {
type = event.service.type, type = event.service.type,
host = event.service.host, host = event.service.host,
port = event.service.port, port = event.service.port,
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
)
DiscoveryState.Resolved(device = device) DiscoveryState.Resolved(device = device)
} }
@ -74,7 +77,8 @@ class DeviceDiscoveryService {
type = event.service.type, type = event.service.type,
host = event.service.host, host = event.service.host,
port = event.service.port, port = event.service.port,
deviceId = "") deviceId = "",
)
DiscoveryState.Removed(device = device) DiscoveryState.Removed(device = device)
} }
} }
@ -84,7 +88,8 @@ class DeviceDiscoveryService {
name: String, name: String,
port: Int, port: Int,
deviceId: String, deviceId: String,
registerTimeoutMs: Long = 5_000): NetService { registerTimeoutMs: Long = 5_000,
): NetService {
val service = createService(name, port, deviceId) val service = createService(name, port, deviceId)
service.register(timeoutInMs = registerTimeoutMs) service.register(timeoutInMs = registerTimeoutMs)
return service return service
@ -93,9 +98,11 @@ class DeviceDiscoveryService {
fun createService( fun createService(
name: String, name: String,
port: Int, port: Int,
deviceId: String): NetService = createNetService( deviceId: String,
): NetService = createNetService(
type = SERVICE_TYPE, type = SERVICE_TYPE,
name = name, name = name,
port = port, port = port,
txt = mapOf(TXT_DEVICE_ID to deviceId)) txt = mapOf(TXT_DEVICE_ID to deviceId),
)
} }

View File

@ -46,7 +46,8 @@ sealed class JamMessage {
*/ */
val follow: Boolean = false, val follow: Boolean = false,
/** Host's live play state — late-joining guests start with it. */ /** Host's live play state — late-joining guests start with it. */
val isPlaying: Boolean = false) : JamMessage() val isPlaying: Boolean = false,
) : JamMessage()
@Serializable @Serializable
@SerialName("playbackCommand") @SerialName("playbackCommand")
@ -56,19 +57,22 @@ sealed class JamMessage {
@SerialName("suggestTrack") @SerialName("suggestTrack")
data class SuggestTrack( data class SuggestTrack(
val mediaItem: JamMediaItem, val mediaItem: JamMediaItem,
val addedBy: String = "") : JamMessage() val addedBy: String = "",
) : JamMessage()
@Serializable @Serializable
@SerialName("suggestPlaylist") @SerialName("suggestPlaylist")
data class SuggestPlaylist( data class SuggestPlaylist(
val tracks: List<JamMediaItem>, val tracks: List<JamMediaItem>,
val addedBy: String = "") : JamMessage() val addedBy: String = "",
) : JamMessage()
@Serializable @Serializable
@SerialName("kick") @SerialName("kick")
data class Kick( data class Kick(
val participantId: String, val participantId: String,
val reason: String = "kicked") : JamMessage() val reason: String = "kicked",
) : JamMessage()
@Serializable @Serializable
@SerialName("leave") @SerialName("leave")
@ -100,7 +104,8 @@ data class JamPresence(
val clientId: String, val clientId: String,
val displayName: String, val displayName: String,
val isHost: Boolean, val isHost: Boolean,
val left: Boolean = false) val left: Boolean = false,
)
@Serializable @Serializable
data class JamMediaItem( data class JamMediaItem(
@ -112,7 +117,8 @@ data class JamMediaItem(
val durationMs: Long, val durationMs: Long,
val coverUrl: String, val coverUrl: String,
val protocol: String, val protocol: String,
val addedBy: String = "") { val addedBy: String = "",
) {
companion object { companion object {
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
is QueueEntry.StreamingTrack -> JamMediaItem( is QueueEntry.StreamingTrack -> JamMediaItem(
@ -126,7 +132,8 @@ data class JamMediaItem(
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
.orEmpty(), .orEmpty(),
protocol = entry.protocol.name, protocol = entry.protocol.name,
addedBy = entry.addedBy) addedBy = entry.addedBy,
)
is QueueEntry.LocalTrack -> JamMediaItem( is QueueEntry.LocalTrack -> JamMediaItem(
url = entry.url, url = entry.url,
@ -137,7 +144,8 @@ data class JamMediaItem(
durationMs = entry.duration, durationMs = entry.duration,
coverUrl = "", coverUrl = "",
protocol = "PROGRESSIVE", protocol = "PROGRESSIVE",
addedBy = entry.addedBy) addedBy = entry.addedBy,
)
} }
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem( fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
@ -150,7 +158,8 @@ data class JamMediaItem(
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
.orEmpty(), .orEmpty(),
protocol = "PROGRESSIVE") protocol = "PROGRESSIVE",
)
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem( fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
url = item.url, url = item.url,
@ -159,7 +168,8 @@ data class JamMediaItem(
album = item.album, album = item.album,
durationMs = item.duration.inWholeMilliseconds, durationMs = item.duration.inWholeMilliseconds,
coverUrl = item.coverURL, coverUrl = item.coverURL,
protocol = item.protocol.name) protocol = item.protocol.name,
)
fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem( fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
title = item.title, title = item.title,
@ -169,7 +179,8 @@ data class JamMediaItem(
coverURL = item.coverUrl, coverURL = item.coverUrl,
url = item.url, url = item.url,
protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
.valueOf(item.protocol.ifBlank { "PROGRESSIVE" })) .valueOf(item.protocol.ifBlank { "PROGRESSIVE" }),
)
} }
} }
@ -177,7 +188,8 @@ data class JamMediaItem(
data class JamParticipant( data class JamParticipant(
val id: String, val id: String,
val displayName: String, val displayName: String,
val isHost: Boolean) val isHost: Boolean,
)
@Serializable @Serializable
enum class JamRole { enum class JamRole {

View File

@ -30,8 +30,10 @@ import com.ditchoom.mqtt.controlpacket.TopicName
import com.ditchoom.mqtt.controlpacket.WillConfig import com.ditchoom.mqtt.controlpacket.WillConfig
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
import dev.krtirtho.spotube.modules.settings.JamBroker import dev.krtirtho.spotube.modules.settings.JamBroker
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -45,7 +47,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import kotlin.time.Duration.Companion.seconds
/** /**
* Thin facade over the Ditchoom MQTT 5 client for one jam room. * Thin facade over the Ditchoom MQTT 5 client for one jam room.
@ -113,7 +114,8 @@ class JamRoomClient : KoinComponent {
code: String, code: String,
clientId: String, clientId: String,
displayName: String, displayName: String,
isHost: Boolean): Result<Unit> { isHost: Boolean,
): Result<Unit> {
disconnect() disconnect()
if (broker.host.isBlank()) { if (broker.host.isBlank()) {
return Result.failure(IllegalArgumentException("No jam broker configured")) return Result.failure(IllegalArgumentException("No jam broker configured"))
@ -124,7 +126,8 @@ class JamRoomClient : KoinComponent {
clientId = clientId, clientId = clientId,
displayName = displayName, displayName = displayName,
isHost = isHost, isHost = isHost,
left = false) left = false,
)
val mqtt = startClient(broker, clientId) val mqtt = startClient(broker, clientId)
client = mqtt client = mqtt
@ -164,7 +167,8 @@ class JamRoomClient : KoinComponent {
publishJson( publishJson(
presenceTopic(code, presence.clientId), presenceTopic(code, presence.clientId),
json.encodeToString(JamPresence.serializer(), presence), json.encodeToString(JamPresence.serializer(), presence),
retain = true) retain = true,
)
} }
/** Marks us as the host in presence (host takeover). */ /** Marks us as the host in presence (host takeover). */
@ -182,7 +186,8 @@ class JamRoomClient : KoinComponent {
publishJson( publishJson(
presenceTopic(code, presence.clientId), presenceTopic(code, presence.clientId),
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)), json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
retain = true) retain = true,
)
} }
} }
@ -203,22 +208,26 @@ class JamRoomClient : KoinComponent {
host = broker.host, host = broker.host,
port = broker.port, port = broker.port,
tlsEnabled = broker.useTls, tlsEnabled = broker.useTls,
connectionTimeout = broker.connectionTimeoutSeconds.seconds) connectionTimeout = broker.connectionTimeoutSeconds.seconds,
)
val code = roomCode ?: "unset" val code = roomCode ?: "unset"
val will = WillConfig.Enabled( val will = WillConfig.Enabled(
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)), topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
payload = json.encodeToString( payload = json.encodeToString(
JamPresence.serializer(), JamPresence.serializer(),
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8), JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true),
).toReadBuffer(Charset.UTF8),
qos = QualityOfService.AT_LEAST_ONCE, qos = QualityOfService.AT_LEAST_ONCE,
retain = true) retain = true,
)
val request = ConnectionRequest( val request = ConnectionRequest(
clientId = clientId, clientId = clientId,
keepAliveSeconds = broker.keepAliveSeconds, keepAliveSeconds = broker.keepAliveSeconds,
cleanStart = true, cleanStart = true,
userName = broker.username, userName = broker.username,
password = broker.password, password = broker.password,
will = will) will = will,
)
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true) val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
val brokerRef = persistence.addBroker(connection, request) val brokerRef = persistence.addBroker(connection, request)
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence) return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
@ -256,7 +265,8 @@ class JamRoomClient : KoinComponent {
val operation = mqtt.subscribe( val operation = mqtt.subscribe(
roomFilter(code), roomFilter(code),
OpaquePublishPayloadCodec, OpaquePublishPayloadCodec,
QualityOfService.AT_LEAST_ONCE) { publish, payload -> QualityOfService.AT_LEAST_ONCE,
) { publish, payload ->
route(publish.topic.toString(), payload) route(publish.topic.toString(), payload)
} }
runCatching { operation.subAck.await() } runCatching { operation.subAck.await() }
@ -299,7 +309,8 @@ class JamRoomClient : KoinComponent {
topicName = topic, topicName = topic,
qos = QualityOfService.AT_LEAST_ONCE, qos = QualityOfService.AT_LEAST_ONCE,
payload = payload.toReadBuffer(Charset.UTF8), payload = payload.toReadBuffer(Charset.UTF8),
retain = retain) retain = retain,
)
}.onFailure { e -> }.onFailure { e ->
log.w(e) { "Failed to publish to $topic" } log.w(e) { "Failed to publish to $topic" }
} }

View File

@ -59,13 +59,15 @@ private data class PlaybackBroadcast(
val queue: List<QueueEntry>, val queue: List<QueueEntry>,
val current: QueueEntry?, val current: QueueEntry?,
val shuffle: Boolean, val shuffle: Boolean,
val playerState: PlayerState) val playerState: PlayerState,
)
class JamRoomService( class JamRoomService(
private val jamClient: JamRoomClient, private val jamClient: JamRoomClient,
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val settingsRepository: SettingsRepository) { private val settingsRepository: SettingsRepository,
) {
private val log = Logger.withTag("JamRoomService") private val log = Logger.withTag("JamRoomService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -169,7 +171,8 @@ class JamRoomService(
code = code, code = code,
clientId = localClientId, clientId = localClientId,
displayName = name, displayName = name,
isHost = true).map { isHost = true,
).map {
_role.value = JamRole.Host _role.value = JamRole.Host
_roomCode.value = code _roomCode.value = code
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true)) _participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
@ -203,7 +206,8 @@ class JamRoomService(
code = normalized, code = normalized,
clientId = localClientId, clientId = localClientId,
displayName = name, displayName = name,
isHost = false).map { isHost = false,
).map {
_role.value = JamRole.Guest _role.value = JamRole.Guest
_roomCode.value = normalized _roomCode.value = normalized
_participants.value = emptyList() _participants.value = emptyList()
@ -257,7 +261,8 @@ class JamRoomService(
jamClient.publishCommand( jamClient.publishCommand(
JamMessage.SuggestTrack( JamMessage.SuggestTrack(
mediaItem = JamMediaItem.fromTrack(track), mediaItem = JamMediaItem.fromTrack(track),
addedBy = localClientId) addedBy = localClientId,
)
) )
} }
@ -266,7 +271,8 @@ class JamRoomService(
jamClient.publishCommand( jamClient.publishCommand(
JamMessage.SuggestPlaylist( JamMessage.SuggestPlaylist(
tracks = tracks.map(JamMediaItem::fromTrack), tracks = tracks.map(JamMediaItem::fromTrack),
addedBy = localClientId) addedBy = localClientId,
)
) )
} }
@ -290,7 +296,8 @@ class JamRoomService(
audioPlayerQueue.queueFlow, audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.shuffleModeFlow, audioPlayer.shuffleModeFlow,
audioPlayer.playerStateFlow) { queue, current, shuffle, playerState -> audioPlayer.playerStateFlow,
) { queue, current, shuffle, playerState ->
PlaybackBroadcast(queue, current, shuffle, playerState) PlaybackBroadcast(queue, current, shuffle, playerState)
} }
.onEach { broadcast -> .onEach { broadcast ->
@ -307,7 +314,8 @@ class JamRoomService(
currentIndex = index.coerceAtLeast(0), currentIndex = index.coerceAtLeast(0),
shuffleEnabled = broadcast.shuffle, shuffleEnabled = broadcast.shuffle,
follow = now() > autoAdvanceDeadlineMs, follow = now() > autoAdvanceDeadlineMs,
isPlaying = broadcast.playerState == PlayerState.PLAYING) isPlaying = broadcast.playerState == PlayerState.PLAYING,
)
) )
} }
.launchIn(this) .launchIn(this)
@ -366,7 +374,8 @@ class JamRoomService(
audioPlayerQueue.load( audioPlayerQueue.load(
entries = items.map { it.toQueueEntry() }, entries = items.map { it.toQueueEntry() },
autoPlay = wasPlaying, autoPlay = wasPlaying,
startPosition = startIndex) startPosition = startIndex,
)
}.onFailure { log.w(it) { "Failed to apply jam queue" } } }.onFailure { log.w(it) { "Failed to apply jam queue" } }
return return
} }
@ -515,11 +524,13 @@ class JamRoomService(
explicit = null, explicit = null,
popularity = null, popularity = null,
isrcCode = null, isrcCode = null,
externalUri = null), externalUri = null,
),
url = "", url = "",
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
.getOrDefault(StreamProtocol.PROGRESSIVE), .getOrDefault(StreamProtocol.PROGRESSIVE),
addedBy = addedBy) addedBy = addedBy,
)
else -> QueueEntry.LocalTrack( else -> QueueEntry.LocalTrack(
name = title, name = title,
@ -528,7 +539,8 @@ class JamRoomService(
album = album.ifBlank { null }, album = album.ifBlank { null },
coverBytes = null, coverBytes = null,
url = url, url = url,
addedBy = addedBy) addedBy = addedBy,
)
} }
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean { private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {

View File

@ -34,7 +34,6 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
import dev.krtirtho.spotube.modules.search.SearchScreen import dev.krtirtho.spotube.modules.search.SearchScreen
import dev.krtirtho.spotube.modules.settings.SettingsScreen import dev.krtirtho.spotube.modules.settings.SettingsScreen
import dev.krtirtho.spotube.modules.settings.JamSettingsScreen import dev.krtirtho.spotube.modules.settings.JamSettingsScreen
import dev.krtirtho.spotube.modules.welcome.WelcomeScreen
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinExperimentalAPI
@ -92,9 +91,6 @@ sealed interface Routes : NavKey {
@Serializable @Serializable
data object JamSettings : Routes data object JamSettings : Routes
@Serializable
data object Welcome : Routes
} }
@OptIn(KoinExperimentalAPI::class) @OptIn(KoinExperimentalAPI::class)
@ -125,7 +121,8 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
navigationCommands = get()) navigationCommands = get(),
)
} }
navigation<Routes.Artist> { navigation<Routes.Artist> {
ArtistScreen( ArtistScreen(
@ -135,7 +132,8 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
navigationCommands = get()) navigationCommands = get(),
)
} }
navigation<Routes.Album> { navigation<Routes.Album> {
AlbumScreen( AlbumScreen(
@ -146,7 +144,8 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
navigationCommands = get()) navigationCommands = get(),
)
} }
navigation<Routes.Lyrics> { navigation<Routes.Lyrics> {
LyricsScreen(viewModel = koinViewModel()) LyricsScreen(viewModel = koinViewModel())
@ -159,7 +158,8 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
navigationCommands = get()) navigationCommands = get(),
)
} }
navigation<Routes.Blacklist> { navigation<Routes.Blacklist> {
BlacklistScreen() BlacklistScreen()
@ -178,7 +178,4 @@ val navigationModule = module {
navigation<Routes.JamSettings> { navigation<Routes.JamSettings> {
JamSettingsScreen(viewModel = koinViewModel()) JamSettingsScreen(viewModel = koinViewModel())
} }
navigation<Routes.Welcome> {
WelcomeScreen(viewModel = koinViewModel())
}
} }

View File

@ -26,6 +26,7 @@ import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
@ -44,9 +45,8 @@ val TOP_LEVEL_ROUTES = setOf(
Routes.Home, Routes.Home,
Routes.Search, Routes.Search,
Routes.Library, Routes.Library,
Routes.Settings, Routes.Settings
// Bootstrap destination: needs its own back stack when the app starts on it. )
Routes.Welcome)
val serializersConfig = SavedStateConfiguration { val serializersConfig = SavedStateConfiguration {
serializersModule = SerializersModule { serializersModule = SerializersModule {
@ -63,7 +63,6 @@ val serializersConfig = SavedStateConfiguration {
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer()) subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
subclass(Routes.Devices::class, Routes.Devices.serializer()) subclass(Routes.Devices::class, Routes.Devices.serializer())
subclass(Routes.Jam::class, Routes.Jam.serializer()) subclass(Routes.Jam::class, Routes.Jam.serializer())
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
} }
} }
} }
@ -122,7 +121,9 @@ fun NavigationState.toEntries(
): SnapshotStateList<NavEntry<NavKey>> { ): SnapshotStateList<NavEntry<NavKey>> {
val decoratedEntries = backStacks.mapValues { (_, stack) -> val decoratedEntries = backStacks.mapValues { (_, stack) ->
val decorators = listOf( val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>()) rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
rememberViewModelStoreNavEntryDecorator()
)
rememberDecoratedNavEntries( rememberDecoratedNavEntries(
backStack = stack, backStack = stack,
entryDecorators = decorators, entryDecorators = decorators,

View File

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

View File

@ -34,7 +34,8 @@ class CollectionPlaybackHelper(
private val savedTracksRepository: SavedTracksRepository, private val savedTracksRepository: SavedTracksRepository,
private val artistRepository: ArtistRepository, private val artistRepository: ArtistRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository) { private val blacklistRepository: BlacklistRepository,
) {
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) { suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it } val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
@ -43,7 +44,8 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = QueueCollectionEntry.Album(albumId)) collectionEntry = QueueCollectionEntry.Album(albumId),
)
} }
} }
@ -52,7 +54,8 @@ class CollectionPlaybackHelper(
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.addAllToQueue( audioPlayerQueue.addAllToQueue(
entries, entries,
collectionEntry = QueueCollectionEntry.Album(albumId)) collectionEntry = QueueCollectionEntry.Album(albumId),
)
} }
} }
@ -84,7 +87,8 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = startPosition, startPosition = startPosition,
collectionEntry = QueueCollectionEntry.Album(albumId)) collectionEntry = QueueCollectionEntry.Album(albumId),
)
} }
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) { suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
@ -95,7 +99,8 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = QueueCollectionEntry.Playlist(playlistId)) collectionEntry = QueueCollectionEntry.Playlist(playlistId),
)
} }
} }
@ -104,7 +109,8 @@ class CollectionPlaybackHelper(
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.addAllToQueue( audioPlayerQueue.addAllToQueue(
entries, entries,
collectionEntry = QueueCollectionEntry.Playlist(playlistId)) collectionEntry = QueueCollectionEntry.Playlist(playlistId),
)
} }
} }
@ -136,7 +142,8 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = startPosition, startPosition = startPosition,
collectionEntry = QueueCollectionEntry.Playlist(playlistId)) collectionEntry = QueueCollectionEntry.Playlist(playlistId),
)
} }
suspend fun playSavedTracks(shuffle: Boolean = false) { suspend fun playSavedTracks(shuffle: Boolean = false) {
@ -147,7 +154,8 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = QueueCollectionEntry.SavedTracks) collectionEntry = QueueCollectionEntry.SavedTracks,
)
} }
} }
@ -158,7 +166,8 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null,
)
} }
} }
@ -181,7 +190,8 @@ class CollectionPlaybackHelper(
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.addAllToQueue( audioPlayerQueue.addAllToQueue(
entries, entries,
collectionEntry = QueueCollectionEntry.SavedTracks) collectionEntry = QueueCollectionEntry.SavedTracks,
)
} }
} }
@ -206,7 +216,8 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = startPosition, startPosition = startPosition,
collectionEntry = QueueCollectionEntry.SavedTracks) collectionEntry = QueueCollectionEntry.SavedTracks,
)
} }
private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> { private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> {
@ -302,7 +313,8 @@ class CollectionPlaybackHelper(
private fun isTrackBlacklisted( private fun isTrackBlacklisted(
track: MetadataTrack, track: MetadataTrack,
blacklistedTrackIds: Set<String>, blacklistedTrackIds: Set<String>,
blacklistedArtistIds: Set<String>): Boolean { blacklistedArtistIds: Set<String>,
): Boolean {
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds } return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
} }

View File

@ -17,24 +17,17 @@
package dev.krtirtho.spotube.core.remote package dev.krtirtho.spotube.core.remote
import androidx.compose.foundation.layout.Arrangement import androidx.compose.material3.Button
import androidx.compose.foundation.layout.Row import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Text
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog
import org.koin.compose.koinInject import org.koin.compose.koinInject
@Composable @Composable
@ -49,45 +42,50 @@ fun ConnectionRequestDialogHost() {
} }
pendingRequest?.let { request -> pendingRequest?.let { request ->
MillenniumDialog( AdaptiveDialogBottomSheet(
visible = true, onDismiss = {
onDismissRequest = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
pendingRequest = null pendingRequest = null
}, },
title = "Remote Control Request") { title = {
Text( Text(
text = "\"${request.deviceName}\" wants to control playback on this device.", text = "Remote Control Request",
style = AppTheme.typography.bodyMedium, style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Start) )
Row( },
modifier = Modifier.fillMaxWidth(), content = {
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), Text(
verticalAlignment = Alignment.CenterVertically) { text = "\"${request.deviceName}\" wants to control playback on this device.",
MillenniumButton( style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Start,
)
},
actions = {
Button(
onClick = { onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
pendingRequest = null pendingRequest = null
}, },
style = MillenniumButtonStyle.Secondary) { ) {
Text("Deny") Text("Deny")
} }
MillenniumButton( Button(
onClick = { onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow) handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
pendingRequest = null pendingRequest = null
}) { },
) {
Text("Allow") Text("Allow")
} }
MillenniumButton( Button(
onClick = { onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
pendingRequest = null pendingRequest = null
}, },
style = MillenniumButtonStyle.Quiet) { ) {
Text("Allow Always") Text("Allow Always")
} }
} },
} )
} }
} }

View File

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

View File

@ -48,11 +48,13 @@ class RemoteControlHandler(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent { private val collectionPlaybackHelper: CollectionPlaybackHelper,
) : KoinComponent {
val logger by injectLogger<RemoteControlHandler>() val logger by injectLogger<RemoteControlHandler>()
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>( val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
extraBufferCapacity = 16) extraBufferCapacity = 16,
)
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@ -86,7 +88,8 @@ class RemoteControlHandler(
deviceId = deviceId ?: "unknown", deviceId = deviceId ?: "unknown",
deviceName = deviceName, deviceName = deviceName,
sessionId = session.call.request.headers["X-Request-Id"] sessionId = session.call.request.headers["X-Request-Id"]
?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}") ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}",
)
incomingConnectionRequests.emit(request) incomingConnectionRequests.emit(request)
val response = waitForRequestResolution(request.sessionId) val response = waitForRequestResolution(request.sessionId)
if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) { if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) {
@ -222,7 +225,8 @@ class RemoteControlHandler(
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")), entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null,
)
} }
is RemoteControlCommand.AddTrackToQueue -> { is RemoteControlCommand.AddTrackToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = "")) audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
@ -296,7 +300,8 @@ class RemoteControlHandler(
private suspend fun handleCollectionSource( private suspend fun handleCollectionSource(
source: String, source: String,
action: RemoteCollectionAction, action: RemoteCollectionAction,
shuffle: Boolean = false) { shuffle: Boolean = false,
) {
logger.d { "Remote collection $action for source: $source" } logger.d { "Remote collection $action for source: $source" }
when { when {
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
@ -354,7 +359,8 @@ class RemoteControlHandler(
currentTrackTitle = current?.titleOrNull(), currentTrackTitle = current?.titleOrNull(),
currentTrackArtists = current?.artistsOrNull(), currentTrackArtists = current?.artistsOrNull(),
currentTrackAlbum = current?.albumOrNull(), currentTrackAlbum = current?.albumOrNull(),
currentTrackCoverUrl = current?.coverUrlOrNull()) currentTrackCoverUrl = current?.coverUrlOrNull(),
)
val text = json.encodeToString(RemoteControlEvent.serializer(), state) val text = json.encodeToString(RemoteControlEvent.serializer(), state)
session.send(Frame.Text(text)) session.send(Frame.Text(text))
} }
@ -369,7 +375,8 @@ class RemoteControlHandler(
} }
val event = RemoteControlEvent.QueueUpdated( val event = RemoteControlEvent.QueueUpdated(
entries = queue.map { it.toRemoteQueueEntry() }, entries = queue.map { it.toRemoteQueueEntry() },
currentIndex = currentIndex) currentIndex = currentIndex,
)
val text = json.encodeToString(RemoteControlEvent.serializer(), event) val text = json.encodeToString(RemoteControlEvent.serializer(), event)
session.send(Frame.Text(text)) session.send(Frame.Text(text))
} }
@ -396,7 +403,8 @@ class RemoteControlHandler(
artists = track.artists.joinToString(", ") { artist -> artist.name }, artists = track.artists.joinToString(", ") { artist -> artist.name },
album = track.album?.title, album = track.album?.title,
coverUrl = coverUrlOrNull(), coverUrl = coverUrlOrNull(),
durationMs = track.durationMs) durationMs = track.durationMs,
)
is QueueEntry.LocalTrack -> RemoteQueueEntry( is QueueEntry.LocalTrack -> RemoteQueueEntry(
mediaUrl = url, mediaUrl = url,
@ -405,7 +413,8 @@ class RemoteControlHandler(
artists = artists.joinToString(", "), artists = artists.joinToString(", "),
album = album, album = album,
coverUrl = null, coverUrl = null,
durationMs = duration) durationMs = duration,
)
} }
private fun QueueEntry.mediaKey(): String = when (this) { private fun QueueEntry.mediaKey(): String = when (this) {
@ -438,7 +447,8 @@ class RemoteControlHandler(
@Serializable @Serializable
data class CommandEnvelope( data class CommandEnvelope(
val commandId: String, val commandId: String,
val command: RemoteControlCommand) val command: RemoteControlCommand,
)
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/" private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/" private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
@ -454,7 +464,8 @@ enum class RemoteCollectionAction {
data class ConnectionRequest( data class ConnectionRequest(
val deviceId: String, val deviceId: String,
val deviceName: String, val deviceName: String,
val sessionId: String) val sessionId: String,
)
enum class ConnectionRequestResponse { enum class ConnectionRequestResponse {
Allow, Allow,

View File

@ -119,13 +119,15 @@ sealed class RemoteControlEvent {
val currentTrackTitle: String?, val currentTrackTitle: String?,
val currentTrackArtists: String?, val currentTrackArtists: String?,
val currentTrackAlbum: String?, val currentTrackAlbum: String?,
val currentTrackCoverUrl: String?) : RemoteControlEvent() val currentTrackCoverUrl: String?,
) : RemoteControlEvent()
@Serializable @Serializable
@SerialName("queueUpdated") @SerialName("queueUpdated")
data class QueueUpdated( data class QueueUpdated(
val entries: List<RemoteQueueEntry>, val entries: List<RemoteQueueEntry>,
val currentIndex: Int) : RemoteControlEvent() val currentIndex: Int,
) : RemoteControlEvent()
@Serializable @Serializable
@SerialName("ack") @SerialName("ack")
@ -144,4 +146,5 @@ data class RemoteQueueEntry(
val artists: String, val artists: String,
val album: String?, val album: String?,
val coverUrl: String?, val coverUrl: String?,
val durationMs: Long) val durationMs: Long,
)

View File

@ -53,7 +53,8 @@ import kotlin.random.Random
class RemoteControlService( class RemoteControlService(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val discoveryService: DeviceDiscoveryService, private val discoveryService: DeviceDiscoveryService,
private val localServer: LocalServer) { private val localServer: LocalServer,
) {
private val log = Logger.withTag("RemoteControlService") private val log = Logger.withTag("RemoteControlService")
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -77,7 +78,8 @@ class RemoteControlService(
scope.launch { scope.launch {
combine( combine(
settingsRepository.userSettings, settingsRepository.userSettings,
localServer.port) { settings, port -> settings to port } localServer.port,
) { settings, port -> settings to port }
.distinctUntilChanged() .distinctUntilChanged()
.collect { (settings, port) -> .collect { (settings, port) ->
if (settings.allowRemoteControl && port != null) { if (settings.allowRemoteControl && port != null) {
@ -137,7 +139,8 @@ class RemoteControlService(
val service = discoveryService.createService( val service = discoveryService.createService(
name = serviceName, name = serviceName,
port = port, port = port,
deviceId = deviceId) deviceId = deviceId,
)
pendingService = service pendingService = service
try { try {
service.register(timeoutInMs = REGISTER_TIMEOUT_MS) service.register(timeoutInMs = REGISTER_TIMEOUT_MS)

View File

@ -64,17 +64,20 @@ sealed interface PlaybackDestinationRequest {
val type: RemoteCollectionType, val type: RemoteCollectionType,
val id: String, val id: String,
val startTrack: MetadataTrack? = null, val startTrack: MetadataTrack? = null,
val shuffle: Boolean = false) : PlaybackDestinationRequest val shuffle: Boolean = false,
) : PlaybackDestinationRequest
data class Track( data class Track(
override val title: String, override val title: String,
override val action: PlaybackDestinationAction, override val action: PlaybackDestinationAction,
val track: MetadataTrack) : PlaybackDestinationRequest val track: MetadataTrack,
) : PlaybackDestinationRequest
data class Tracks( data class Tracks(
override val title: String, override val title: String,
override val action: PlaybackDestinationAction, override val action: PlaybackDestinationAction,
val tracks: List<MetadataTrack>) : PlaybackDestinationRequest val tracks: List<MetadataTrack>,
) : PlaybackDestinationRequest
} }
/** /**
@ -87,7 +90,8 @@ class RemotePlaybackController(
private val collectionPlaybackHelper: CollectionPlaybackHelper, private val collectionPlaybackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val jamRoomService: JamRoomService) : KoinComponent { private val jamRoomService: JamRoomService,
) : KoinComponent {
private val logger = Logger.withTag("RemotePlaybackController") private val logger = Logger.withTag("RemotePlaybackController")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -109,7 +113,8 @@ class RemotePlaybackController(
id: String, id: String,
title: String, title: String,
startTrack: MetadataTrack? = null, startTrack: MetadataTrack? = null,
shuffle: Boolean = false) { shuffle: Boolean = false,
) {
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle)) request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
} }
@ -200,7 +205,8 @@ class RemotePlaybackController(
QueueEntry.StreamingTrack( QueueEntry.StreamingTrack(
track = track, track = track,
url = "", url = "",
addedBy = jamRoomService.participantClientId) addedBy = jamRoomService.participantClientId,
)
} }
) )
@ -285,7 +291,8 @@ class RemotePlaybackController(
entries = listOf(entry), entries = listOf(entry),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null,
)
} }
PlaybackDestinationAction.AddToQueue -> { PlaybackDestinationAction.AddToQueue -> {
@ -312,7 +319,8 @@ class RemotePlaybackController(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null,
)
} }
PlaybackDestinationAction.AddToQueue -> { PlaybackDestinationAction.AddToQueue -> {

View File

@ -52,7 +52,8 @@ class ScrobbleManager(
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val scrobblePluginSource: ScrobblePluginSource, private val scrobblePluginSource: ScrobblePluginSource,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent { private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) : KoinComponent {
private val logger by injectLogger<ScrobbleManager>() private val logger by injectLogger<ScrobbleManager>()
@ -68,12 +69,14 @@ class ScrobbleManager(
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.playerStateFlow, audioPlayer.playerStateFlow,
audioPlayer.positionFlow, audioPlayer.positionFlow,
audioPlayer.durationFlow) { entry, playerState, position, duration -> audioPlayer.durationFlow,
) { entry, playerState, position, duration ->
PlaybackSnapshot( PlaybackSnapshot(
entry = entry, entry = entry,
playerState = playerState, playerState = playerState,
positionMs = position.inWholeMilliseconds, positionMs = position.inWholeMilliseconds,
playerDurationMs = duration.inWholeMilliseconds) playerDurationMs = duration.inWholeMilliseconds,
)
}.collect(::onPlaybackSnapshot) }.collect(::onPlaybackSnapshot)
} }
} }
@ -134,7 +137,8 @@ class ScrobbleManager(
scrobbled = true scrobbled = true
val track = entry.toScrobbleTrack( val track = entry.toScrobbleTrack(
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(), timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
durationMs = durationMs) durationMs = durationMs,
)
scope.launch { submitScrobble(track) } scope.launch { submitScrobble(track) }
} }
@ -193,7 +197,8 @@ class ScrobbleManager(
artistName = track.artists.joinToString(", ") { it.name }, artistName = track.artists.joinToString(", ") { it.name },
albumName = track.album?.title, albumName = track.album?.title,
streamingProvider = resolveStreamingProvider(track), streamingProvider = resolveStreamingProvider(track),
durationMs = durationMs) durationMs = durationMs,
)
is QueueEntry.LocalTrack -> ScrobbleTrack( is QueueEntry.LocalTrack -> ScrobbleTrack(
timestamp = timestamp, timestamp = timestamp,
@ -204,7 +209,8 @@ class ScrobbleManager(
artistName = artists.joinToString(", "), artistName = artists.joinToString(", "),
albumName = album, albumName = album,
streamingProvider = LOCAL_PROVIDER, streamingProvider = LOCAL_PROVIDER,
durationMs = durationMs) durationMs = durationMs,
)
} }
} }
@ -239,7 +245,8 @@ class ScrobbleManager(
val entry: QueueEntry?, val entry: QueueEntry?,
val playerState: PlayerState, val playerState: PlayerState,
val positionMs: Long, val positionMs: Long,
val playerDurationMs: Long) val playerDurationMs: Long,
)
companion object { companion object {
/** Tracks shorter than this are never scrobbled (Last.fm rule). */ /** Tracks shorter than this are never scrobbled (Last.fm rule). */

View File

@ -29,7 +29,8 @@ class AlternativeTracksRepository(
private val matchedTracksRepository: TrackSourceRepository, private val matchedTracksRepository: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager) : KoinComponent { private val cacheManager: CacheManager,
) : KoinComponent {
private val logger by injectLogger<AlternativeTracksRepository>() private val logger by injectLogger<AlternativeTracksRepository>()

View File

@ -51,11 +51,13 @@ data class CacheEntry(
val filename: String, val filename: String,
val sizeBytes: Long, val sizeBytes: Long,
val createdAtMs: Long, val createdAtMs: Long,
val contentType: String = "application/octet-stream") val contentType: String = "application/octet-stream",
)
class CacheManager( class CacheManager(
private val paths: Paths, private val paths: Paths,
private val settingsRepository: SettingsRepository) { private val settingsRepository: SettingsRepository,
) {
private val fileSystem: FileSystem = FileSystem.SYSTEM private val fileSystem: FileSystem = FileSystem.SYSTEM
private val cacheMutex: Mutex = Mutex() private val cacheMutex: Mutex = Mutex()
private val json: Json = Json { ignoreUnknownKeys = true } private val json: Json = Json { ignoreUnknownKeys = true }
@ -158,13 +160,15 @@ class CacheManager(
trackId: String, trackId: String,
filename: String, filename: String,
allBytes: ByteArray, allBytes: ByteArray,
contentType: String) { contentType: String,
) {
val entry = CacheEntry( val entry = CacheEntry(
trackId = trackId, trackId = trackId,
filename = filename, filename = filename,
sizeBytes = allBytes.size.toLong(), sizeBytes = allBytes.size.toLong(),
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(), createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
contentType = contentType) contentType = contentType,
)
cacheMutex.withLock { cacheMutex.withLock {
val index = readCacheIndex() val index = readCacheIndex()
writeCacheIndex(CacheIndex( writeCacheIndex(CacheIndex(

View File

@ -60,7 +60,8 @@ class LocalServer(
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager, private val cacheManager: CacheManager,
private val remoteControlHandler: RemoteControlHandler) : KoinComponent { private val remoteControlHandler: RemoteControlHandler,
) : KoinComponent {
val logger by injectLogger<LocalServer>() val logger by injectLogger<LocalServer>()
private val httpClient = HttpClient { private val httpClient = HttpClient {
@ -98,7 +99,8 @@ class LocalServer(
audioPlayerQueue = audioPlayerQueue, audioPlayerQueue = audioPlayerQueue,
isCachingEnabled = { cachedCacheEnabled.value }, isCachingEnabled = { cachedCacheEnabled.value },
activePort = { activePort.value }, activePort = { activePort.value },
scope = scope) scope = scope,
)
} }
companion object { companion object {

View File

@ -37,7 +37,8 @@ import kotlinx.serialization.json.Json
@Serializable @Serializable
data class PluginTaggedAudioSource( data class PluginTaggedAudioSource(
val pluginId: String, val pluginId: String,
val source: AudioSource.Basic) val source: AudioSource.Basic,
)
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */ /** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
interface TrackSourceRepository { interface TrackSourceRepository {

View File

@ -62,7 +62,8 @@ internal class StreamProxy(
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val isCachingEnabled: () -> Boolean, private val isCachingEnabled: () -> Boolean,
private val activePort: () -> Int?, private val activePort: () -> Int?,
private val scope: CoroutineScope): KoinComponent { private val scope: CoroutineScope,
): KoinComponent {
val logger by injectLogger<StreamProxy>() val logger by injectLogger<StreamProxy>()
companion object { companion object {
@ -413,7 +414,8 @@ internal class StreamProxy(
trackId = trackIdForCache, trackId = trackIdForCache,
filename = cacheFilename, filename = cacheFilename,
allBytes = allBytes, allBytes = allBytes,
contentType = cacheContentType) contentType = cacheContentType,
)
scope.launch { scope.launch {
cacheManager.evictIfNeeded() cacheManager.evictIfNeeded()
} }

View File

@ -48,24 +48,28 @@ data class CachedStreamUrl(
// The id of the audio plugin that produced this entry. Guards against a stale entry // The id of the audio plugin that produced this entry. Guards against a stale entry
// (e.g. a YouTube video URL) being served after the user switches to a different // (e.g. a YouTube video URL) being served after the user switches to a different
// audio plugin, which would otherwise play back completely unrelated content. // audio plugin, which would otherwise play back completely unrelated content.
val pluginId: String) val pluginId: String,
)
private data class CachedAlternatives( private data class CachedAlternatives(
val pluginId: String, val pluginId: String,
val sources: List<AudioSource>) val sources: List<AudioSource>,
)
data class StreamInfo( data class StreamInfo(
val url: String, val url: String,
val protocol: StreamProtocol, val protocol: StreamProtocol,
val codec: String, val codec: String,
val container: String) val container: String,
)
class StreamingUrlRepository( class StreamingUrlRepository(
private val pluginManager: AudioPluginSource, private val pluginManager: AudioPluginSource,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: TrackSourceRepository, private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource, private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent { private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
) : KoinComponent {
private val logger by injectLogger<StreamingUrlRepository>() private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex() private val streamUrlCacheMutex = Mutex()
@ -129,7 +133,8 @@ class StreamingUrlRepository(
val resolvedStream = selectPreferredAudioStream( val resolvedStream = selectPreferredAudioStream(
stream.streams, stream.streams,
preferredFormat, preferredFormat,
preferredQuality) preferredQuality,
)
if (resolvedStream != null) { if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo() val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
@ -188,7 +193,8 @@ class StreamingUrlRepository(
val resolvedStream = selectPreferredAudioStream( val resolvedStream = selectPreferredAudioStream(
stream.streams, stream.streams,
preferredFormat, preferredFormat,
preferredQuality) preferredQuality,
)
if (resolvedStream != null) { if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo() val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
@ -201,13 +207,15 @@ class StreamingUrlRepository(
url = normalizeManifestUrl(url, protocol), url = normalizeManifestUrl(url, protocol),
protocol = protocol, protocol = protocol,
codec = codec, codec = codec,
container = container) container = container,
)
private suspend fun getCachedStreamUrl( private suspend fun getCachedStreamUrl(
trackId: String, trackId: String,
preferredFormat: AudioFormat, preferredFormat: AudioFormat,
preferredQuality: AudioQuality, preferredQuality: AudioQuality,
pluginId: String): StreamInfo? { pluginId: String,
): StreamInfo? {
val now = Clock.System.now().toEpochMilliseconds() val now = Clock.System.now().toEpochMilliseconds()
return streamUrlCacheMutex.withLock { return streamUrlCacheMutex.withLock {
val cached = streamUrlCache[trackId] ?: return@withLock null val cached = streamUrlCache[trackId] ?: return@withLock null
@ -233,7 +241,8 @@ class StreamingUrlRepository(
preferredQuality: AudioQuality, preferredQuality: AudioQuality,
source: AudioSource.Streamed, source: AudioSource.Streamed,
selectedStream: AudioStream, selectedStream: AudioStream,
pluginId: String) { pluginId: String,
) {
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
streamUrlCacheMutex.withLock { streamUrlCacheMutex.withLock {
streamUrlCache[trackId] = CachedStreamUrl( streamUrlCache[trackId] = CachedStreamUrl(
@ -246,7 +255,8 @@ class StreamingUrlRepository(
selectedStream = selectedStream, selectedStream = selectedStream,
preferredFormat = preferredFormat, preferredFormat = preferredFormat,
preferredQuality = preferredQuality, preferredQuality = preferredQuality,
pluginId = pluginId) pluginId = pluginId,
)
} }
} }
@ -316,7 +326,8 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
internal fun selectPreferredAudioStream( internal fun selectPreferredAudioStream(
streams: List<AudioStream>, streams: List<AudioStream>,
preferredFormat: AudioFormat, preferredFormat: AudioFormat,
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull( preferredQuality: AudioQuality,
): AudioStream? = streams.minWithOrNull(
compareBy<AudioStream> { stream -> compareBy<AudioStream> { stream ->
formatMismatchCount(stream, preferredFormat) formatMismatchCount(stream, preferredFormat)
}.thenBy { stream -> }.thenBy { stream ->

View File

@ -0,0 +1,483 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
private val AutocompleteTextFieldMinHeight = 44.dp
private val AutocompleteMenuDefaultMaxHeight = 300.dp
private val AutocompleteMenuDefaultOffset = 4.dp
private data class ResolvedAutoCompleteTextFieldState(
val background: Brush,
val highlight: Color,
val shape: Shape,
val border: BaseUITheme.Border,
val shadow: BaseUITheme.Shadow,
val foreground: Color,
)
@Composable
private fun resolveTextFieldState(
theme: BaseUITheme.TextFieldTheme,
isFocused: Boolean,
isHovered: Boolean,
): ResolvedAutoCompleteTextFieldState {
return when {
isFocused -> ResolvedAutoCompleteTextFieldState(
theme.background.focused,
theme.highlight.focused,
theme.shape.focused,
theme.border.focused,
theme.shadow.focused,
theme.foreground.focused
)
isHovered -> ResolvedAutoCompleteTextFieldState(
theme.background.hovered,
theme.highlight.hovered,
theme.shape.hovered,
theme.border.hovered,
theme.shadow.hovered,
theme.foreground.hovered
)
else -> ResolvedAutoCompleteTextFieldState(
theme.background.normal,
theme.highlight.normal,
theme.shape.normal,
theme.border.normal,
theme.shadow.normal,
theme.foreground.normal
)
}
}
@Composable
fun <T> AutocompleteTextField(
value: String,
onValueChange: (String) -> Unit,
items: List<T>,
itemContent: @Composable (item: T, isSelected: Boolean) -> Unit,
modifier: Modifier = Modifier,
onItemSelected: (T) -> Unit = {},
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
label: @Composable (() -> Unit)? = null,
singleLine: Boolean = true,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
enabled: Boolean = true,
readOnly: Boolean = false,
isError: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
textStyle: TextStyle = TextStyle.Default,
cursorBrush: Color? = null,
onKeyEvent: ((KeyEvent) -> Boolean)? = null,
menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight,
menuOffset: Dp = AutocompleteMenuDefaultOffset,
textFieldTheme: BaseUITheme.TextFieldTheme? = null,
menuTheme: BaseUITheme.AutoCompleteMenuTheme? = null,
) {
val density = LocalDensity.current
val baseTextFieldTheme = LocalBaseUITheme.current.textField
val baseMenuTheme = LocalBaseUITheme.current.autoCompleteMenuTheme
val resolvedTextFieldTheme = textFieldTheme ?: baseTextFieldTheme
val resolvedMenuTheme = menuTheme ?: baseMenuTheme
val isFocused by interactionSource.collectIsFocusedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
var isMenuOpen by remember { mutableStateOf(false) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
val menuListState = rememberLazyListState()
val textFieldFocusRequester = remember { FocusRequester() }
val menuExpanded = isMenuOpen && items.isNotEmpty()
LaunchedEffect(isFocused) {
if (isFocused) {
isMenuOpen = true
}
}
LaunchedEffect(value) {
if (selectedIndex != -1) selectedIndex = -1
}
LaunchedEffect(selectedIndex) {
if (selectedIndex >= 0 && menuExpanded) {
menuListState.scrollToItem(selectedIndex)
}
}
val wrappedKeyboardActions = remember(keyboardActions, menuExpanded, selectedIndex, items) {
KeyboardActions(
onSearch = {
if (menuExpanded && selectedIndex in items.indices) {
onItemSelected(items[selectedIndex])
selectedIndex = -1
isMenuOpen = false
} else {
keyboardActions.onSearch?.invoke(this)
}
},
onDone = keyboardActions.onDone,
onGo = keyboardActions.onGo,
onNext = keyboardActions.onNext,
onPrevious = keyboardActions.onPrevious,
onSend = keyboardActions.onSend,
)
}
val fieldState = resolveTextFieldState(resolvedTextFieldTheme, isFocused, isHovered)
val resolvedBorder = if (isError) {
BaseUITheme.Border(MaterialTheme.colorScheme.error, fieldState.border.width)
} else {
fieldState.border
}
val contentColor = when {
!enabled -> fieldState.foreground.copy(alpha = 0.38f)
else -> fieldState.foreground
}
val cursor = cursorBrush?.let { SolidColor(it) } ?: resolvedTextFieldTheme.cursor
fun navigate(delta: Int) {
if (items.isEmpty()) return
selectedIndex = ((selectedIndex + delta) % items.size + items.size) % items.size
}
Box(modifier = modifier) {
Column {
AnimatedVisibility(
visible = label != null,
enter = fadeIn(),
exit = fadeOut(),
) {
label?.invoke()
}
Box(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { textFieldSize = it }
.defaultMinSize(minHeight = AutocompleteTextFieldMinHeight)
.then(
if (fieldState.shadow.elevation > 0.dp) {
Modifier.shadow(
elevation = fieldState.shadow.elevation,
shape = fieldState.shape,
ambientColor = fieldState.shadow.ambientColor,
spotColor = fieldState.shadow.spotColor,
)
} else Modifier
)
.clip(fieldState.shape)
.background(fieldState.background, fieldState.shape)
.border(
BorderStroke(resolvedBorder.width, resolvedBorder.color),
fieldState.shape
)
.highlight(fieldState.highlight)
.padding(resolvedTextFieldTheme.padding),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (leadingIcon != null) {
leadingIcon()
}
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.hoverable(
interactionSource = interactionSource,
enabled = enabled,
)
.focusRequester(textFieldFocusRequester)
.onFocusChanged { state ->
if (!state.isFocused) {
isMenuOpen = false
selectedIndex = -1
}
}
.onPreviewKeyEvent { event ->
if (onKeyEvent?.invoke(event) == true) {
return@onPreviewKeyEvent true
}
if (!menuExpanded) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionDown -> {
navigate(1)
true
}
Key.DirectionUp -> {
if (selectedIndex >= 0) {
navigate(-1)
true
} else false
}
Key.Enter -> {
if (selectedIndex in items.indices) {
onItemSelected(items[selectedIndex])
selectedIndex = -1
isMenuOpen = false
return@onPreviewKeyEvent true
}
false
}
Key.Escape -> {
selectedIndex = -1
isMenuOpen = false
true
}
else -> false
}
},
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle.copy(color = contentColor),
cursorBrush = cursor,
keyboardOptions = keyboardOptions,
keyboardActions = wrappedKeyboardActions,
singleLine = singleLine,
maxLines = maxLines,
visualTransformation = visualTransformation,
interactionSource = interactionSource,
decorationBox = { innerTextField ->
if (value.isEmpty() && placeholder != null && !isFocused) {
CompositionLocalProvider(LocalTextStyle provides textStyle) {
Box(contentAlignment = Alignment.CenterStart) {
placeholder()
}
}
} else {
innerTextField()
}
},
)
}
if (trailingIcon != null) {
trailingIcon()
}
}
}
}
if (menuExpanded && textFieldSize.width > 0) {
val textFieldHeightPx = textFieldSize.height
val menuOffsetPx = with(density) { menuOffset.roundToPx() }
val textFieldWidthDp = with(density) { textFieldSize.width.toDp() }
Popup(
alignment = Alignment.TopStart,
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
properties = PopupProperties(focusable = false),
onDismissRequest = {
selectedIndex = -1
isMenuOpen = false
},
) {
Box(
modifier = Modifier
.width(textFieldWidthDp)
.heightIn(max = menuMaxHeight)
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
.clip(resolvedMenuTheme.shape),
) {
LazyColumn(
state = menuListState,
modifier = Modifier
.heightIn(max = menuMaxHeight),
) {
items(items.size) { index ->
Box(
modifier = Modifier
.fillMaxWidth()
.clickable {
onItemSelected(items[index])
selectedIndex = -1
isMenuOpen = false
textFieldFocusRequester.requestFocus()
},
) {
itemContent(items[index], selectedIndex == index)
}
}
}
}
}
}
}
}
@Preview
@Composable
private fun AutocompleteTextFieldPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
val artists = remember {
listOf(
"Twenty One Pilots",
"The Beatles",
"Adele",
"Drake",
"Taylor Swift",
"Arctic Monkeys",
"Billie Eilish",
)
}
var value by remember { mutableStateOf("") }
AutocompleteTextField(
value = value,
onValueChange = {
value = it
},
items = artists.filter {
it.contains(value, ignoreCase = true) && value.isNotEmpty()
},
onItemSelected = { selected ->
value = selected
},
placeholder = { Text("Search artists...") },
leadingIcon = {
androidx.compose.material3.Icon(
imageVector = Iconsax.IconsaxSearchBroken,
contentDescription = "Search",
)
},
modifier = Modifier.fillMaxWidth(),
itemContent = { item, isSelected ->
Row(
modifier = Modifier
.fillMaxWidth()
.background(
color = if (isSelected) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
} else {
Color.Transparent
},
)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
androidx.compose.material3.Icon(
imageVector = Iconsax.IconsaxSearchBroken,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = item,
style = MaterialTheme.typography.bodyMedium,
)
}
},
)
}
}
}
}

View File

@ -0,0 +1,844 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.User
private val BadgeShape = RoundedCornerShape(11.dp)
private const val DisabledContentAlpha = 0.38f
private val ButtonMinHeight = 40.dp
private val SquareButtonSize = 40.dp
private data class ResolvedButtonState(
val colors: BaseUITheme.ButtonColors,
val shape: Shape,
val shadow: BaseUITheme.Shadow,
val border: BaseUITheme.Border,
val padding: PaddingValues,
)
@Composable
private fun resolveButtonState(
style: BaseUITheme.ButtonStyle,
isPressed: Boolean,
isHovered: Boolean,
): ResolvedButtonState {
return when {
isPressed -> ResolvedButtonState(
style.colors.pressed,
style.shape.pressed,
style.shadow.pressed,
style.border.pressed,
style.padding.pressed
)
isHovered -> ResolvedButtonState(
style.colors.hovered,
style.shape.hovered,
style.shadow.hovered,
style.border.hovered,
style.padding.hovered
)
else -> ResolvedButtonState(
style.colors.normal,
style.shape.normal,
style.shadow.normal,
style.border.normal,
style.padding.normal
)
}
}
private fun Modifier.applyShadow(
shadow: BaseUITheme.Shadow,
shape: Shape,
): Modifier {
return if (shadow.elevation > 0.dp) {
this.shadow(
elevation = shadow.elevation,
shape = shape,
ambientColor = shadow.ambientColor,
spotColor = shadow.spotColor,
)
} else {
this
}
}
@Composable
fun OutlineButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
hoverOnly: Boolean = false,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable RowScope.() -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val state = resolveButtonState(style, isPressed, isHovered)
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
Box(
modifier = modifier
.defaultMinSize(minHeight = ButtonMinHeight)
.hoverable(interactionSource = interactionSource, enabled = enabled)
.graphicsLayer { translationY = lift.toPx() }
.then(
if (hoverOnly && !isHovered) Modifier else Modifier.applyShadow(
state.shadow,
state.shape
)
)
.clip(state.shape)
.then(
if (hoverOnly && !isHovered) Modifier else Modifier.background(
state.colors.background,
state.shape
)
.border(BorderStroke(state.border.width, state.border.color), state.shape)
)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick,
)
.then(if (hoverOnly && !isHovered) Modifier else Modifier.highlight(state.colors.highlight))
.padding(state.padding),
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = {
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
content()
}
},
)
}
}
@Composable
fun PrimaryButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable RowScope.() -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.primary
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val state = resolveButtonState(style, isPressed, isHovered)
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
Box(
modifier = modifier
.defaultMinSize(minHeight = ButtonMinHeight)
.hoverable(interactionSource = interactionSource, enabled = enabled)
.graphicsLayer { translationY = lift.toPx() }
.applyShadow(state.shadow, state.shape)
.clip(state.shape)
.background(state.colors.background, state.shape)
.border(BorderStroke(state.border.width, state.border.color), state.shape)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick,
)
.highlight(state.colors.highlight)
.padding(state.padding),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}
}
}
@Composable
fun SecondaryButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable RowScope.() -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.secondary
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val state = resolveButtonState(style, isPressed, isHovered)
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
Box(
modifier = modifier
.defaultMinSize(minHeight = ButtonMinHeight)
.hoverable(interactionSource = interactionSource, enabled = enabled)
.graphicsLayer { translationY = lift.toPx() }
.applyShadow(state.shadow, state.shape)
.clip(state.shape)
.background(state.colors.background, state.shape)
.border(BorderStroke(state.border.width, state.border.color), state.shape)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick,
)
.highlight(state.colors.highlight)
.padding(state.padding),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}
}
}
@Composable
fun IconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable () -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.iconButtons.outline
OutlineButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
theme = style,
) {
content()
}
}
@Composable
fun GhostButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable RowScope.() -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.ghost
OutlineButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
hoverOnly = true,
theme = style,
content = content,
)
}
@Composable
fun GhostIconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable () -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.iconButtons.ghost
OutlineButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
hoverOnly = true,
theme = style,
) {
content()
}
}
@Composable
fun PrimaryIconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable () -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.iconButtons.primary
PrimaryButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
theme = style,
) {
content()
}
}
@Composable
fun SecondaryIconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable () -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.iconButtons.secondary
SecondaryButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
theme = style,
) {
content()
}
}
@Composable
fun ButtonBadge(
count: Int,
modifier: Modifier = Modifier,
theme: BaseUITheme.ButtonStyle? = null,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
val state = resolveButtonState(style, isPressed = false, isHovered = false)
Box(
modifier = modifier
.heightIn(min = 22.dp)
.defaultMinSize(minWidth = 22.dp)
.background(state.colors.background, BadgeShape)
.border(1.dp, state.border.color, BadgeShape)
.padding(horizontal = 7.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = count.toString(),
fontSize = 12.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
maxLines = 1,
softWrap = false,
color = state.colors.foreground.copy(alpha = 0.7f),
)
}
}
@Composable
fun GroupButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable RowScope.() -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val state = resolveButtonState(style, isPressed, isHovered)
val overlay = when {
isPressed -> state.colors.background
isHovered -> state.colors.background
else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
}
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
Box(
modifier = modifier
.defaultMinSize(minHeight = ButtonMinHeight)
.graphicsLayer { translationY = lift.toPx() }
.background(overlay)
.hoverable(interactionSource = interactionSource, enabled = enabled)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick,
)
.padding(state.padding),
contentAlignment = Alignment.Center,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}
}
@Composable
fun GroupIconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable () -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val state = resolveButtonState(style, isPressed, isHovered)
val overlay = when {
isPressed -> state.colors.background
isHovered -> state.colors.background
else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
}
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
Box(
modifier = modifier
.size(SquareButtonSize)
.graphicsLayer { translationY = lift.toPx() }
.background(overlay)
.hoverable(interactionSource = interactionSource, enabled = enabled)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxSize()
.alpha(if (enabled) 1f else DisabledContentAlpha),
contentAlignment = Alignment.Center,
) {
content()
}
}
}
@Composable
fun ButtonGroup(
modifier: Modifier = Modifier,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable () -> Unit,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
val state = resolveButtonState(style, isPressed = false, isHovered = false)
val groupShape = state.shape
Surface(
modifier = modifier
.defaultMinSize(minHeight = ButtonMinHeight)
.applyShadow(state.shadow, groupShape),
shape = groupShape,
color = Color.Transparent,
border = BorderStroke(state.border.width, state.border.color),
) {
Box(
modifier = Modifier.background(state.colors.background, groupShape),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
content()
}
}
}
}
@Composable
fun ButtonGroupDivider(
theme: BaseUITheme.ButtonStyle? = null,
) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
val state = resolveButtonState(style, isPressed = false, isHovered = false)
Box(
modifier = Modifier
.width(1.dp)
.heightIn(min = 20.dp)
.background(state.border.color),
)
}
@Preview(showBackground = true)
@Composable
private fun ButtonsRow1Preview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlineButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxShare,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text(
"Copy link",
maxLines = 1,
softWrap = false,
)
}
OutlineButton(onClick = {}) {
Icon(
imageVector = Iconsax.User,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text("Login", maxLines = 1, softWrap = false)
}
PrimaryButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxAddSquare,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text(
"Sign Up",
maxLines = 1,
softWrap = false,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
)
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ButtonsRow2Preview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ButtonGroup {
GroupButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxDocumentText,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text("Documents", maxLines = 1, softWrap = false)
}
ButtonGroupDivider()
GroupButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxShare,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text("Export", maxLines = 1, softWrap = false)
}
ButtonGroupDivider()
GroupIconButton(onClick = {}) {
Icon(
imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
ButtonGroup {
GroupIconButton(onClick = {}) {
Icon(
imageVector = Iconsax.ArrowLeft3,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
ButtonGroupDivider()
GroupIconButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxNext,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ButtonsRow3Preview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlineButton(onClick = {}) {
Text("Cancel", maxLines = 1, softWrap = false)
}
PrimaryButton(onClick = {}) {
Text(
"Done",
maxLines = 1,
softWrap = false,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
)
}
IconButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxMagic,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
OutlineButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxHeart,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text("Like", maxLines = 1, softWrap = false)
ButtonBadge(count = 2)
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ButtonsRow4Preview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ButtonGroup {
GroupIconButton(onClick = {}) {
Icon(
imageVector = Iconsax.ArrowLeft3,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
ButtonGroupDivider()
GroupIconButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxNext,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
OutlineButton(onClick = {}) {
Text("Forward", maxLines = 1, softWrap = false)
Icon(
imageVector = Iconsax.IconsaxArrowSquareUp,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ButtonStylesPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlineButton(onClick = {}) {
Text("Outline", maxLines = 1, softWrap = false)
}
SecondaryButton(onClick = {}) {
Text("Secondary", maxLines = 1, softWrap = false)
}
PrimaryButton(onClick = {}) {
Text(
"Primary",
maxLines = 1,
softWrap = false,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
)
}
}
}
}
}
}
enum class VariableIconButtonVariant {
Outline,
Ghost,
Primary,
Secondary,
}
@Composable
fun VariableIconButton(
variant: VariableIconButtonVariant = VariableIconButtonVariant.Outline,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ButtonStyle? = null,
content: @Composable () -> Unit,
) {
when (variant) {
VariableIconButtonVariant.Outline -> IconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
theme = theme,
content = content,
)
VariableIconButtonVariant.Ghost -> GhostIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
theme = theme,
content = content,
)
VariableIconButtonVariant.Primary -> PrimaryIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
theme = theme,
content = content,
)
VariableIconButtonVariant.Secondary -> SecondaryIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
theme = theme,
content = content,
)
}
}

View File

@ -0,0 +1,81 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun Card(
modifier: Modifier = Modifier,
theme: BaseUITheme.CardTheme? = null,
content: @Composable () -> Unit,
) {
val cardTheme = theme ?: LocalBaseUITheme.current.card
Box(
modifier = modifier
.then(
if (cardTheme.shadow.elevation > 0.dp) {
Modifier.shadow(
elevation = cardTheme.shadow.elevation,
shape = cardTheme.shape,
ambientColor = cardTheme.shadow.ambientColor,
spotColor = cardTheme.shadow.spotColor,
)
} else Modifier
)
.clip(cardTheme.shape)
.background(cardTheme.background, cardTheme.shape)
.border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape)
.padding(cardTheme.padding),
) {
content()
}
}
@Composable
@androidx.compose.ui.tooling.preview.Preview
private fun CardPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Card {
Box(modifier = Modifier.padding(16.dp))
}
}
}
}
}

View File

@ -0,0 +1,253 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
enum class CheckBoxState {
SELECTED,
UNSELECTED,
INDETERMINATE,
@Deprecated("Use INDETERMINATE", ReplaceWith("CheckBoxState.INDETERMINATE"))
CLEAR;
}
private val CheckBoxSize = 22.dp
private data class ResolvedCheckBoxState(
val colors: BaseUITheme.ButtonColors,
val shape: Shape,
val shadow: BaseUITheme.Shadow,
val border: BaseUITheme.Border,
)
@Composable
private fun resolveCheckBoxState(
style: BaseUITheme.ButtonStyle,
isPressed: Boolean,
isHovered: Boolean,
): ResolvedCheckBoxState {
return when {
isPressed -> ResolvedCheckBoxState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed)
isHovered -> ResolvedCheckBoxState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered)
else -> ResolvedCheckBoxState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal)
}
}
@Composable
fun CheckBox(
state: CheckBoxState = CheckBoxState.UNSELECTED,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
theme: BaseUITheme.CheckBoxTheme? = null,
) {
val baseTheme = LocalBaseUITheme.current.checkBox
val checkBoxTheme = theme ?: baseTheme
val source = interactionSource ?: remember { MutableInteractionSource() }
val isPressed by source.collectIsPressedAsState()
val isHovered by source.collectIsHoveredAsState()
val isSelected = state == CheckBoxState.SELECTED
val isIndeterminate = state == CheckBoxState.INDETERMINATE
val isFilled = isSelected || isIndeterminate
val style = if (isFilled) checkBoxTheme.selected else checkBoxTheme.unselected
val resolved = resolveCheckBoxState(style, isPressed, isHovered)
val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp
val borderColor = when {
!enabled -> resolved.colors.foreground.copy(alpha = 0.38f)
else -> resolved.border.color
}
val checkProgress by animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = tween(durationMillis = 180),
label = "checkProgress",
)
val dashProgress by animateFloatAsState(
targetValue = if (isIndeterminate) 1f else 0f,
animationSpec = tween(durationMillis = 150),
label = "dashProgress",
)
Box(
modifier = modifier
.size(CheckBoxSize)
.graphicsLayer { translationY = lift.toPx() }
.then(
if (resolved.shadow.elevation > 0.dp) {
Modifier.shadow(
elevation = resolved.shadow.elevation,
shape = resolved.shape,
ambientColor = resolved.shadow.ambientColor,
spotColor = resolved.shadow.spotColor,
)
} else Modifier
)
.clip(resolved.shape)
.background(resolved.colors.background, resolved.shape)
.border(BorderStroke(0.5.dp, borderColor), resolved.shape)
.highlight(resolved.colors.highlight)
.then(
if (onClick != null) {
Modifier
.hoverable(interactionSource = source, enabled = enabled)
.clickable(
interactionSource = source,
indication = ripple(),
enabled = enabled,
onClick = onClick,
)
} else {
Modifier
}
),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Box(
modifier = Modifier
.size(14.dp)
.graphicsLayer { alpha = checkProgress },
) {
androidx.compose.foundation.Canvas(modifier = Modifier.size(14.dp)) {
val stroke = 2.dp.toPx()
val path = Path().apply {
val w = size.width
val h = size.height
moveTo(w * 0.2f, h * 0.52f)
lineTo(w * 0.42f, h * 0.74f)
lineTo(w * 0.82f, h * 0.28f)
}
drawPath(
path = path,
color = checkBoxTheme.checkmarkColor,
style = Stroke(
width = stroke,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
),
)
}
}
} else if (isIndeterminate) {
Box(
modifier = Modifier
.size(width = 10.dp, height = 2.dp)
.graphicsLayer { alpha = dashProgress }
.clip(RoundedCornerShape(1.dp))
.background(
brush = Brush.horizontalGradient(
colors = listOf(
checkBoxTheme.checkmarkColor,
checkBoxTheme.checkmarkColor.copy(alpha = 0.92f),
)
),
),
)
}
}
}
@Preview
@Composable
fun CheckBoxPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CheckBox(state = CheckBoxState.SELECTED, onClick = {})
Text("Selected", style = MaterialTheme.typography.bodyMedium)
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CheckBox(state = CheckBoxState.UNSELECTED, onClick = {})
Text("Unselected", style = MaterialTheme.typography.bodyMedium)
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CheckBox(state = CheckBoxState.INDETERMINATE, onClick = {})
Text("Indeterminate", style = MaterialTheme.typography.bodyMedium)
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CheckBox(state = CheckBoxState.SELECTED, onClick = {}, enabled = false)
Text("Disabled", style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
}

View File

@ -0,0 +1,171 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val ChipTabMinHeight = 36.dp
private data class ResolvedChipState(
val colors: BaseUITheme.ButtonColors,
val shape: Shape,
val shadow: BaseUITheme.Shadow,
val border: BaseUITheme.Border,
val padding: androidx.compose.foundation.layout.PaddingValues,
)
@Composable
private fun resolveChipState(
style: BaseUITheme.ButtonStyle,
isPressed: Boolean,
isHovered: Boolean,
): ResolvedChipState {
return when {
isPressed -> ResolvedChipState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed, style.padding.pressed)
isHovered -> ResolvedChipState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered, style.padding.hovered)
else -> ResolvedChipState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal, style.padding.normal)
}
}
private fun Modifier.applyChipShadow(
shadow: BaseUITheme.Shadow,
shape: Shape,
): Modifier {
return if (shadow.elevation > 0.dp) {
this.shadow(
elevation = shadow.elevation,
shape = shape,
ambientColor = shadow.ambientColor,
spotColor = shadow.spotColor,
)
} else {
this
}
}
@Composable
fun ChipTab(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.ChipTabTheme? = null,
content: @Composable RowScope.() -> Unit,
) {
val baseTheme = LocalBaseUITheme.current.chipTab
val chipTheme = theme ?: baseTheme
val style = if (selected) chipTheme.selected else chipTheme.unselected
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val state = resolveChipState(style, isPressed, isHovered)
Box(
modifier = modifier
.defaultMinSize(minHeight = ChipTabMinHeight)
.hoverable(interactionSource = interactionSource, enabled = enabled)
.applyChipShadow(state.shadow, state.shape)
.clip(state.shape)
.background(state.colors.background, state.shape)
.border(BorderStroke(state.border.width, state.border.color), state.shape)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick,
)
.highlight(state.colors.highlight)
.padding(state.padding),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(
LocalContentColor provides state.colors.foreground,
LocalTextStyle provides LocalTextStyle.current.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
content = content,
)
}
}
}
@Composable
fun ChipTab(
text: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
leadingIcon: @Composable (() -> Unit)? = null,
theme: BaseUITheme.ChipTabTheme? = null,
) {
ChipTab(
selected = selected,
onClick = onClick,
modifier = modifier,
enabled = enabled,
theme = theme,
) {
if (leadingIcon != null) {
leadingIcon()
}
Text(
text = text,
maxLines = 1,
softWrap = false,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
)
}
}

View File

@ -0,0 +1,186 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
private val DefaultDialogMaxWidth: Dp = 440.dp
@Composable
fun ThemedDialog(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
title: @Composable (() -> Unit)? = null,
actions: @Composable (() -> Unit)? = null,
theme: BaseUITheme.DialogTheme? = null,
properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false),
content: @Composable () -> Unit,
) {
val dialogTheme = theme ?: LocalBaseUITheme.current.dialog
Dialog(
onDismissRequest = onDismissRequest,
properties = properties,
) {
AnimatedVisibility(
visible = true,
enter = scaleIn(
initialScale = 0.92f,
animationSpec = tween(280),
) + fadeIn(animationSpec = tween(200)),
exit = scaleOut(
targetScale = 0.92f,
animationSpec = tween(180),
) + fadeOut(animationSpec = tween(150)),
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.widthIn(max = DefaultDialogMaxWidth)
.shadow(
elevation = dialogTheme.shadow.elevation,
shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) },
ambientColor = dialogTheme.shadow.ambientColor,
spotColor = dialogTheme.shadow.spotColor,
)
.clip(dialogTheme.shape)
.background(dialogTheme.background, dialogTheme.shape)
.border(
BorderStroke(dialogTheme.border.width, dialogTheme.border.color),
dialogTheme.shape
)
.highlight(Color.White.copy(alpha = 0.08f)),
) {
Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) {
if (title != null) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, top = 24.dp),
) {
title()
}
Spacer(Modifier.height(8.dp))
}
Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
.padding(
start = 24.dp,
end = 24.dp,
top = if (title == null) 24.dp else 0.dp,
),
) {
content()
}
if (actions != null) {
Spacer(Modifier.height(16.dp))
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
actions()
}
}
} else {
Spacer(Modifier.height(24.dp))
}
}
}
}
}
}
}
@Preview
@Composable
private fun ThemedDialogPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
var show by remember { mutableStateOf(true) }
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background,
) {
PrimaryButton(onClick = { show = true }) {
Text("Open Dialog")
}
if (show) {
ThemedDialog(onDismissRequest = { show = false }) {
Text(
"This is a themed dialog with glass-like gradient, shadow, and scale animation.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
}

View File

@ -0,0 +1,172 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
@Composable
fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 4.dp),
theme: BaseUITheme.DropdownMenuTheme? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu
androidx.compose.material3.DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
offset = offset,
modifier = modifier
.widthIn(min = 180.dp)
.shadow(
elevation = menuTheme.shadow.elevation,
shape = menuTheme.shape,
ambientColor = menuTheme.shadow.ambientColor,
spotColor = menuTheme.shadow.spotColor,
)
.clip(menuTheme.shape)
.background(menuTheme.background, menuTheme.shape)
.border(
BorderStroke(menuTheme.border.width, menuTheme.border.color),
menuTheme.shape,
),
) {
content()
}
}
@Composable
fun DropdownMenuItem(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
leadingIcon: ImageVector? = null,
selected: Boolean = false,
theme: BaseUITheme.DropdownMenuTheme? = null,
) {
val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
Row(
modifier = modifier
.fillMaxWidth()
.clip(menuTheme.shape)
.hoverable(interactionSource)
.then(
if (isHovered && enabled) {
Modifier.background(menuTheme.itemHoverBackground)
} else {
Modifier
}
)
.then(
if (isHovered && enabled) {
Modifier.highlight(menuTheme.itemHighlight)
} else {
Modifier
}
)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = null,
onClick = onClick,
)
.padding(menuTheme.itemPadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (leadingIcon != null) {
Icon(
imageVector = leadingIcon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (enabled) {
menuTheme.itemForeground
} else {
menuTheme.itemForeground.copy(alpha = 0.38f)
},
)
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled) {
menuTheme.itemForeground
} else {
menuTheme.itemForeground.copy(alpha = 0.38f)
},
modifier = Modifier.weight(1f),
maxLines = 1,
softWrap = false,
)
}
}
@Composable
fun DropdownMenuDivider(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(0.5.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
)
}
}

View File

@ -0,0 +1,132 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.dp
@Composable
fun ListRowTile(
selected: Boolean = false,
enabled: Boolean = true,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
leading: @Composable (() -> Unit)? = null,
title: @Composable (() -> Unit)? = null,
subtitle: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
) {
val rowTheme = LocalBaseUITheme.current.listRowTile
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val isFocused by interactionSource.collectIsFocusedAsState()
val background = when {
isPressed -> rowTheme.background.pressed
isHovered -> rowTheme.background.hovered
isFocused -> rowTheme.background.focused
selected -> rowTheme.background.selected
else -> rowTheme.background.normal
}
val shape = when {
isPressed -> rowTheme.shape.pressed
isHovered -> rowTheme.shape.hovered
isFocused -> rowTheme.shape.focused
selected -> rowTheme.shape.selected
else -> rowTheme.shape.normal
}
val borderDef = when {
isPressed -> rowTheme.border.pressed
isHovered -> rowTheme.border.hovered
isFocused -> rowTheme.border.focused
selected -> rowTheme.border.selected
else -> rowTheme.border.normal
}
val shadowDef = when {
isPressed -> rowTheme.shadow.pressed
isHovered -> rowTheme.shadow.hovered
isFocused -> rowTheme.shadow.focused
selected -> rowTheme.shadow.selected
else -> rowTheme.shadow.normal
}
val mod = modifier
.fillMaxWidth()
.shadow(shadowDef.elevation, shape, shadowDef.clip, shadowDef.ambientColor, shadowDef.spotColor)
.clip(shape)
.border(borderDef.width, borderDef.color, shape)
.background(background, shape)
.then(
if (onClick != null) {
Modifier.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = null,
onClick = onClick,
)
} else Modifier
)
.padding(rowTheme.padding)
Row(
modifier = mod,
verticalAlignment = Alignment.CenterVertically,
) {
if (leading != null) {
leading()
Spacer(Modifier.width(12.dp))
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
) {
if (title != null) {
title()
}
if (subtitle != null) {
Spacer(Modifier.width(2.dp))
subtitle()
}
}
if (trailing != null) {
Spacer(Modifier.width(12.dp))
trailing()
}
}
}

View File

@ -0,0 +1,177 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
private val RadioSize = 22.dp
private val RadioDotSize = 10.dp
private data class ResolvedRadioState(
val colors: BaseUITheme.ButtonColors,
val shape: Shape,
val shadow: BaseUITheme.Shadow,
val border: BaseUITheme.Border,
)
@Composable
private fun resolveRadioState(
style: BaseUITheme.ButtonStyle,
isPressed: Boolean,
isHovered: Boolean,
): ResolvedRadioState {
return when {
isPressed -> ResolvedRadioState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed)
isHovered -> ResolvedRadioState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered)
else -> ResolvedRadioState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal)
}
}
@Composable
fun Radio(
selected: Boolean,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
theme: BaseUITheme.CheckBoxTheme? = null,
) {
val baseTheme = LocalBaseUITheme.current.checkBox
val radioTheme = theme ?: baseTheme
val source = remember { MutableInteractionSource() }
val isPressed by source.collectIsPressedAsState()
val isHovered by source.collectIsHoveredAsState()
val style = if (selected) radioTheme.selected else radioTheme.unselected
val resolved = resolveRadioState(style, isPressed, isHovered)
val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp
val dotProgress by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = tween(durationMillis = 180),
label = "radioDot",
)
Box(
modifier = modifier
.size(RadioSize)
.graphicsLayer {
translationY = lift.toPx()
shape = CircleShape
clip = true
}
.then(
if (resolved.shadow.elevation > 0.dp) {
Modifier.shadow(
elevation = resolved.shadow.elevation,
shape = CircleShape,
ambientColor = resolved.shadow.ambientColor,
spotColor = resolved.shadow.spotColor,
)
} else Modifier
)
.clip(CircleShape)
.background(resolved.colors.background, CircleShape)
.border(BorderStroke(0.5.dp, resolved.border.color), CircleShape)
.highlight(resolved.colors.highlight)
.then(
if (onClick != null) {
Modifier
.hoverable(interactionSource = source, enabled = enabled)
.clickable(
interactionSource = source,
indication = ripple(bounded = true, radius = RadioSize / 2),
enabled = enabled,
onClick = onClick,
)
} else {
Modifier
}
),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(RadioDotSize * dotProgress)
.clip(CircleShape)
.background(radioTheme.checkmarkColor, CircleShape),
)
}
}
@Preview
@Composable
private fun RadioPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Radio(selected = true, onClick = {})
Text("Selected", style = MaterialTheme.typography.bodyMedium)
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Radio(selected = false, onClick = {})
Text("Unselected", style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
}

View File

@ -0,0 +1,313 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
private val SliderTrackHeight = 7.dp
private val SliderThumbSize = 24.dp
private val SliderMinHeight = 32.dp
@Composable
fun Slider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
steps: Int = 0,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onValueChangeFinished: () -> Unit = {},
theme: BaseUITheme.SliderTheme? = null,
) {
val baseTheme = LocalBaseUITheme.current.slider
val sliderTheme = theme ?: baseTheme
val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val density = LocalDensity.current
var sliderWidth by remember { mutableIntStateOf(0) }
val range = valueRange.endInclusive - valueRange.start
val fraction = if (range > 0f) ((value - valueRange.start) / range).coerceIn(0f, 1f) else 0f
val stateColor = when {
isPressed -> sliderTheme.trackActiveColor.pressed
isHovered -> sliderTheme.trackActiveColor.hovered
else -> sliderTheme.trackActiveColor.normal
}
val inactiveColor = when {
isPressed -> sliderTheme.trackInactiveColor.pressed
isHovered -> sliderTheme.trackInactiveColor.hovered
else -> sliderTheme.trackInactiveColor.normal
}
val thumbColor = when {
isPressed -> sliderTheme.thumbColor.pressed
isHovered -> sliderTheme.thumbColor.hovered
else -> sliderTheme.thumbColor.normal
}
val thumbShadowState = when {
isPressed -> sliderTheme.thumbShadow.pressed
isHovered -> sliderTheme.thumbShadow.hovered
else -> sliderTheme.thumbShadow.normal
}
val trackActiveColor = if (enabled) stateColor else stateColor.copy(alpha = 0.38f)
val trackInactiveColor = if (enabled) inactiveColor else inactiveColor.copy(alpha = 0.38f)
val resolvedThumbColor = if (enabled) thumbColor else thumbColor.copy(alpha = 0.38f)
val thumbScale by animateFloatAsState(
targetValue = when {
isPressed -> 1.15f
isHovered -> 1.08f
else -> 1f
},
animationSpec = tween(durationMillis = 150),
label = "thumbScale",
)
Box(
modifier = modifier
.fillMaxWidth()
.heightIn(min = SliderMinHeight)
.onSizeChanged { sliderWidth = it.width }
.hoverable(interactionSource = interactionSource, enabled = enabled)
.pointerInput(enabled, valueRange, steps) {
if (!enabled) return@pointerInput
val stepSize = if (steps > 0) range / (steps + 1) else 0f
val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f }
fun valueFromX(x: Float): Float {
val width = size.width.toFloat()
val trackWidth = width - 2 * thumbHalfPx
if (trackWidth <= 0f) return valueRange.start
val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start
val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive)
return if (steps > 0) {
val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt()
(valueRange.start + stepIndex * stepSize).coerceIn(
valueRange.start,
valueRange.endInclusive,
)
} else {
coerced
}
}
detectTapGestures { offset ->
onValueChange(valueFromX(offset.x))
onValueChangeFinished()
}
}
.pointerInput(enabled, valueRange, steps) {
if (!enabled) return@pointerInput
val stepSize = if (steps > 0) range / (steps + 1) else 0f
val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f }
fun valueFromX(x: Float): Float {
val width = size.width.toFloat()
val trackWidth = width - 2 * thumbHalfPx
if (trackWidth <= 0f) return valueRange.start
val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start
val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive)
return if (steps > 0) {
val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt()
(valueRange.start + stepIndex * stepSize).coerceIn(
valueRange.start,
valueRange.endInclusive,
)
} else {
coerced
}
}
detectHorizontalDragGestures(
onDragEnd = { onValueChangeFinished() },
) { change, _ ->
change.consume()
onValueChange(valueFromX(change.position.x))
}
},
contentAlignment = Alignment.CenterStart,
) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(SliderTrackHeight)
.align(Alignment.CenterStart),
) {
val thumbHalfPx = SliderThumbSize.toPx() / 2f
val trackY = size.height / 2f
val trackStart = thumbHalfPx
val trackEnd = size.width - thumbHalfPx
val trackWidth = trackEnd - trackStart
if (trackWidth > 0f) {
val trackRadius = CornerRadius(size.height / 2f)
drawRoundRect(
brush = Brush.verticalGradient(
colors = listOf(
trackInactiveColor.copy(alpha = 0.45f),
trackInactiveColor,
trackInactiveColor.copy(alpha = 0.7f),
),
),
topLeft = Offset(trackStart, 0f),
size = Size(trackWidth, size.height),
cornerRadius = trackRadius,
)
drawLine(
color = Color.White.copy(alpha = 0.15f),
start = Offset(trackStart + size.height / 2f, 1.dp.toPx()),
end = Offset(trackEnd - size.height / 2f, 1.dp.toPx()),
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
)
val activeEnd = trackStart + trackWidth * fraction
drawRoundRect(
brush = Brush.verticalGradient(
colors = listOf(
trackActiveColor.copy(alpha = 0.75f),
trackActiveColor,
trackActiveColor.copy(alpha = 0.9f),
),
),
topLeft = Offset(trackStart, 0f),
size = Size((activeEnd - trackStart).coerceAtLeast(0f), size.height),
cornerRadius = trackRadius,
)
drawLine(
color = Color.White.copy(alpha = 0.22f),
start = Offset(trackStart + size.height / 2f, 1.dp.toPx()),
end = Offset(activeEnd - size.height / 2f, 1.dp.toPx()),
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
)
if (steps > 0 && enabled) {
val stepSpacing = trackWidth / (steps + 1)
for (i in 1 downTo steps) {
val x = trackStart + stepSpacing * i
drawCircle(
color = if (x <= activeEnd) {
sliderTheme.stepActiveColor
} else {
sliderTheme.stepInactiveColor
},
radius = 2.dp.toPx(),
center = Offset(x, trackY),
)
}
}
}
}
Box(
modifier = Modifier
.size(SliderThumbSize)
.offset {
val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f }
val trackWidth = sliderWidth.toFloat() - 2 * thumbHalfPx
val x = thumbHalfPx + trackWidth * fraction - thumbHalfPx
IntOffset(x.roundToInt(), 0)
}
.graphicsLayer {
scaleX = thumbScale
scaleY = thumbScale
}
.shadow(
elevation = thumbShadowState.elevation,
shape = CircleShape,
ambientColor = thumbShadowState.ambientColor,
spotColor = thumbShadowState.spotColor,
)
.background(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = 0.6f),
resolvedThumbColor.copy(alpha = 0.95f),
resolvedThumbColor,
),
center = Offset(0f, -0.55f),
radius = 1.1f,
),
shape = CircleShape,
)
.border(
width = 0.8.dp,
brush = Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.55f),
Color.White.copy(alpha = 0.08f),
),
),
shape = CircleShape,
),
)
}
}

View File

@ -0,0 +1,243 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
private val TextFieldMinHeight = 44.dp
private data class ResolvedTextFieldState(
val background: Brush,
val highlight: Color,
val shape: Shape,
val border: BaseUITheme.Border,
val shadow: BaseUITheme.Shadow,
val foreground: Color,
)
@Composable
private fun resolveTextFieldState(
theme: BaseUITheme.TextFieldTheme,
isFocused: Boolean,
isHovered: Boolean,
): ResolvedTextFieldState {
return when {
isFocused -> ResolvedTextFieldState(theme.background.focused, theme.highlight.focused, theme.shape.focused, theme.border.focused, theme.shadow.focused, theme.foreground.focused)
isHovered -> ResolvedTextFieldState(theme.background.hovered, theme.highlight.hovered, theme.shape.hovered, theme.border.hovered, theme.shadow.hovered, theme.foreground.hovered)
else -> ResolvedTextFieldState(theme.background.normal, theme.highlight.normal, theme.shape.normal, theme.border.normal, theme.shadow.normal, theme.foreground.normal)
}
}
@Composable
fun TextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
label: @Composable (() -> Unit)? = null,
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
enabled: Boolean = true,
readOnly: Boolean = false,
isError: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
textStyle: TextStyle = TextStyle.Default,
cursorBrush: Color? = null,
theme: BaseUITheme.TextFieldTheme? = null,
) {
val baseTheme = LocalBaseUITheme.current.textField
val textFieldTheme = theme ?: baseTheme
val isFocused by interactionSource.collectIsFocusedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val state = resolveTextFieldState(textFieldTheme, isFocused, isHovered)
val resolvedBorder = if (isError) {
BaseUITheme.Border(MaterialTheme.colorScheme.error, state.border.width)
} else {
state.border
}
val contentColor = when {
!enabled -> state.foreground.copy(alpha = 0.38f)
else -> state.foreground
}
val cursor = cursorBrush?.let { SolidColor(it) } ?: textFieldTheme.cursor
Column(modifier = modifier) {
AnimatedVisibility(
visible = label != null,
enter = fadeIn(),
exit = fadeOut(),
) {
label?.invoke()
}
Box(
modifier = Modifier
.defaultMinSize(minHeight = TextFieldMinHeight)
.then(
if (state.shadow.elevation > 0.dp) {
Modifier.shadow(
elevation = state.shadow.elevation,
shape = state.shape,
ambientColor = state.shadow.ambientColor,
spotColor = state.shadow.spotColor,
)
} else Modifier
)
.clip(state.shape)
.background(state.background, state.shape)
.border(BorderStroke(resolvedBorder.width, resolvedBorder.color), state.shape)
.highlight(state.highlight)
.padding(textFieldTheme.padding),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (leadingIcon != null) {
leadingIcon()
}
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.hoverable(interactionSource = interactionSource, enabled = enabled),
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle.copy(color = contentColor),
cursorBrush = cursor,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
maxLines = maxLines,
visualTransformation = visualTransformation,
interactionSource = interactionSource,
decorationBox = { innerTextField ->
if (value.isEmpty() && placeholder != null && !isFocused) {
CompositionLocalProvider(LocalTextStyle provides textStyle) {
Box(contentAlignment = Alignment.CenterStart) {
placeholder()
}
}
} else {
innerTextField()
}
},
)
}
if (trailingIcon != null) {
trailingIcon()
}
}
}
}
}
@Preview
@Composable
fun TextFieldPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
TextField(
modifier = Modifier.padding(top = 4.dp),
value = "Twenty One Pilots",
onValueChange = {},
placeholder = { Text("Placeholder") },
label = {
Text(
"Search Field",
modifier = Modifier.padding(bottom = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
},
leadingIcon = {
androidx.compose.material3.Icon(
imageVector = Iconsax.IconsaxSearchBroken,
contentDescription = "Search Icon",
)
},
trailingIcon = {
androidx.compose.material3.Icon(
imageVector = Iconsax.IconsaxTrash,
contentDescription = "Clear Icon",
)
},
)
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,264 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import kotlin.math.roundToInt
private val ToggleWidth = 44.dp
private val ToggleHeight = 24.dp
private val ThumbSize = 20.dp
private data class ResolvedToggleState(
val colors: BaseUITheme.ButtonColors,
val shape: Shape,
val shadow: BaseUITheme.Shadow,
val border: BaseUITheme.Border,
)
@Composable
private fun resolveToggleState(
style: BaseUITheme.ButtonStyle,
isPressed: Boolean,
isHovered: Boolean,
): ResolvedToggleState {
return when {
isPressed -> ResolvedToggleState(
style.colors.pressed,
style.shape.pressed,
style.shadow.pressed,
style.border.pressed,
)
isHovered -> ResolvedToggleState(
style.colors.hovered,
style.shape.hovered,
style.shadow.hovered,
style.border.hovered,
)
else -> ResolvedToggleState(
style.colors.normal,
style.shape.normal,
style.shadow.normal,
style.border.normal,
)
}
}
@Composable
fun Toggle(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
theme: BaseUITheme.ToggleTheme? = null,
) {
val baseTheme = LocalBaseUITheme.current.toggle
val toggleTheme = theme ?: baseTheme
val source = interactionSource ?: remember { MutableInteractionSource() }
val isPressed by source.collectIsPressedAsState()
val isHovered by source.collectIsHoveredAsState()
val style = if (checked) toggleTheme.checked else toggleTheme.unchecked
val resolved = resolveToggleState(style, isPressed, isHovered)
val thumbColor = when {
isPressed -> toggleTheme.thumb.pressed
isHovered -> toggleTheme.thumb.hovered
else -> toggleTheme.thumb.normal
}
val thumbShadowState = when {
isPressed -> toggleTheme.thumbShadow.pressed
isHovered -> toggleTheme.thumbShadow.hovered
else -> toggleTheme.thumbShadow.normal
}
val borderColor = when {
!enabled -> resolved.colors.foreground.copy(alpha = 0.38f)
else -> resolved.border.color
}
val thumbProgress by animateFloatAsState(
targetValue = if (checked) 1f else 0f,
animationSpec = tween(durationMillis = 200),
label = "thumbProgress",
)
val thumbScale by animateFloatAsState(
targetValue = when {
isPressed -> 0.92f
isHovered -> 1.05f
else -> 1f
},
animationSpec = tween(durationMillis = 150),
label = "thumbScale",
)
val lift = if (isHovered && !isPressed && onCheckedChange != null) (-1).dp else 0.dp
val density = LocalDensity.current
val thumbOffsetXPx by animateDpAsState(
targetValue = with(density) {
val trackWidth = ToggleWidth - ThumbSize
max((thumbProgress * trackWidth.toPx()).toDp() - 4.dp, 2.dp)
},
animationSpec = tween(durationMillis = 200),
label = "thumbOffsetX",
)
Box(
modifier = modifier
.width(ToggleWidth)
.height(ToggleHeight)
.graphicsLayer {
translationY = lift.toPx()
}
.then(
if (resolved.shadow.elevation > 0.dp) {
Modifier.shadow(
elevation = resolved.shadow.elevation,
shape = resolved.shape,
ambientColor = resolved.shadow.ambientColor,
spotColor = resolved.shadow.spotColor,
)
} else Modifier
)
.clip(resolved.shape)
.background(resolved.colors.background, resolved.shape)
.border(BorderStroke(resolved.border.width, borderColor), resolved.shape)
.highlight(resolved.colors.highlight)
.then(
if (onCheckedChange != null) {
Modifier
.hoverable(interactionSource = source, enabled = enabled)
.clickable(
enabled = enabled,
interactionSource = source,
indication = null,
onClick = { onCheckedChange(!checked) },
)
} else {
Modifier
}
),
contentAlignment = Alignment.CenterStart,
) {
Box(
modifier = Modifier
.offset(x = thumbOffsetXPx)
.size(ThumbSize)
.graphicsLayer {
scaleX = thumbScale
scaleY = thumbScale
}
.shadow(
elevation = thumbShadowState.elevation,
shape = CircleShape,
ambientColor = thumbShadowState.ambientColor,
spotColor = thumbShadowState.spotColor,
)
.background(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = 0.6f),
thumbColor.copy(alpha = 0.95f),
thumbColor,
),
center = Offset(0f, -0.55f),
radius = 1.1f,
),
shape = CircleShape,
)
.border(
width = 0.8.dp,
brush = Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.55f),
Color.White.copy(alpha = 0.08f),
),
),
shape = CircleShape,
),
)
}
}
@Preview(showBackground = true)
@Composable
private fun TogglePreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Toggle(checked = true, onCheckedChange = {})
Toggle(checked = false, onCheckedChange = {})
Toggle(checked = true, onCheckedChange = null)
Toggle(checked = false, onCheckedChange = null, enabled = false)
}
}
}
}
}

View File

@ -0,0 +1,23 @@
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
fun Modifier.highlight(color: Color): Modifier =
drawWithCache {
val highlightBrush = Brush.verticalGradient(
colors = listOf(color, Color.Transparent),
startY = 0f,
endY = size.height * 0.5f,
)
onDrawWithContent {
drawContent()
drawRect(
brush = highlightBrush,
topLeft = androidx.compose.ui.geometry.Offset.Zero,
size = size,
)
}
}

View File

@ -17,13 +17,6 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.MillenniumSeparator
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -32,54 +25,75 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import com.composeunstyled.SheetDetent import androidx.compose.foundation.layout.size
import com.composeunstyled.rememberModalBottomSheetState import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AdaptiveDialogBottomSheet( fun AdaptiveDialogBottomSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
title: @Composable (() -> Unit)? = null, title: @Composable (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
breakpointDp: Float = 600f,
actions: @Composable (() -> Unit)? = null, actions: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit) { content: @Composable () -> Unit,
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium ) {
val adaptiveInfo = currentWindowAdaptiveInfo()
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
if (isLargeScreen) { if (isLargeScreen) {
MillenniumDialog( ThemedDialog(
visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = "", title = if (title != null) {
modifier = modifier) { {
title?.let { it() } Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f)) { title() }
IconButton(onClick = onDismiss) {
Icon(
Iconsax.FluentDismiss,
contentDescription = "Close dialog",
)
}
}
}
} else {
null
},
actions = actions,
modifier = modifier,
) {
content() content()
actions?.let {
MillenniumSeparator()
it()
}
} }
} else { } else {
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) ModalBottomSheet(
LaunchedEffect(sheetState.currentDetent) { onDismissRequest = onDismiss,
if (sheetState.currentDetent == SheetDetent.Hidden) { dragHandle = null,
onDismiss() modifier = modifier,
} ) {
}
MillenniumModalBottomSheet(state = sheetState) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(top = 16.dp), .padding(top = 16.dp),
horizontalAlignment = Alignment.Start) { horizontalAlignment = Alignment.Start,
) {
title?.let { title?.let {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -87,12 +101,11 @@ fun AdaptiveDialogBottomSheet(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box(Modifier.weight(1f)) { it() } Box(Modifier.weight(1f)) { it() }
MillenniumIconButton( IconButton(onClick = onDismiss) {
onClick = onDismiss, Icon(
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
Iconsax.FluentDismiss, Iconsax.FluentDismiss,
contentDescription = "Close dialog") contentDescription = "Close dialog",
)
} }
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@ -100,7 +113,7 @@ fun AdaptiveDialogBottomSheet(
content() content()
actions?.let { actions?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
MillenniumSeparator() HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
it() it()
} }

View File

@ -17,12 +17,13 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -33,12 +34,20 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -46,37 +55,25 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.composeunstyled.DropdownMenuPanel
import com.composeunstyled.SheetDetent
import com.composeunstyled.Text
import com.composeunstyled.UnstyledDropdownMenu
import com.composeunstyled.UnstyledDropdownMenuItem
import com.composeunstyled.rememberModalBottomSheetState
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Check import compose.icons.feathericons.Check
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.DropdownMenu
import dev.krtirtho.spotube.core.ui.base.DropdownMenuItem
import dev.krtirtho.spotube.core.ui.base.DropdownMenuDivider
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.gelBrush
import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface
data class AdaptiveMenuItem( data class AdaptiveMenuItem(
val icon: ImageVector? = null, val icon: ImageVector? = null,
@ -85,8 +82,7 @@ data class AdaptiveMenuItem(
val enabled: Boolean = true, val enabled: Boolean = true,
val selected: Boolean = false, val selected: Boolean = false,
val dividerBefore: Boolean = false, val dividerBefore: Boolean = false,
val subtitle: String? = null, )
val leadingContent: (@Composable () -> Unit)? = null)
enum class HeaderDisplayMode { enum class HeaderDisplayMode {
Always, Always,
@ -99,129 +95,71 @@ fun AdaptiveDropdownBottomSheet(
items: List<AdaptiveMenuItem>, items: List<AdaptiveMenuItem>,
trigger: @Composable (onClick: () -> Unit) -> Unit, trigger: @Composable (onClick: () -> Unit) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
breakpointDp: Float = 600f,
menuMinWidth: Dp = 200.dp, menuMinWidth: Dp = 200.dp,
header: @Composable (() -> Unit)? = null, header: @Composable (() -> Unit)? = null,
headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always, headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always,
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium val adaptiveInfo = currentWindowAdaptiveInfo()
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
Box(modifier = modifier) { Box(modifier = modifier) {
trigger { expanded = true }
if (isLargeScreen) { if (isLargeScreen) {
UnstyledDropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onExpandedChange = { expanded = it }, onDismissRequest = { expanded = false },
panel = { ) {
DropdownMenuPanel( if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
Modifier header()
.width(menuMinWidth)
.millenniumSurface(
background = millenniumPanelBrush(MillenniumTheme.colors),
shape = AppTheme.shapes.medium,
borderColor = AppTheme.colors.outlineVariant,
borderWidth = 1.dp)
.padding(4.dp)) {
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
header()
}
val dropdownQueryState = rememberTextFieldState()
val query = dropdownQueryState.text.toString()
if (filter != null) {
MillenniumTextField(
state = dropdownQueryState,
label = "",
placeholder = "Search...",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp))
}
items.forEach { item ->
if (query.isNotBlank() && filter != null && !filter(item, query)) {
return@forEach
}
if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
}
val itemInteraction = remember { MutableInteractionSource() }
val itemHovered by itemInteraction.collectIsHoveredAsState()
val itemPressed by itemInteraction.collectIsPressedAsState()
val itemEnabled = item.enabled
val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected)
val itemForeground = when {
!itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f)
itemHighlighted -> MillenniumTheme.colors.onAccent
else -> AppTheme.colors.onSurface
}
UnstyledDropdownMenuItem(
onClick = { item.onClick() },
enabled = itemEnabled,
indication = null,
interactionSource = itemInteraction) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (itemHighlighted) {
Modifier.millenniumSurface(
background = gelBrush(
MillenniumTheme.colors.accentTop,
MillenniumTheme.colors.accentBottom),
shape = RoundedCornerShape(8.dp))
} else {
Modifier
})
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val itemIcon = item.icon
when {
item.leadingContent != null -> item.leadingContent.invoke()
itemIcon != null -> MillenniumIcon(
imageVector = itemIcon,
contentDescription = null,
tint = itemForeground,
modifier = Modifier.size(20.dp))
item.selected -> MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
tint = itemForeground,
modifier = Modifier.size(20.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.label,
style = AppTheme.typography.bodyMedium,
color = itemForeground)
item.subtitle?.let { subtitle ->
Text(
text = subtitle,
style = AppTheme.typography.bodySmall,
color = if (itemHighlighted) {
itemForeground.copy(alpha = 0.85f)
} else {
AppTheme.colors.onSurfaceVariant
})
}
}
if (item.selected && (item.icon != null || item.leadingContent != null)) {
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
tint = itemForeground,
modifier = Modifier.size(20.dp))
}
}
}
}
} }
},
anchor = { trigger { expanded = true } }, var query by remember { mutableStateOf("") }
) if (filter != null) {
TextField(
value = query,
onValueChange = {
query = it
},
placeholder = { Text("Search...") },
singleLine = true,
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Search",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
items.forEach { item ->
if (query.isNotBlank() && filter != null && !filter(item, query)) {
return@forEach
}
if (item.dividerBefore) {
DropdownMenuDivider()
}
DropdownMenuItem(
text = item.label,
onClick = {
item.onClick()
expanded = false
},
enabled = item.enabled,
selected = item.selected,
leadingIcon = item.icon,
)
}
}
} else { } else {
trigger { expanded = true }
if (expanded) { if (expanded) {
AdaptiveBottomSheetContent( AdaptiveBottomSheetContent(
onDismiss = { expanded = false }, onDismiss = { expanded = false },
@ -231,124 +169,143 @@ fun AdaptiveDropdownBottomSheet(
} else { } else {
null null
}, },
filter = filter) filter = filter,
)
} }
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AdaptiveBottomSheetContent( private fun AdaptiveBottomSheetContent(
onDismiss: () -> Unit, onDismiss: () -> Unit,
items: List<AdaptiveMenuItem>, items: List<AdaptiveMenuItem>,
header: @Composable (() -> Unit)?, header: @Composable (() -> Unit)?,
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
) {
val hasSelection = items.any { it.selected } val hasSelection = items.any { it.selected }
val queryState = rememberTextFieldState() var query by remember { mutableStateOf("") }
val query = queryState.text.toString()
val filteredItems = if (filter != null && query.isNotBlank()) { val filteredItems = if (filter != null && query.isNotBlank()) {
items.filter { filter(it, query) } items.filter { filter(it, query) }
} else { } else {
items items
} }
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) ModalBottomSheet(
LaunchedEffect(sheetState.currentDetent) { onDismissRequest = onDismiss,
if (sheetState.currentDetent == SheetDetent.Hidden) { dragHandle = null,
onDismiss() ) {
}
}
MillenniumModalBottomSheet(state = sheetState) {
Column( Column(
modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth(),
) {
header?.invoke() header?.invoke()
if (filter != null) { if (filter != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
MillenniumTextField( TextField(
state = queryState, value = query,
label = "", onValueChange = {
placeholder = "Search...", query = it
},
placeholder = { Text("Search...") },
singleLine = true,
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Search",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)) .padding(horizontal = 12.dp, vertical = 8.dp)
)
} }
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp), .padding(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)) { verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items( items(
count = filteredItems.size) { index -> count = filteredItems.size,
) { index ->
val item = filteredItems[index] val item = filteredItems[index]
if (item.dividerBefore) { if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(AppTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.clickable( .clickable(
enabled = item.enabled, enabled = item.enabled,
onClick = { onClick = {
item.onClick() item.onClick()
onDismiss() onDismiss()
}) },
)
.background( .background(
color = if (item.selected) { color = if (item.selected) {
AppTheme.colors.secondary.copy(alpha = 0.12f) MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f)
} else { } else {
Color.Transparent Color.Transparent
}, },
shape = AppTheme.shapes.medium) shape = MaterialTheme.shapes.medium,
)
.padding(horizontal = 12.dp, vertical = 14.dp), .padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) { horizontalArrangement = Arrangement.SpaceBetween,
when { ) {
item.leadingContent != null -> item.leadingContent.invoke() Row(
item.icon != null -> MillenniumIcon( horizontalArrangement = Arrangement.spacedBy(12.dp),
imageVector = item.icon, verticalAlignment = Alignment.CenterVertically,
contentDescription = null, ) {
modifier = Modifier.size(22.dp), if (item.icon != null) {
tint = if (item.enabled) { Icon(
AppTheme.colors.onSurface imageVector = item.icon,
} else { contentDescription = null,
AppTheme.colors.onSurface.copy(alpha = 0.38f) modifier = Modifier.size(22.dp),
}) tint = if (item.enabled) {
MaterialTheme.colorScheme.onSurface
item.selected -> MillenniumIcon( } else {
imageVector = FeatherIcons.Check, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
contentDescription = "Selected", },
modifier = Modifier.size(22.dp), )
tint = AppTheme.colors.onSurfaceVariant) } else if (item.selected) {
Icon(
hasSelection -> Spacer(modifier = Modifier.size(22.dp)) imageVector = FeatherIcons.Check,
} contentDescription = "Selected",
modifier = Modifier.size(22.dp),
Column(modifier = Modifier.weight(1f)) { tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (hasSelection) {
Spacer(modifier = Modifier.size(22.dp))
}
Text( Text(
text = item.label, text = item.label,
style = AppTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = if (item.enabled) { color = if (item.enabled) {
AppTheme.colors.onSurface MaterialTheme.colorScheme.onSurface
} else { } else {
AppTheme.colors.onSurface.copy(alpha = 0.38f) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}) },
item.subtitle?.let { subtitle -> )
Text(
text = subtitle,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
}
} }
if (item.selected && (item.icon != null || item.leadingContent != null)) { if (item.selected && item.icon != null) {
MillenniumIcon( Icon(
imageVector = FeatherIcons.Check, imageVector = FeatherIcons.Check,
contentDescription = "Selected", contentDescription = "Selected",
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = AppTheme.colors.onSurfaceVariant) tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
@ -360,37 +317,44 @@ private fun AdaptiveBottomSheetContent(
@Preview @Preview
@Composable @Composable
private fun AdaptiveDropdownBottomSheetPreview() { private fun AdaptiveDropdownBottomSheetPreview() {
Box(modifier = Modifier.background(AppTheme.colors.background)) { Scaffold {
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = listOf( items = listOf(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxHeart, icon = Iconsax.IconsaxHeart,
label = "Favorite", label = "Favorite",
onClick = {}), onClick = {},
),
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxShare, icon = Iconsax.IconsaxShare,
label = "Share", label = "Share",
onClick = {}), onClick = {},
),
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxTrash, icon = Iconsax.IconsaxTrash,
label = "Delete", label = "Delete",
onClick = {}, onClick = {},
enabled = false)), enabled = false,
),
),
trigger = { onClick -> trigger = { onClick ->
MillenniumIconButton( IconButton(
onClick = onClick, onClick = onClick,
modifier = Modifier modifier = Modifier
.size(32.dp) .size(32.dp)
.background( .background(
color = AppTheme.colors.primary.copy(alpha = 0.1f), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
shape = CircleShape) shape = CircleShape,
)
.clip(CircleShape), .clip(CircleShape),
style = MillenniumIconButtonStyle.Quiet) { ) {
MillenniumIcon( Icon(
imageVector = Iconsax.Iconsax3DotsMore, imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = null, contentDescription = null,
tint = AppTheme.colors.primary) tint = MaterialTheme.colorScheme.primary,
)
} }
}) },
)
} }
} }

View File

@ -40,7 +40,8 @@ fun AlbumCard(
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject(),
remotePlaybackController: RemotePlaybackController = koinInject()) { remotePlaybackController: RemotePlaybackController = koinInject(),
) {
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
PlayableCard( PlayableCard(
@ -56,14 +57,17 @@ fun AlbumCard(
remotePlaybackController.requestCollectionPlay( remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Album, RemoteCollectionType.Album,
album.id, album.id,
album.title) album.title,
)
}, },
onAddToQueue = { onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue( remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Album, RemoteCollectionType.Album,
album.id, album.id,
album.title) album.title,
)
}, },
modifier = modifier.width(160.dp), modifier = modifier.width(160.dp),
sharedElementKey = "album_art_${album.id}") sharedElementKey = "album_art_${album.id}",
)
} }

View File

@ -17,12 +17,9 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
@ -33,12 +30,12 @@ import org.koin.compose.koinInject
fun ApplicationBackButton() { fun ApplicationBackButton() {
val navigationCommands: NavigationCommands = koinInject() val navigationCommands: NavigationCommands = koinInject()
MillenniumIconButton( IconButton(
onClick = { onClick = {
navigationCommands.pop() navigationCommands.pop()
}, },
style = MillenniumIconButtonStyle.Quiet) { ) {
MillenniumIcon( Icon(
imageVector = Iconsax.ArrowLeft3, imageVector = Iconsax.ArrowLeft3,
contentDescription = "Back" contentDescription = "Back"
) )
@ -50,5 +47,5 @@ expect fun ApplicationMainBar(
title: @Composable () -> Unit = {}, title: @Composable () -> Unit = {},
subtitle: @Composable () -> Unit = {}, subtitle: @Composable () -> Unit = {},
actions: @Composable (RowScope.() -> Unit) = {}, actions: @Composable (RowScope.() -> Unit) = {},
backButton: Boolean = true, backButton: Boolean = true
transparent: Boolean = false) )

View File

@ -29,9 +29,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.MaterialTheme
import com.composeunstyled.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -52,7 +52,8 @@ import org.koin.compose.koinInject
fun ArtistCard( fun ArtistCard(
artist: MetadataArtist.Basic, artist: MetadataArtist.Basic,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isBlacklisted: Boolean = false) { isBlacklisted: Boolean = false,
) {
val navigationCommands = koinInject<NavigationCommands>() val navigationCommands = koinInject<NavigationCommands>()
AvatarCard( AvatarCard(
@ -63,6 +64,7 @@ fun ArtistCard(
navigationCommands.navigateTo(Routes.Artist(artist.id)) navigationCommands.navigateTo(Routes.Artist(artist.id))
}, },
modifier = modifier, modifier = modifier,
isBlacklisted = isBlacklisted) isBlacklisted = isBlacklisted,
)
} }

View File

@ -17,24 +17,13 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.layout.height
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumSeparator
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import com.composeunstyled.currentWindowWidthBreakpoint import androidx.compose.foundation.layout.BoxWithConstraints
import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -42,9 +31,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.MaterialTheme
import com.composeunstyled.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -53,6 +42,12 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
@ -67,6 +62,7 @@ import dev.krtirtho.spotube.resources.iconsax.User
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CollectionDetails( fun CollectionDetails(
@ -89,9 +85,10 @@ fun CollectionDetails(
onFollowClick: () -> Unit = { }, onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true, showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
sharedElementKey: String? = null) { sharedElementKey: String? = null,
Box(modifier = modifier.fillMaxWidth()) { ) {
val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val isCompact = maxWidth < 600.dp
val sharedTransitionScope = LocalSharedTransitionScope.current val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
@ -101,86 +98,95 @@ fun CollectionDetails(
if (onAddToJam != null) { if (onAddToJam != null) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)) { horizontalArrangement = Arrangement.spacedBy(6.dp),
MillenniumButton( ) {
PrimaryButton(
modifier = if (isCompact) Modifier.weight(1f) else Modifier, modifier = if (isCompact) Modifier.weight(1f) else Modifier,
onClick = onAddToJam!!) { onClick = onAddToJam!!,
MillenniumIcon( ) {
Icon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to Jam") contentDescription = "Add to Jam",
TextWithShimmer(text = "Add to Jam") )
TextWithShimmer(
text = "Add to Jam",
modifier = Modifier.padding(start = 6.dp),
)
} }
} }
} }
} else { } else {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)) { horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val modifier = if (isCompact) { val modifier = if (isCompact) {
Modifier.weight(1f) Modifier.weight(1f)
} else { } else {
Modifier Modifier
} }
MillenniumButton( PrimaryButton(
modifier = modifier, modifier = modifier,
onClick = onPlay) { onClick = onPlay,
MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play")
TextWithShimmer(text = if (isPlaying) "Pause" else "Play")
}
MillenniumButton(
modifier = modifier,
onClick = onShufflePlay,
style = MillenniumButtonStyle.Secondary
) { ) {
MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") Icon(
TextWithShimmer(text = "Shuffle") imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
)
TextWithShimmer(
text = if (isPlaying) "Pause" else "Play",
modifier = Modifier.padding(start = 6.dp),
)
}
OutlineButton(
modifier = modifier,
onClick = onShufflePlay
) {
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
TextWithShimmer(
text = "Shuffle",
modifier = Modifier.padding(start = 6.dp),
)
} }
} }
} }
} }
val actions = @Composable { val actions = @Composable {
Row(verticalAlignment = Alignment.CenterVertically) { ButtonGroup {
MillenniumIconButton( GroupIconButton(onClick = onAddToQueue) {
onClick = onAddToQueue, Icon(
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to queue" contentDescription = "Add to queue"
) )
} }
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) ButtonGroupDivider()
if (showFollowButton) { if (showFollowButton) {
if (isFollowing) { if (isFollowing) {
MillenniumIconButton( GroupIconButton(onClick = onFollowClick) {
onClick = onFollowClick, Icon(
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.IconsaxHeart2, imageVector = Iconsax.IconsaxHeart2,
contentDescription = "Unfollow") contentDescription = "Unfollow",
)
} }
} else { } else {
MillenniumIconButton( GroupIconButton(onClick = onFollowClick) {
onClick = onFollowClick, Icon(
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.IconsaxHeart, imageVector = Iconsax.IconsaxHeart,
contentDescription = "Follow", contentDescription = "Follow",
tint = AppTheme.colors.onSurfaceVariant) tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
if (onEdit != null) { if (onEdit != null) {
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) ButtonGroupDivider()
MillenniumIconButton( GroupIconButton(onClick = onEdit) {
onClick = onEdit, Icon(
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.IconsaxEdit, imageVector = Iconsax.IconsaxEdit,
contentDescription = "Edit playlist") contentDescription = "Edit playlist",
)
} }
} }
} }
@ -189,7 +195,8 @@ fun CollectionDetails(
Row( Row(
modifier = Modifier.clickable(onClick = onOwnerClick), modifier = Modifier.clickable(onClick = onOwnerClick),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) { horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (ownerImageURL != null) { if (ownerImageURL != null) {
AsyncImage( AsyncImage(
model = ownerImageURL, model = ownerImageURL,
@ -198,24 +205,27 @@ fun CollectionDetails(
.size(16.dp) .size(16.dp)
.clip(CircleShape) .clip(CircleShape)
.shimmerApply(), .shimmerApply(),
contentScale = ContentScale.Crop) contentScale = ContentScale.Crop,
)
} else { } else {
MillenniumIcon( Icon(
imageVector = Iconsax.User, imageVector = Iconsax.User,
contentDescription = ownerName, contentDescription = ownerName,
modifier = Modifier modifier = Modifier
.size(16.dp) .size(16.dp)
.background( .background(
AppTheme.colors.primaryContainer, MaterialTheme.colorScheme.primaryContainer,
CircleShape CircleShape
) )
.padding(6.dp), .padding(6.dp),
tint = AppTheme.colors.onPrimaryContainer) tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
} }
TextWithShimmer( TextWithShimmer(
text = "By $ownerName", text = "By $ownerName",
style = AppTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
@ -227,68 +237,79 @@ fun CollectionDetails(
.sharedElementOrNone( .sharedElementOrNone(
key = sharedElementKey, key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope) animatedVisibilityScope = animatedVisibilityScope,
)
.shimmerApply(), .shimmerApply(),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center,
) {
if (imageURL.isNotBlank()) { if (imageURL.isNotBlank()) {
AsyncImage( AsyncImage(
model = imageURL, model = imageURL,
contentDescription = title, contentDescription = title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop) contentScale = ContentScale.Crop,
)
} else if (imageResource != null) { } else if (imageResource != null) {
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
painter = painterResource(imageResource), painter = painterResource(imageResource),
contentDescription = title, contentDescription = title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop) contentScale = ContentScale.Crop,
)
} else { } else {
Text( Text(
text = title.take(1).ifBlank { "?" }.uppercase(), text = title.take(1).ifBlank { "?" }.uppercase(),
style = AppTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = AppTheme.colors.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
val metadata = @Composable { val metadata = @Composable {
Column { Column {
TextWithShimmer( TextWithShimmer(
text = title, text = title,
style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge, style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.headlineLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
if (description.isNotBlank()) { if (description.isNotBlank()) {
TextWithShimmer( TextWithShimmer(
text = description, text = description,
style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium, style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
} }
} }
} }
MillenniumCard( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
contentPadding = PaddingValues(0.dp)) { ) {
if (isCompact) { if (isCompact) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(14.dp), .padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)) { verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically,
) {
artwork() artwork()
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp)) { verticalArrangement = Arrangement.spacedBy(6.dp),
) {
metadata() metadata()
ownerInfo() ownerInfo()
actions() actions()
@ -302,12 +323,14 @@ fun CollectionDetails(
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top) { verticalAlignment = Alignment.Top,
) {
artwork() artwork()
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp),
) {
metadata() metadata()
ownerInfo() ownerInfo()
actions() actions()
@ -331,5 +354,6 @@ fun CollectionDetailsPreview() {
onOwnerClick = {}, onOwnerClick = {},
onPlay = {}, onPlay = {},
onShufflePlay = {}, onShufflePlay = {},
onAddToQueue = {}) onAddToQueue = {},
)
} }

View File

@ -17,22 +17,18 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.IconButton
import com.composeunstyled.Text import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -97,7 +93,8 @@ fun CollectionView(
onAddToJam: (List<MetadataTrack>) -> Unit = {}, onAddToJam: (List<MetadataTrack>) -> Unit = {},
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
footerContent: (@Composable () -> Unit)? = null, footerContent: (@Composable () -> Unit)? = null,
trailingContent: @Composable () -> Unit = {}) { trailingContent: @Composable () -> Unit = {},
) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val isCollapsed by remember { val isCollapsed by remember {
derivedStateOf { derivedStateOf {
@ -106,42 +103,45 @@ fun CollectionView(
} }
} }
Column(modifier = Modifier.fillMaxSize()) { Scaffold(
ApplicationMainBar( topBar = {
backButton = true, ApplicationMainBar(
title = if (isCollapsed) { backButton = true,
{ title = if (isCollapsed) {
CollapsedCollectionTitle( {
title = title, CollapsedCollectionTitle(
imageURL = imageURL, title = title,
imageResource = imageResource) imageURL = imageURL,
} imageResource = imageResource,
} else { )
{}
},
actions = if (isCollapsed && !isJamGuest) {
{
MillenniumIconButton(
onClick = onPlay,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play")
} }
} } else {
} else { {}
{} },
}) actions = if (isCollapsed && !isJamGuest) {
{
Box(modifier = Modifier.weight(1f).fillMaxSize()) { IconButton(onClick = onPlay) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
)
}
}
} else {
{}
},
)
}
) { innerPadding ->
if (error != null && tracks.isEmpty() && !isLoading) { if (error != null && tracks.isEmpty() && !isLoading) {
ErrorDisplay( ErrorDisplay(
errorMessage = error, errorMessage = error,
onRetry = onRetry ?: {}, onRetry = onRetry ?: {},
modifier = Modifier.fillMaxSize()) modifier = Modifier.padding(innerPadding),
)
} else { } else {
TrackList( TrackList(
modifier = Modifier.fillMaxSize(), modifier = Modifier.padding(innerPadding),
state = listState, state = listState,
headerContent = { headerContent = {
if (isLoading && tracks.isEmpty()) { if (isLoading && tracks.isEmpty()) {
@ -164,7 +164,8 @@ fun CollectionView(
onEdit = onEdit, onEdit = onEdit,
sharedElementKey = sharedElementKey, sharedElementKey = sharedElementKey,
isJamGuest = isJamGuest, isJamGuest = isJamGuest,
onAddToJam = { onAddToJam(tracks) }) onAddToJam = { onAddToJam(tracks) },
)
} }
} else { } else {
CollectionDetails( CollectionDetails(
@ -185,7 +186,8 @@ fun CollectionView(
onEdit = onEdit, onEdit = onEdit,
sharedElementKey = sharedElementKey, sharedElementKey = sharedElementKey,
isJamGuest = isJamGuest, isJamGuest = isJamGuest,
onAddToJam = { onAddToJam(tracks) }) onAddToJam = { onAddToJam(tracks) },
)
} }
}, },
footerContent = footerContent, footerContent = footerContent,
@ -209,11 +211,11 @@ fun CollectionView(
onBulkAddToJam = onBulkAddToJam, onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest, isJamGuest = isJamGuest,
trackOptionsState = trackOptionsState) trackOptionsState = trackOptionsState,
)
} }
trailingContent() trailingContent()
}
} }
} }
@ -221,39 +223,46 @@ fun CollectionView(
private fun CollapsedCollectionTitle( private fun CollapsedCollectionTitle(
title: String, title: String,
imageURL: String, imageURL: String,
imageResource: DrawableResource? = null) { imageResource: DrawableResource? = null,
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.shimmerApply(), .shimmerApply(),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center,
) {
if (imageURL.isNotBlank()) { if (imageURL.isNotBlank()) {
AsyncImage( AsyncImage(
model = imageURL, model = imageURL,
contentDescription = title, contentDescription = title,
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
contentScale = ContentScale.Crop) contentScale = ContentScale.Crop,
)
} else if (imageResource != null) { } else if (imageResource != null) {
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
painter = painterResource(imageResource), painter = painterResource(imageResource),
contentDescription = title, contentDescription = title,
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
contentScale = ContentScale.Crop) contentScale = ContentScale.Crop,
)
} else { } else {
Text( Text(
text = title.take(1).ifBlank { "?" }.uppercase(), text = title.take(1).ifBlank { "?" }.uppercase(),
style = AppTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = AppTheme.colors.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
TextWithShimmer( TextWithShimmer(
text = title, text = title,
style = AppTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 10.dp)) modifier = Modifier.padding(start = 10.dp),
)
} }
} }

View File

@ -17,33 +17,30 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.Button
import androidx.compose.material3.Card
import com.composeunstyled.Text import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -70,7 +67,8 @@ import compose.icons.feathericons.RefreshCw
fun ErrorDisplay( fun ErrorDisplay(
errorMessage: String, errorMessage: String,
onRetry: () -> Unit, onRetry: () -> Unit,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier,
) {
var showFullLog by remember { mutableStateOf(false) } var showFullLog by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
@ -79,99 +77,117 @@ fun ErrorDisplay(
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)) { verticalArrangement = Arrangement.spacedBy(20.dp),
MillenniumIcon( ) {
Icon(
imageVector = FeatherIcons.AlertCircle, imageVector = FeatherIcons.AlertCircle,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
tint = AppTheme.colors.error.copy(alpha = 0.7f)) tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
)
Text( Text(
text = "We're Truly Sorry", text = "We're Truly Sorry",
style = AppTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = AppTheme.colors.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center) textAlign = TextAlign.Center,
)
Text( Text(
text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.", text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.",
style = AppTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2) lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2,
)
MillenniumCard( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(0.dp)) { colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f),
),
shape = MaterialTheme.shapes.medium,
) {
Column( Column(
modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth(),
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically,
) {
Text( Text(
text = "Error Details", text = "Error Details",
style = AppTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)) { horizontalArrangement = Arrangement.spacedBy(4.dp),
MillenniumIconButton( ) {
IconButton(
onClick = { onClick = {
clipboardManager.setText(AnnotatedString(errorMessage)) clipboardManager.setText(AnnotatedString(errorMessage))
}, },
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
style = MillenniumIconButtonStyle.Quiet) { ) {
MillenniumIcon( Icon(
imageVector = FeatherIcons.Copy, imageVector = FeatherIcons.Copy,
contentDescription = "Copy to clipboard", contentDescription = "Copy to clipboard",
modifier = Modifier.size(16.dp)) modifier = Modifier.size(16.dp),
)
} }
MillenniumIconButton( IconButton(
onClick = { showFullLog = true }, onClick = { showFullLog = true },
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
style = MillenniumIconButtonStyle.Quiet) { ) {
MillenniumIcon( Icon(
imageVector = FeatherIcons.Maximize2, imageVector = FeatherIcons.Maximize2,
contentDescription = "View full log", contentDescription = "View full log",
modifier = Modifier.size(16.dp)) modifier = Modifier.size(16.dp),
)
} }
} }
} }
MillenniumCard( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(0.dp)) { color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f),
) {
SelectionContainer { SelectionContainer {
Text( Text(
text = errorMessage, text = errorMessage,
style = AppTheme.typography.bodySmall.copy( style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace), fontFamily = FontFamily.Monospace,
color = AppTheme.colors.onSurfaceVariant, ),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 5, maxLines = 5,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(12.dp)) modifier = Modifier.padding(12.dp),
)
} }
} }
} }
} }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)) { horizontalArrangement = Arrangement.spacedBy(12.dp),
MillenniumButton( ) {
Button(
onClick = onRetry, onClick = onRetry,
modifier = Modifier.height(40.dp)) { modifier = Modifier.height(40.dp),
Row(verticalAlignment = Alignment.CenterVertically) { ) {
MillenniumIcon( Icon(
imageVector = FeatherIcons.RefreshCw, imageVector = FeatherIcons.RefreshCw,
contentDescription = null, contentDescription = null,
modifier = Modifier.padding(end = 8.dp).size(18.dp)) modifier = Modifier.padding(end = 8.dp).size(18.dp),
Text("Retry") )
} Text("Retry")
} }
} }
} }
@ -179,85 +195,93 @@ fun ErrorDisplay(
if (showFullLog) { if (showFullLog) {
ErrorLogDialog( ErrorLogDialog(
errorMessage = errorMessage, errorMessage = errorMessage,
onDismiss = { showFullLog = false }) onDismiss = { showFullLog = false },
)
} }
} }
@Composable @Composable
private fun ErrorLogDialog( private fun ErrorLogDialog(
errorMessage: String, errorMessage: String,
onDismiss: () -> Unit) { onDismiss: () -> Unit,
) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
MillenniumDialog( AlertDialog(
visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = "") { title = {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically,
Text( ) {
text = "Full Error Log",
style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.Bold)
MillenniumIconButton(
onClick = {
clipboardManager.setText(AnnotatedString(errorMessage))
},
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = FeatherIcons.Copy,
contentDescription = "Copy to clipboard")
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.clip(AppTheme.shapes.small)
.background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
SelectionContainer {
Text( Text(
text = errorMessage, text = "Full Error Log",
style = AppTheme.typography.bodySmall.copy( style = MaterialTheme.typography.titleLarge,
fontFamily = FontFamily.Monospace), fontWeight = FontWeight.Bold,
color = AppTheme.colors.onSurfaceVariant, )
modifier = Modifier
.fillMaxSize() IconButton(
.verticalScroll(rememberScrollState()) onClick = {
.padding(12.dp)) clipboardManager.setText(AnnotatedString(errorMessage))
},
) {
Icon(
imageVector = FeatherIcons.Copy,
contentDescription = "Copy to clipboard",
)
}
} }
} },
Row( text = {
modifier = Modifier.fillMaxWidth(), Box(
horizontalArrangement = Arrangement.End) { modifier = Modifier
MillenniumButton( .fillMaxWidth()
onClick = onDismiss, .height(400.dp)
style = MillenniumButtonStyle.Quiet) { .clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)),
) {
SelectionContainer {
Text(
text = errorMessage,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Close") Text("Close")
} }
} },
} )
} }
@Preview @Preview
@Composable @Composable
private fun ErrorDisplayPreview() { private fun ErrorDisplayPreview() {
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { Surface {
ErrorDisplay( ErrorDisplay(
errorMessage = "This is an error message", errorMessage = "This is an error message",
onRetry = {}) onRetry = {},
)
} }
} }
@Preview @Preview
@Composable @Composable
private fun ErrorLogDialogPreview() { private fun ErrorLogDialogPreview() {
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { Scaffold {
ErrorLogDialog( ErrorLogDialog(
errorMessage = "This is an error message", errorMessage = "This is an error message",
onDismiss = {}) onDismiss = {},
)
} }
} }

View File

@ -17,10 +17,6 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -38,11 +34,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.IconButton
import com.composeunstyled.Text import androidx.compose.material3.MaterialTheme
import com.composeunstyled.currentWindowWidthBreakpoint import androidx.compose.material3.Text
import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -87,7 +83,8 @@ import org.koin.compose.koinInject
@Composable @Composable
fun FeaturedCarousel( fun FeaturedCarousel(
items: List<MetadataBrowseItem>, items: List<MetadataBrowseItem>,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier,
) {
val filteredItems = remember(items) { val filteredItems = remember(items) {
items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track } items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track }
} }
@ -95,17 +92,20 @@ fun FeaturedCarousel(
if (filteredItems.isEmpty()) return if (filteredItems.isEmpty()) return
FeaturedCarouselContent( FeaturedCarouselContent(
items = filteredItems) items = filteredItems,
)
} }
@Composable @Composable
private fun FeaturedCarouselContent( private fun FeaturedCarouselContent(
items: List<MetadataBrowseItem>, items: List<MetadataBrowseItem>,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier,
) {
val centerIndex = items.size / 2 val centerIndex = items.size / 2
var selectedIndex by remember { mutableIntStateOf(centerIndex) } var selectedIndex by remember { mutableIntStateOf(centerIndex) }
val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index } val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index }
val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium val adaptiveInfo = currentWindowAdaptiveInfo()
val isSmallScreen = adaptiveInfo.windowSizeClass.minWidthDp <= 600
Box( Box(
modifier = modifier modifier = modifier
@ -113,7 +113,8 @@ private fun FeaturedCarouselContent(
.height( .height(
if (isSmallScreen) 200.dp else 300.dp if (isSmallScreen) 200.dp else 300.dp
), ),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center,
) {
Coverflow( Coverflow(
state = state, state = state,
params = CoverflowParams( params = CoverflowParams(
@ -122,17 +123,21 @@ private fun FeaturedCarouselContent(
angle = 12f, angle = 12f,
shift = 0.255f, shift = 0.255f,
zoom = 0.7864f, zoom = 0.7864f,
mirror = false), mirror = false,
modifier = modifier) { ),
modifier = modifier,
) {
items( items(
items = items, items = items,
key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem -> key = { index: Int -> items[index].hashCode() },
) { item: MetadataBrowseItem ->
FeaturedCarouselCard( FeaturedCarouselCard(
item = item, item = item,
isCentered = selectedIndex == items.indexOf(item), isCentered = selectedIndex == items.indexOf(item),
onScrollToCenter = { onScrollToCenter = {
state.scrollToItem(items.indexOf(item)) state.scrollToItem(items.indexOf(item))
}) },
)
} }
} }
} }
@ -143,7 +148,8 @@ private fun FeaturedCarouselCard(
item: MetadataBrowseItem, item: MetadataBrowseItem,
isCentered: Boolean, isCentered: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onScrollToCenter: () -> Unit = { }) { onScrollToCenter: () -> Unit = { },
) {
val navigationCommands = koinInject<NavigationCommands>() val navigationCommands = koinInject<NavigationCommands>()
val audioPlayerQueue = koinInject<AudioPlayerQueue>() val audioPlayerQueue = koinInject<AudioPlayerQueue>()
@ -198,21 +204,25 @@ private fun FeaturedCarouselCard(
} }
} }
.aspectRatio(1f) .aspectRatio(1f)
.hazeSource(hazeState)) { .hazeSource(hazeState),
) {
AsyncImage( AsyncImage(
model = thumbnailUrl, model = thumbnailUrl,
contentDescription = title, contentDescription = title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop) contentScale = ContentScale.Crop,
)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = dimAlpha))) .background(Color.Black.copy(alpha = dimAlpha)),
)
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter) { contentAlignment = Alignment.BottomCenter,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -221,35 +231,43 @@ private fun FeaturedCarouselCard(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
colors = listOf( colors = listOf(
Color.Transparent, Color.Transparent,
Color.Black.copy(alpha = 0.9f))))) Color.Black.copy(alpha = 0.9f),
),
),
),
)
} }
} }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
contentAlignment = Alignment.BottomCenter) { contentAlignment = Alignment.BottomCenter,
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp)) { .padding(16.dp),
) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = title, text = title,
style = AppTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White, color = Color.White,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = subtitle, text = subtitle,
style = AppTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.85f), color = Color.White.copy(alpha = 0.85f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
} }
val animatedAlpha by animateFloatAsState( val animatedAlpha by animateFloatAsState(
@ -288,7 +306,8 @@ private fun FeaturedPlayButton(
navigationCommands: NavigationCommands, navigationCommands: NavigationCommands,
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
enabled: Boolean, enabled: Boolean,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
@ -301,8 +320,7 @@ private fun FeaturedPlayButton(
modifier = modifier.size(44.dp), modifier = modifier.size(44.dp),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
MillenniumIconButton( IconButton(
style = MillenniumIconButtonStyle.Quiet,
onClick = { onClick = {
if (isPlaying) { if (isPlaying) {
navigationCommands.navigateTo(Routes.Album(item.data.id)) navigationCommands.navigateTo(Routes.Album(item.data.id))
@ -312,7 +330,7 @@ private fun FeaturedPlayButton(
}, },
enabled = enabled enabled = enabled
) { ) {
MillenniumIcon( Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = null, contentDescription = null,
tint = Color.White tint = Color.White
@ -329,8 +347,7 @@ private fun FeaturedPlayButton(
modifier = modifier.size(44.dp), modifier = modifier.size(44.dp),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
MillenniumIconButton( IconButton(
style = MillenniumIconButtonStyle.Quiet,
onClick = { onClick = {
if (isPlaying) { if (isPlaying) {
navigationCommands.navigateTo(Routes.Playlist(item.data.id)) navigationCommands.navigateTo(Routes.Playlist(item.data.id))
@ -340,7 +357,7 @@ private fun FeaturedPlayButton(
}, },
enabled = enabled enabled = enabled
) { ) {
MillenniumIcon( Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = null, contentDescription = null,
tint = Color.White tint = Color.White
@ -360,20 +377,20 @@ private fun FeaturedPlayButton(
modifier = modifier.size(44.dp), modifier = modifier.size(44.dp),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
MillenniumIconButton( IconButton(
style = MillenniumIconButtonStyle.Quiet,
onClick = { onClick = {
scope.launch { scope.launch {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = listOf(queueEntry), entries = listOf(queueEntry),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null,
)
} }
}, },
enabled = enabled enabled = enabled
) { ) {
MillenniumIcon( Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = null, contentDescription = null,
tint = Color.White tint = Color.White

View File

@ -17,10 +17,6 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@ -39,9 +35,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledIconButton
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.FilledTonalIconButton
import com.composeunstyled.Text import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -78,7 +76,8 @@ fun LikedTracksCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject()) { navigationCommands: NavigationCommands = koinInject(),
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
@ -93,7 +92,8 @@ fun LikedTracksCard(
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }) .clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
.width(160.dp) .width(160.dp)
.hoverable(interactionSource = interactionSource)) { .hoverable(interactionSource = interactionSource),
) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
@ -106,8 +106,10 @@ fun LikedTracksCard(
.sharedElementOrNone( .sharedElementOrNone(
key = "saved_tracks_art", key = "saved_tracks_art",
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope), animatedVisibilityScope = animatedVisibilityScope,
contentScale = ContentScale.Crop) ),
contentScale = ContentScale.Crop,
)
Box( Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
@ -118,24 +120,22 @@ fun LikedTracksCard(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp) modifier = Modifier.padding(4.dp)
) { ) {
MillenniumIconButton( FilledTonalIconButton(
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp)
style = MillenniumIconButtonStyle.Secondary
) { ) {
MillenniumIcon( Icon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to queue", contentDescription = "Add to queue",
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
MillenniumIconButton( FilledIconButton(
onClick = { scope.launch { playbackHelper.playSavedTracks() } }, onClick = { scope.launch { playbackHelper.playSavedTracks() } },
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp)
style = MillenniumIconButtonStyle.Primary
) { ) {
MillenniumIcon( Icon(
imageVector = if (isPlaying) { imageVector = if (isPlaying) {
Iconsax.IconsaxPauseCircle Iconsax.IconsaxPauseCircle
} else Iconsax.Play, } else Iconsax.Play,
@ -152,16 +152,18 @@ fun LikedTracksCard(
) { ) {
Text( Text(
text = "Liked Tracks", text = "Liked Tracks",
style = AppTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Your saved songs", text = "Your saved songs",
style = AppTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
} }
} }
} }

View File

@ -40,7 +40,8 @@ fun PlaylistCard(
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject(),
remotePlaybackController: RemotePlaybackController = koinInject()) { remotePlaybackController: RemotePlaybackController = koinInject(),
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
@ -57,14 +58,17 @@ fun PlaylistCard(
remotePlaybackController.requestCollectionPlay( remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Playlist, RemoteCollectionType.Playlist,
playlist.id, playlist.id,
playlist.title) playlist.title,
)
}, },
onAddToQueue = { onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue( remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Playlist, RemoteCollectionType.Playlist,
playlist.id, playlist.id,
playlist.title) playlist.title,
)
}, },
modifier = modifier, modifier = modifier,
sharedElementKey = "playlist_art_${playlist.id}") sharedElementKey = "playlist_art_${playlist.id}",
)
} }

View File

@ -35,11 +35,13 @@ val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?>
fun Modifier.sharedElementOrNone( fun Modifier.sharedElementOrNone(
key: String?, key: String?,
sharedTransitionScope: SharedTransitionScope?, sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { animatedVisibilityScope: AnimatedVisibilityScope?,
): Modifier {
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
with(sharedTransitionScope) { with(sharedTransitionScope) {
return this@sharedElementOrNone.sharedElement( return this@sharedElementOrNone.sharedElement(
sharedContentState = rememberSharedContentState(key = key), sharedContentState = rememberSharedContentState(key = key),
animatedVisibilityScope = animatedVisibilityScope) animatedVisibilityScope = animatedVisibilityScope,
)
} }
} }

View File

@ -26,9 +26,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.FilledIconButton
import com.composeunstyled.Text import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -51,7 +54,8 @@ import org.koin.compose.koinInject
fun TrackCard( fun TrackCard(
track: MetadataTrack, track: MetadataTrack,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject()) { audioPlayerQueue: AudioPlayerQueue = koinInject(),
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val queueEntry = QueueEntry.StreamingTrack(track = track, url = "") val queueEntry = QueueEntry.StreamingTrack(track = track, url = "")
@ -65,7 +69,8 @@ fun TrackCard(
entries = listOf(queueEntry), entries = listOf(queueEntry),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null,
)
} }
}, },
onAddToQueue = { onAddToQueue = {

View File

@ -17,10 +17,6 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -40,7 +36,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -49,20 +44,12 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.state.ToggleableState import androidx.compose.material3.HorizontalDivider
import org.spotube.millennium.composables.MillenniumCard import androidx.compose.material3.Icon
import org.spotube.millennium.composables.MillenniumCheckbox import androidx.compose.material3.MaterialTheme
import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.material3.Scaffold
import org.spotube.millennium.composables.MillenniumIconButton import androidx.compose.material3.Text
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.composables.MillenniumTriStateCheckbox
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -81,6 +68,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -95,6 +83,16 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumTy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.CheckBox
import dev.krtirtho.spotube.core.ui.base.CheckBoxState
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.highlight
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
@ -124,7 +122,7 @@ enum class TrackSortOption(val label: String) {
Duration("Duration"), Duration("Duration"),
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun TrackList( fun TrackList(
tracks: List<MetadataTrack> = emptyList(), tracks: List<MetadataTrack> = emptyList(),
@ -155,9 +153,9 @@ fun TrackList(
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
simplified: Boolean = false, simplified: Boolean = false,
scrollable: Boolean = true, scrollable: Boolean = true,
state: LazyListState? = null) { state: LazyListState? = null,
val filterState = rememberTextFieldState() ) {
val filterQuery = filterState.text.toString() var filterQuery by rememberSaveable { mutableStateOf("") }
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) } var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) }
var isSelectionMode by rememberSaveable { mutableStateOf(false) } var isSelectionMode by rememberSaveable { mutableStateOf(false) }
@ -209,19 +207,20 @@ fun TrackList(
} }
} }
val widthBreakpoint = currentWindowWidthBreakpoint() val windowInfo = LocalWindowInfo.current
val isCompact = widthBreakpoint isBelow WindowBreakpoints.Medium val maxWidth = with(density) { windowInfo.containerSize.width.toDp() }
val isCompact = maxWidth < CompactTrackListBreakpoint
val isDesktop = remember { getPlatform().isDesktop() } val isDesktop = remember { getPlatform().isDesktop() }
val showIndex = widthBreakpoint isAtLeast WindowBreakpoints.Expanded val showIndex = maxWidth >= LargeTrackListBreakpoint
val showAlbum = !isCompact val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !isCompact val useDropdownForOptions = isDesktop && !isCompact
val trackCardContent: @Composable () -> Unit = { val trackCardContent: @Composable () -> Unit = {
MillenniumCard( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
contentPadding = PaddingValues(0.dp)) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -229,7 +228,9 @@ fun TrackList(
) { ) {
visibleTracks.forEachIndexed { displayedIndex, track -> visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) { if (displayedIndex > 0) {
MillenniumSeparator() HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
TrackListRow( TrackListRow(
index = displayedIndex + 1, index = displayedIndex + 1,
@ -283,31 +284,38 @@ fun TrackList(
onShowOptionsClick = { selectedTrackForOptions = track }, onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistsOverflowClick = { onArtistsOverflowClick(track) }) onArtistsOverflowClick = { onArtistsOverflowClick(track) },
)
} }
if (isLoading && tracks.isEmpty()) { if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex -> repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
MillenniumSeparator( HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp)) modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
ShimmerTrackListRow( ShimmerTrackListRow(
index = shimmerIndex + 1, index = shimmerIndex + 1,
showIndex = showIndex, showIndex = showIndex,
showAlbum = showAlbum, showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions) useDropdownForOptions = useDropdownForOptions,
)
} }
} }
if (error != null) { if (error != null) {
if (visibleTracks.isNotEmpty()) { if (visibleTracks.isNotEmpty()) {
MillenniumSeparator( HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp)) modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
TrackListFeedbackRow( TrackListFeedbackRow(
message = error, message = error,
isError = true) isError = true,
)
} }
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
@ -316,14 +324,17 @@ fun TrackList(
if (isLoadingNextPage) { if (isLoadingNextPage) {
if (visibleTracks.isNotEmpty()) { if (visibleTracks.isNotEmpty()) {
MillenniumSeparator( HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp)) modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
ShimmerTrackListRow( ShimmerTrackListRow(
index = visibleTracks.size + 1, index = visibleTracks.size + 1,
showIndex = showIndex, showIndex = showIndex,
showAlbum = showAlbum, showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions) useDropdownForOptions = useDropdownForOptions,
)
} }
} }
} }
@ -334,18 +345,19 @@ fun TrackList(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
.heightIn(max = 60.dp), .heightIn(max = 60.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) { horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
) {
if (showIndex || isSelectionMode) { if (showIndex || isSelectionMode) {
val allSelected = val allSelected =
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
val anySelected = selectedTrackIds.isNotEmpty() val anySelected = selectedTrackIds.isNotEmpty()
val headerState = when { val headerState = when {
allSelected -> ToggleableState.On allSelected -> CheckBoxState.SELECTED
anySelected -> ToggleableState.Indeterminate anySelected -> CheckBoxState.INDETERMINATE
else -> ToggleableState.Off else -> CheckBoxState.UNSELECTED
} }
MillenniumTriStateCheckbox( CheckBox(
value = headerState, state = headerState,
onClick = { onClick = {
if (allSelected) { if (allSelected) {
isSelectionMode = false isSelectionMode = false
@ -355,45 +367,58 @@ fun TrackList(
selectedTrackIds = visibleTracks.map { it.id }.toSet() selectedTrackIds = visibleTracks.map { it.id }.toSet()
} }
}, },
label = "") )
} }
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
) { ) {
MillenniumTextField( TextField(
state = filterState, value = filterQuery,
label = "", onValueChange = { filterQuery = it },
placeholder = "Filter",
modifier = Modifier modifier = Modifier
.widthIn(max = 400.dp) .widthIn(max = 400.dp)
.align(Alignment.CenterEnd)) .align(Alignment.CenterEnd),
placeholder = { TextWithShimmer("Filter") },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Filter",
)
},
singleLine = true,
)
} }
Row(verticalAlignment = Alignment.CenterVertically) { ButtonGroup {
Box( Box(
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center,
) {
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = TrackSortOption.entries.map { option -> items = TrackSortOption.entries.map { option ->
AdaptiveMenuItem( AdaptiveMenuItem(
label = option.label, label = option.label,
onClick = { sortBy = option }, onClick = { sortBy = option },
selected = sortBy == option) selected = sortBy == option,
)
}, },
trigger = { onClick -> trigger = { onClick ->
MillenniumIconButton( GroupIconButton(
onClick = onClick, onClick = onClick,
style = MillenniumIconButtonStyle.Quiet) { ) {
MillenniumIcon( Icon(
imageVector = Iconsax.IconsaxSort, imageVector = Iconsax.IconsaxSort,
contentDescription = "Sort") contentDescription = "Sort",
)
} }
}) },
)
} }
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) ButtonGroupDivider()
Box( Box(
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center,
) {
val targetTracks = if (selectedTrackIds.isNotEmpty()) { val targetTracks = if (selectedTrackIds.isNotEmpty()) {
visibleTracks.filter { selectedTrackIds.contains(it.id) } visibleTracks.filter { selectedTrackIds.contains(it.id) }
} else { } else {
@ -408,42 +433,54 @@ fun TrackList(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive, icon = Iconsax.IconsaxDirectboxReceive,
label = if (isAll) "Download All" else "Download $trackCount", label = if (isAll) "Download All" else "Download $trackCount",
onClick = { onBulkDownload(targetTracks) })) onClick = { onBulkDownload(targetTracks) },
),
)
if (!isJamGuest) { if (!isJamGuest) {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
onClick = { onBulkAddToQueue(targetTracks) })) onClick = { onBulkAddToQueue(targetTracks) },
),
)
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxNext, icon = Iconsax.IconsaxNext,
label = if (isAll) "Play All Next" else "Play $trackCount Next", label = if (isAll) "Play All Next" else "Play $trackCount Next",
onClick = { onBulkPlayNext(targetTracks) })) onClick = { onBulkPlayNext(targetTracks) },
),
)
} }
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicPlaylist, icon = Iconsax.IconsaxMusicPlaylist,
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
onClick = { onBulkAddToPlaylist(targetTracks) })) onClick = { onBulkAddToPlaylist(targetTracks) },
),
)
} + if (isInJam) { } + if (isInJam) {
listOf( listOf(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam", label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
onClick = { onBulkAddToJam(targetTracks) })) onClick = { onBulkAddToJam(targetTracks) },
),
)
} else { } else {
emptyList() emptyList()
}, },
trigger = { onClick -> trigger = { onClick ->
MillenniumIconButton( GroupIconButton(
onClick = onClick, onClick = onClick,
style = MillenniumIconButtonStyle.Quiet) { ) {
MillenniumIcon( Icon(
imageVector = Iconsax.Iconsax3DotsMore, imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Bulk actions") contentDescription = "Bulk actions",
)
} }
}) },
)
} }
} }
} }
@ -458,7 +495,8 @@ fun TrackList(
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp)) { verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (headerContent != null) { if (headerContent != null) {
item { item {
headerContent() headerContent()
@ -487,7 +525,8 @@ fun TrackList(
}, },
onAlbumClick = { track.album?.let { onAlbumClick(it) } }, onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest) isJamGuest = isJamGuest,
)
} }
} }
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd)) VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
@ -497,7 +536,8 @@ fun TrackList(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)) { verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (headerContent != null) { if (headerContent != null) {
headerContent() headerContent()
} }
@ -520,7 +560,8 @@ fun TrackList(
onTrackOptionsAction(track, action) onTrackOptionsAction(track, action)
selectedTrackForOptions = null selectedTrackForOptions = null
}, },
onAlbumClick = { track.album?.let { onAlbumClick(it) } }) onAlbumClick = { track.album?.let { onAlbumClick(it) } },
)
} }
} }
} }
@ -549,198 +590,235 @@ private fun TrackListRow(
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: () -> Unit, onArtistsOverflowClick: () -> Unit,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier,
) {
val rowTheme = LocalBaseUITheme.current.listRowTile
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
val artworkInteractionSource = remember { MutableInteractionSource() } val artworkInteractionSource = remember { MutableInteractionSource() }
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
val rowInteractionSource = remember { MutableInteractionSource() } val rowInteractionSource = remember { MutableInteractionSource() }
val isRowHovered by rowInteractionSource.collectIsHoveredAsState() val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
val rowBackgroundColor = when { val rowBackgroundColor = when {
isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f) isSelected -> rowTheme.background.selected
isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
isCurrentTrack -> AppTheme.colors.surfaceVariant isCurrentTrack -> rowTheme.background.focused
trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f) trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f))
else -> Color.Transparent else -> rowTheme.background.normal
} }
val highlightColor = Color.White.copy(
alpha = if (isLight) 0.9f else 0.06f
)
MillenniumListTile( Row(
modifier = modifier, modifier = modifier
selected = isSelected || isCurrentTrack, .fillMaxWidth()
onClick = onTrackClick, .hoverable(rowInteractionSource)
title = { .combinedClickable(
onClick = onTrackClick,
onLongClick = onLongClick,
)
.background(rowBackgroundColor)
.then(
if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) {
Modifier.highlight(highlightColor)
} else {
Modifier
}
)
.clip(MaterialTheme.shapes.small)
.padding(horizontal = 8.dp, vertical = 6.dp)
.padding(end = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (isSelectionMode) {
CheckBox(
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
onClick = { onSelectionToggle(!isSelected) },
modifier = Modifier.shimmerApply()
)
} else if (showIndex) {
Box(
modifier = Modifier.width(30.dp),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = isRowHovered,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "index-checkbox",
) { hovered ->
if (hovered) {
CheckBox(
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
onClick = { onSelectionToggle(!isSelected) },
modifier = Modifier.shimmerApply()
)
} else {
TextWithShimmer(
text = index.toString(),
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Box(
modifier = Modifier
.size(44.dp)
.clip(MaterialTheme.shapes.small)
.hoverable(interactionSource = artworkInteractionSource),
contentAlignment = Alignment.Center,
) {
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
val platformContext = LocalPlatformContext.current
val imageRequest = remember(imageUrl) {
ImageRequest.Builder(platformContext)
.data(imageUrl)
.size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size.
.crossfade(false) // Crucial for scroll performance
.build()
}
AsyncImage(
model = imageRequest,
contentDescription = track.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().shimmerApply(),
)
if (isArtworkHovered || isCurrentTrack) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.10f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(18.dp).shimmerApply(),
)
}
}
}
Column(modifier = Modifier.weight(1.2f)) {
TextWithShimmer( TextWithShimmer(
text = track.title, text = track.title,
style = AppTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
}, )
subtitle = {
ArtistChips( ArtistChips(
artists = track.artists, artists = track.artists,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onArtistsOverflowClick = onArtistsOverflowClick) onArtistsOverflowClick = onArtistsOverflowClick,
}, )
leading = { }
if (showAlbum && track.album != null) {
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val colorScheme = MaterialTheme.colorScheme
Row( Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)
horizontalArrangement = Arrangement.spacedBy(10.dp)) { ) {
if (isSelectionMode) {
MillenniumCheckbox(
checked = isSelected,
onCheckedChange = { onSelectionToggle(it) },
label = "",
modifier = Modifier.shimmerApply())
} else if (showIndex) {
Box(
modifier = Modifier.width(30.dp),
contentAlignment = Alignment.Center) {
AnimatedContent(
targetState = isRowHovered,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "index-checkbox") { hovered ->
if (hovered) {
MillenniumCheckbox(
checked = isSelected,
onCheckedChange = { onSelectionToggle(it) },
label = "",
modifier = Modifier.shimmerApply())
} else {
TextWithShimmer(
text = index.toString(),
style = AppTheme.typography.labelMedium,
textAlign = TextAlign.Center,
color = AppTheme.colors.onSurfaceVariant)
}
}
}
}
Box(
modifier = Modifier
.size(44.dp)
.clip(AppTheme.shapes.small)
.hoverable(interactionSource = artworkInteractionSource),
contentAlignment = Alignment.Center) {
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
val platformContext = LocalPlatformContext.current
val imageRequest = remember(imageUrl) {
ImageRequest.Builder(platformContext)
.data(imageUrl)
.size(128)
.crossfade(false)
.build()
}
AsyncImage(
model = imageRequest,
contentDescription = track.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().shimmerApply())
if (isArtworkHovered || isCurrentTrack) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.10f)),
contentAlignment = Alignment.Center) {
MillenniumIcon(
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(18.dp).shimmerApply())
}
}
}
}
},
trailing = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (showAlbum && track.album != null) {
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val colorScheme = AppTheme.colors
TextWithShimmer(
text = track.album!!.title,
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.widthIn(max = 220.dp)
.hoverable(interactionSource)
.clickable(
indication = null,
interactionSource = interactionSource) { onAlbumClick(track.album!!) }
.then(
if (isHovered || isPressed) {
Modifier.drawWithCache {
val underlineHeight = 1.dp.toPx()
onDrawWithContent {
drawContent()
drawLine(
color = colorScheme.primary,
start = Offset(0f, size.height - underlineHeight),
end = Offset(size.width, size.height - underlineHeight),
strokeWidth = underlineHeight)
}
}
} else {
Modifier
}))
}
TextWithShimmer( TextWithShimmer(
text = track.durationMs.toDurationString(), text = track.album!!.title,
style = AppTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.End, color = MaterialTheme.colorScheme.primary,
color = AppTheme.colors.onSurfaceVariant, maxLines = 1,
modifier = Modifier.width(56.dp)) overflow = TextOverflow.Ellipsis,
modifier = Modifier
if (useDropdownForOptions) { .hoverable(interactionSource)
TrackOptions( .clickable(
track = track, indication = null,
state = trackOptionsState, interactionSource = interactionSource
onAction = onTrackOptionsAction, ) { onAlbumClick(track.album!!) }
onAlbumClick = { track.album?.let { onAlbumClick(it) } }, // Hide ripple and show underline on hover/click
isInJam = isInJam, .then(
isJamGuest = isJamGuest) if (isHovered || isPressed) {
} else { Modifier.drawWithCache {
MillenniumIconButton( val underlineHeight = 1.dp.toPx()
onClick = onShowOptionsClick, onDrawWithContent {
style = MillenniumIconButtonStyle.Quiet) { drawContent()
MillenniumIcon( drawLine(
imageVector = Iconsax.Iconsax3DotsMore, color = colorScheme.primary,
contentDescription = "Track options") start = Offset(0f, size.height - underlineHeight),
} end = Offset(size.width, size.height - underlineHeight),
} strokeWidth = underlineHeight,
)
}
}
} else {
Modifier
}
)
)
} }
}) }
TextWithShimmer(
text = track.durationMs.toDurationString(),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.End,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(56.dp),
)
if (useDropdownForOptions) {
TrackOptions(
track = track,
state = trackOptionsState,
onAction = onTrackOptionsAction,
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam,
isJamGuest = isJamGuest,
)
} else {
GhostIconButton(onClick = onShowOptionsClick) {
Icon(
imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Track options",
)
}
}
}
} }
@Composable @Composable
private fun ArtistChips( private fun ArtistChips(
artists: List<MetadataArtist.Basic>, artists: List<MetadataArtist.Basic>,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onArtistsOverflowClick: () -> Unit) { onArtistsOverflowClick: () -> Unit,
) {
val visibleArtists = artists.take(2) val visibleArtists = artists.take(2)
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth(),
) {
visibleArtists.forEachIndexed { index, artist -> visibleArtists.forEachIndexed { index, artist ->
Row( Row(
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically,
) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
TextWithShimmer( TextWithShimmer(
text = artist.name, text = artist.name,
style = AppTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = AppTheme.colors.primary, color = MaterialTheme.colorScheme.primary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null, textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null,
@ -749,12 +827,13 @@ private fun ArtistChips(
indication = null, indication = null,
interactionSource = interactionSource interactionSource = interactionSource
) { onArtistClick(artist) } ) { onArtistClick(artist) }
) ,
)
if (index < visibleArtists.size - 1) if (index < visibleArtists.size - 1)
Text( Text(
",", ",",
color = AppTheme.colors.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = AppTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
} }
} }
@ -763,11 +842,12 @@ private fun ArtistChips(
if (remaining > 0) { if (remaining > 0) {
TextWithShimmer( TextWithShimmer(
text = "+$remaining more", text = "+$remaining more",
style = AppTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = AppTheme.colors.primary, color = MaterialTheme.colorScheme.primary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onArtistsOverflowClick)) modifier = Modifier.clickable(onClick = onArtistsOverflowClick),
)
} }
} }
} }
@ -777,7 +857,8 @@ private fun ShimmerTrackListRow(
index: Int, index: Int,
showIndex: Boolean, showIndex: Boolean,
showAlbum: Boolean, showAlbum: Boolean,
useDropdownForOptions: Boolean) { useDropdownForOptions: Boolean,
) {
val dummyTrack = remember(index) { dummyTrackForShimmer(index) } val dummyTrack = remember(index) { dummyTrackForShimmer(index) }
SkeletonTree(true) { SkeletonTree(true) {
TrackListRow( TrackListRow(
@ -800,7 +881,8 @@ private fun ShimmerTrackListRow(
onShowOptionsClick = {}, onShowOptionsClick = {},
onArtistClick = {}, onArtistClick = {},
onAlbumClick = {}, onAlbumClick = {},
onArtistsOverflowClick = {}) onArtistsOverflowClick = {},
)
} }
} }
@ -809,7 +891,8 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
id = "shimmer_artist_$index", id = "shimmer_artist_$index",
name = "Artist Name", name = "Artist Name",
thumbnails = emptyList(), thumbnails = emptyList(),
externalUri = null) externalUri = null,
)
return MetadataTrack( return MetadataTrack(
id = "shimmer_track_$index", id = "shimmer_track_$index",
title = "Track Title Here", title = "Track Title Here",
@ -830,25 +913,29 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
thumbnails = emptyList(), thumbnails = emptyList(),
albumType = MetadataAlbumType.Album, albumType = MetadataAlbumType.Album,
artists = listOf(artist), artists = listOf(artist),
externalUri = null), externalUri = null,
),
thumbnails = null, thumbnails = null,
explicit = false, explicit = false,
popularity = 0, popularity = 0,
isrcCode = null, isrcCode = null,
externalUri = null) externalUri = null,
)
} }
@Composable @Composable
private fun TrackListFeedbackRow( private fun TrackListFeedbackRow(
message: String, message: String,
isError: Boolean = false) { isError: Boolean = false,
) {
TextWithShimmer( TextWithShimmer(
text = message, text = message,
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
style = AppTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp)) .padding(horizontal = 8.dp, vertical = 12.dp),
)
} }
private fun Long.toDurationString(): String { private fun Long.toDurationString(): String {
@ -864,17 +951,21 @@ fun previewTracks(): List<MetadataTrack> {
id = "artist_1", id = "artist_1",
name = "Aria Vale", name = "Aria Vale",
thumbnails = emptyList(), thumbnails = emptyList(),
externalUri = null), externalUri = null,
),
MetadataArtist.Basic( MetadataArtist.Basic(
id = "artist_2", id = "artist_2",
name = "Neon Harbor", name = "Neon Harbor",
thumbnails = emptyList(), thumbnails = emptyList(),
externalUri = null), externalUri = null,
),
MetadataArtist.Basic( MetadataArtist.Basic(
id = "artist_3", id = "artist_3",
name = "Echo Drift", name = "Echo Drift",
thumbnails = emptyList(), thumbnails = emptyList(),
externalUri = null)) externalUri = null,
),
)
val album = MetadataAlbum.Detailed( val album = MetadataAlbum.Detailed(
releaseDate = null, releaseDate = null,
@ -887,11 +978,13 @@ fun previewTracks(): List<MetadataTrack> {
Thumbnail( Thumbnail(
url = "https://picsum.photos/120", url = "https://picsum.photos/120",
width = 120, width = 120,
height = 120) height = 120,
)
), ),
albumType = MetadataAlbumType.Album, albumType = MetadataAlbumType.Album,
artists = artists.take(2), artists = artists.take(2),
externalUri = null) externalUri = null,
)
return listOf( return listOf(
MetadataTrack( MetadataTrack(
@ -906,7 +999,8 @@ fun previewTracks(): List<MetadataTrack> {
explicit = false, explicit = false,
popularity = 92, popularity = 92,
isrcCode = null, isrcCode = null,
externalUri = null), externalUri = null,
),
MetadataTrack( MetadataTrack(
id = "track_2", id = "track_2",
title = "Static Horizon", title = "Static Horizon",
@ -919,7 +1013,8 @@ fun previewTracks(): List<MetadataTrack> {
popularity = 80, popularity = 80,
isrcCode = null, isrcCode = null,
externalUri = null, externalUri = null,
thumbnails = null), thumbnails = null,
),
MetadataTrack( MetadataTrack(
id = "track_3", id = "track_3",
title = "Last Train Echo", title = "Last Train Echo",
@ -932,16 +1027,19 @@ fun previewTracks(): List<MetadataTrack> {
popularity = 77, popularity = 77,
isrcCode = null, isrcCode = null,
externalUri = null, externalUri = null,
thumbnails = null)) thumbnails = null,
),
)
} }
@Composable @Composable
@Preview @Preview
private fun TrackListPreview() { private fun TrackListPreview() {
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { Scaffold {
TrackList( TrackList(
tracks = previewTracks(), tracks = previewTracks(),
hasMore = true, hasMore = true,
isLoadingNextPage = true) isLoadingNextPage = true,
)
} }
} }

View File

@ -17,13 +17,6 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumModalBottomSheet
import com.composeunstyled.SheetDetent
import com.composeunstyled.rememberModalBottomSheetState
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -31,11 +24,12 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.Icon
import com.composeunstyled.Text import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -44,6 +38,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
@ -76,7 +72,8 @@ data class TrackOptionsState(
val isInQueue: Boolean = false, val isInQueue: Boolean = false,
val isCurrentlyPlaying: Boolean = false, val isCurrentlyPlaying: Boolean = false,
val isFavorite: Boolean = false, val isFavorite: Boolean = false,
val isBlacklisted: Boolean = false) val isBlacklisted: Boolean = false,
)
data class TrackOptionsContext( data class TrackOptionsContext(
val currentTrackId: String? = null, val currentTrackId: String? = null,
@ -84,13 +81,15 @@ data class TrackOptionsContext(
val savedTrackIds: Set<String> = emptySet(), val savedTrackIds: Set<String> = emptySet(),
val blacklistedTrackIds: Set<String> = emptySet(), val blacklistedTrackIds: Set<String> = emptySet(),
val blacklistedArtistIds: Set<String> = emptySet(), val blacklistedArtistIds: Set<String> = emptySet(),
val forceFavorite: Boolean = false) { val forceFavorite: Boolean = false,
) {
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState( fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
isInQueue = track.id in queueTrackIds, isInQueue = track.id in queueTrackIds,
isCurrentlyPlaying = track.id == currentTrackId, isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = forceFavorite || track.id in savedTrackIds, isFavorite = forceFavorite || track.id in savedTrackIds,
isBlacklisted = track.id in blacklistedTrackIds || isBlacklisted = track.id in blacklistedTrackIds ||
track.artists.any { it.id in blacklistedArtistIds }) track.artists.any { it.id in blacklistedArtistIds },
)
} }
@Composable @Composable
@ -101,7 +100,8 @@ fun TrackOptions(
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false) { isJamGuest: Boolean = false,
) {
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = buildTrackMenuItems( items = buildTrackMenuItems(
track = track, track = track,
@ -109,12 +109,11 @@ fun TrackOptions(
onAction = onAction, onAction = onAction,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest), isJamGuest = isJamGuest,
),
trigger = { onClick -> trigger = { onClick ->
MillenniumIconButton( GhostIconButton(onClick = onClick) {
onClick = onClick, Icon(
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.Iconsax3DotsMore, imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Track options", contentDescription = "Track options",
modifier = Modifier.shimmerApply() modifier = Modifier.shimmerApply()
@ -123,9 +122,11 @@ fun TrackOptions(
}, },
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
header = { TrackOptionsSheetHeader(track = track) }, header = { TrackOptionsSheetHeader(track = track) },
modifier = modifier) modifier = modifier,
)
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TrackOptionsBottomSheet( fun TrackOptionsBottomSheet(
track: MetadataTrack, track: MetadataTrack,
@ -134,14 +135,9 @@ fun TrackOptionsBottomSheet(
onAction: (TrackOptionsAction) -> Unit, onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false) { isJamGuest: Boolean = false,
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) ) {
LaunchedEffect(sheetState.currentDetent) { ModalBottomSheet(onDismissRequest = onDismiss) {
if (sheetState.currentDetent == SheetDetent.Hidden) {
onDismiss()
}
}
MillenniumModalBottomSheet(state = sheetState) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
TrackOptionsSheetHeader(track = track) TrackOptionsSheetHeader(track = track)
@ -149,7 +145,8 @@ fun TrackOptionsBottomSheet(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp), .padding(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)) { verticalArrangement = Arrangement.spacedBy(2.dp),
) {
buildTrackMenuItems( buildTrackMenuItems(
track = track, track = track,
state = state, state = state,
@ -162,35 +159,40 @@ fun TrackOptionsBottomSheet(
onDismiss() onDismiss()
}, },
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest).forEach { item -> isJamGuest = isJamGuest,
).forEach { item ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable( .clickable(
enabled = item.enabled, enabled = item.enabled,
onClick = item.onClick) onClick = item.onClick,
)
.padding(horizontal = 12.dp, vertical = 14.dp), .padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) { horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
item.icon?.let { icon -> item.icon?.let { icon ->
MillenniumIcon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = if (item.enabled) { tint = if (item.enabled) {
AppTheme.colors.onSurface MaterialTheme.colorScheme.onSurface
} else { } else {
AppTheme.colors.onSurface.copy(alpha = 0.38f) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}) },
)
} }
Text( Text(
text = item.label, text = item.label,
style = AppTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = if (item.enabled) { color = if (item.enabled) {
AppTheme.colors.onSurface MaterialTheme.colorScheme.onSurface
} else { } else {
AppTheme.colors.onSurface.copy(alpha = 0.38f) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}) },
)
} }
} }
} }
@ -205,34 +207,39 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) {
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) { horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage( AsyncImage(
model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(56.dp) .size(56.dp)
.clip(AppTheme.shapes.small)) .clip(MaterialTheme.shapes.small),
)
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = track.title, text = track.title,
style = AppTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
Text( Text(
text = track.artists.joinToString(", ") { it.name }, text = track.artists.joinToString(", ") { it.name },
style = AppTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
track.album?.let { album -> track.album?.let { album ->
Text( Text(
text = album.title, text = album.title,
style = AppTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
} }
} }
} }
@ -244,20 +251,25 @@ private fun buildTrackMenuItems(
onAction: (TrackOptionsAction) -> Unit, onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false): List<AdaptiveMenuItem> = buildList { isJamGuest: Boolean = false,
): List<AdaptiveMenuItem> = buildList {
if (isInJam) { if (isInJam) {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
label = "Add to Jam", label = "Add to Jam",
onClick = { onAction(TrackOptionsAction.AddToJam) })) onClick = { onAction(TrackOptionsAction.AddToJam) },
),
)
} }
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicCircle, icon = Iconsax.IconsaxMusicCircle,
label = "Start radio", label = "Start radio",
onClick = { onAction(TrackOptionsAction.StartRadio) })) onClick = { onAction(TrackOptionsAction.StartRadio) },
),
)
// A guest's queue is the shared jam queue — mutating it locally is not // A guest's queue is the shared jam queue — mutating it locally is not
// allowed, so queue actions are replaced by "Add to Jam". // allowed, so queue actions are replaced by "Add to Jam".
@ -267,13 +279,17 @@ private fun buildTrackMenuItems(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxNext, icon = Iconsax.IconsaxNext,
label = "Play next", label = "Play next",
onClick = { onAction(TrackOptionsAction.PlayNext) })) onClick = { onAction(TrackOptionsAction.PlayNext) },
),
)
} else if (state.isInQueue && !state.isCurrentlyPlaying) { } else if (state.isInQueue && !state.isCurrentlyPlaying) {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxNext, icon = Iconsax.IconsaxNext,
label = "Move to next", label = "Move to next",
onClick = { onAction(TrackOptionsAction.PlayNext) })) onClick = { onAction(TrackOptionsAction.PlayNext) },
),
)
} }
if (!state.isInQueue) { if (!state.isInQueue) {
@ -281,13 +297,17 @@ private fun buildTrackMenuItems(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
label = "Add to queue", label = "Add to queue",
onClick = { onAction(TrackOptionsAction.AddToQueue) })) onClick = { onAction(TrackOptionsAction.AddToQueue) },
),
)
} else { } else {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicSquareRemove, icon = Iconsax.IconsaxMusicSquareRemove,
label = "Remove from queue", label = "Remove from queue",
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) })) onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
),
)
} }
} }
@ -295,32 +315,42 @@ private fun buildTrackMenuItems(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,
label = if (state.isFavorite) "Remove from favorites" else "Save as favorite", label = if (state.isFavorite) "Remove from favorites" else "Save as favorite",
onClick = { onAction(TrackOptionsAction.ToggleFavorite) })) onClick = { onAction(TrackOptionsAction.ToggleFavorite) },
),
)
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicPlaylist, icon = Iconsax.IconsaxMusicPlaylist,
label = "Add to playlist", label = "Add to playlist",
onClick = { onAction(TrackOptionsAction.AddToPlaylist) })) onClick = { onAction(TrackOptionsAction.AddToPlaylist) },
),
)
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive, icon = Iconsax.IconsaxDirectboxReceive,
label = "Download", label = "Download",
onClick = { onAction(TrackOptionsAction.Download) })) onClick = { onAction(TrackOptionsAction.Download) },
),
)
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicSquareRemoveFilled, icon = Iconsax.IconsaxMusicSquareRemoveFilled,
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist", label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) })) onClick = { onAction(TrackOptionsAction.ToggleBlacklist) },
),
)
if (!track.externalUri.isNullOrBlank()) { if (!track.externalUri.isNullOrBlank()) {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxShare, icon = Iconsax.IconsaxShare,
label = "Share", label = "Share",
onClick = { onAction(TrackOptionsAction.Share) })) onClick = { onAction(TrackOptionsAction.Share) },
),
)
} }
if (track.album != null) { if (track.album != null) {
@ -328,6 +358,8 @@ private fun buildTrackMenuItems(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxCd, icon = Iconsax.IconsaxCd,
label = "Go to album", label = "Go to album",
onClick = onAlbumClick)) onClick = onAlbumClick,
),
)
} }
} }

View File

@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.MaterialTheme
import com.composeunstyled.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -44,7 +44,8 @@ import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
@Composable @Composable
fun UserCard( fun UserCard(
user: MetadataUser, user: MetadataUser,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier,
) {
AvatarCard( AvatarCard(
title = user.displayName ?: user.username, title = user.displayName ?: user.username,
subtitle = "User", subtitle = "User",
@ -52,6 +53,7 @@ fun UserCard(
onClick = { onClick = {
// TODO: Route to user details screen. // TODO: Route to user details screen.
}, },
modifier = modifier) modifier = modifier,
)
} }

View File

@ -28,7 +28,8 @@ import dev.krtirtho.spotube.isDesktop
@Composable @Composable
expect fun VerticalScrollbar( expect fun VerticalScrollbar(
listState: LazyListState, listState: LazyListState,
modifier: Modifier = Modifier) modifier: Modifier = Modifier,
)
fun Modifier.dragScrollable(rowState: LazyListState): Modifier = fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
if (getPlatform().isDesktop()) pointerInput(rowState) { if (getPlatform().isDesktop()) pointerInput(rowState) {

View File

@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.MaterialTheme
import com.composeunstyled.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -58,9 +58,10 @@ fun AvatarCard(
imageURL: String? = null, imageURL: String? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isBlacklisted: Boolean = false) { isBlacklisted: Boolean = false,
) {
val backgroundColor = if (isBlacklisted) { val backgroundColor = if (isBlacklisted) {
AppTheme.colors.error.copy(alpha = 0.1f) MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
} else { } else {
Color.Transparent Color.Transparent
} }
@ -70,17 +71,20 @@ fun AvatarCard(
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(backgroundColor) .background(backgroundColor)
.clickable(onClick = { onClick.invoke() }, enabled = true) .clickable(onClick = { onClick.invoke() }, enabled = true)
.width(160.dp)) { .width(160.dp),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) { horizontalAlignment = Alignment.CenterHorizontally,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(CircleShape) .clip(CircleShape)
.padding(12.dp), .padding(12.dp),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center,
) {
AsyncImage( AsyncImage(
model = imageURL, model = imageURL,
contentDescription = title, contentDescription = title,
@ -89,24 +93,27 @@ fun AvatarCard(
.aspectRatio(1f) .aspectRatio(1f)
.clip(CircleShape) .clip(CircleShape)
.shimmerApply(), .shimmerApply(),
contentScale = ContentScale.Crop) contentScale = ContentScale.Crop,
)
} }
Column( Column(
modifier = Modifier.fillMaxWidth().padding(12.dp) modifier = Modifier.fillMaxWidth().padding(12.dp)
) { ) {
TextWithShimmer( TextWithShimmer(
text = title, text = title,
style = AppTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth(),
)
subtitle?.let { subtitle?.let {
TextWithShimmer( TextWithShimmer(
text = subtitle, text = subtitle,
style = AppTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth(),
)
} }
} }
} }

View File

@ -17,10 +17,6 @@
package dev.krtirtho.spotube.core.ui.component.cards package dev.krtirtho.spotube.core.ui.component.cards
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@ -40,8 +36,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -53,6 +50,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
@ -62,11 +63,6 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.player_add_to_queue_content_description
import spotube.composeapp.generated.resources.player_pause_content_description
import spotube.composeapp.generated.resources.player_play_content_description
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
@ -79,7 +75,8 @@ fun PlayableCard(
onPlay: (() -> Unit)? = null, onPlay: (() -> Unit)? = null,
onAddToQueue: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedElementKey: String? = null) { sharedElementKey: String? = null,
) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val sharedTransitionScope = LocalSharedTransitionScope.current val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
@ -89,7 +86,8 @@ fun PlayableCard(
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable(onClick = { onClick?.invoke() }, enabled = true) .clickable(onClick = { onClick?.invoke() }, enabled = true)
.width(160.dp) .width(160.dp)
.hoverable(interactionSource = interactionSource)) { .hoverable(interactionSource = interactionSource),
) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) { Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
AsyncImage( AsyncImage(
@ -102,8 +100,10 @@ fun PlayableCard(
.sharedElementOrNone( .sharedElementOrNone(
key = sharedElementKey, key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope), animatedVisibilityScope = animatedVisibilityScope,
contentScale = ContentScale.Crop) ),
contentScale = ContentScale.Crop,
)
if (onPlay != null || onAddToQueue != null) if (onPlay != null || onAddToQueue != null)
Box( Box(
@ -112,45 +112,48 @@ fun PlayableCard(
) { ) {
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
this@Column.AnimatedVisibility( this@Column.AnimatedVisibility(
visible = isHovered || (isPlaying && onPlay != null), visible = isHovered,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut()) { exit = fadeOut(),
) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp) modifier = Modifier.padding(4.dp)
) { ) {
// While this collection is playing the play/pause toggle stays onAddToQueue?.let {
// visible and the add-to-queue action is hidden entirely. SecondaryIconButton(
onAddToQueue?.takeIf { !isPlaying }?.let {
MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
modifier = Modifier.size(34.dp), modifier = Modifier.size(30.dp),
style = MillenniumIconButtonStyle.Secondary theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
shape = BaseUITheme.InteractionState.fromSingleValue(
CircleShape
)
)
) { ) {
MillenniumIcon( Icon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = stringResource( contentDescription = "Add to queue",
Res.string.player_add_to_queue_content_description)) )
} }
Spacer(modifier = Modifier.height(8.dp))
} }
Spacer(modifier = Modifier.height(8.dp))
onPlay?.let { onPlay?.let {
MillenniumIconButton( PrimaryIconButton(
onClick = onPlay, onClick = onPlay,
modifier = Modifier.size(34.dp), modifier = Modifier.size(30.dp),
style = MillenniumIconButtonStyle.Primary theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
shape = BaseUITheme.InteractionState.fromSingleValue(
CircleShape
)
)
) { ) {
MillenniumIcon( Icon(
imageVector = if (isPlaying) { imageVector = if (isPlaying) {
Iconsax.IconsaxPause Iconsax.IconsaxPause
} else Iconsax.IconsaxPlay, } else Iconsax.IconsaxPlay,
contentDescription = stringResource( contentDescription = "Play",
if (isPlaying) { )
Res.string.player_pause_content_description
} else {
Res.string.player_play_content_description
}))
} }
} }
} }
@ -162,17 +165,19 @@ fun PlayableCard(
) { ) {
TextWithShimmer( TextWithShimmer(
text = title, text = title,
style = AppTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
subtitle?.let { subtitle?.let {
TextWithShimmer( TextWithShimmer(
text = subtitle, text = subtitle,
style = AppTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis,
)
} }
} }
} }
@ -180,10 +185,11 @@ fun PlayableCard(
} }
} }
@PreviewLightDark @PreviewLightDark
@Composable @Composable
private fun PlayableCardPreview() { private fun PlayableCardPreview() {
Box { Scaffold {
PlayableCard( PlayableCard(
title = "Sample Title", title = "Sample Title",
subtitle = "Sample Subtitle", subtitle = "Sample Subtitle",

View File

@ -45,7 +45,8 @@ fun Coverflow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: CoverflowState = rememberCoverflowState(), state: CoverflowState = rememberCoverflowState(),
params: CoverflowParams = CoverflowParams(), params: CoverflowParams = CoverflowParams(),
content: CoverflowScope.() -> Unit) { content: CoverflowScope.() -> Unit,
) {
var size by remember { mutableStateOf(IntSize.Zero) } var size by remember { mutableStateOf(IntSize.Zero) }
var geometry by remember { mutableStateOf<Geometry?>(null) } var geometry by remember { mutableStateOf<Geometry?>(null) }
@ -54,7 +55,8 @@ fun Coverflow(
val g = geometry ?: return@LaunchedEffect val g = geometry ?: return@LaunchedEffect
state.lazyListState.scrollToItem( state.lazyListState.scrollToItem(
state.selectedIndex + 1, state.selectedIndex + 1,
-g.spacerWidth) -g.spacerWidth,
)
state.initialScrollDone = true state.initialScrollDone = true
} }
@ -67,7 +69,8 @@ fun Coverflow(
}, },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
state = state.lazyListState, state = state.lazyListState,
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) { flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState),
) {
if (size != IntSize.Zero) { if (size != IntSize.Zero) {
val g = Geometry(params, size) val g = Geometry(params, size)
geometry = g geometry = g
@ -76,7 +79,8 @@ fun Coverflow(
val coverflowScope = CoverflowScopeImpl( val coverflowScope = CoverflowScopeImpl(
geometry = g, geometry = g,
lazyListScope = this, lazyListScope = this,
coverflowState = state) coverflowState = state,
)
item { item {
Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() })) Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() }))
@ -98,13 +102,15 @@ fun Coverflow(
if (state.selectedIndex > 0) { if (state.selectedIndex > 0) {
state.scrollToItem(state.selectedIndex - 1) state.scrollToItem(state.selectedIndex - 1)
} }
}) {} },
) {}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.clickable { .clickable {
state.scrollToItem(state.selectedIndex + 1) state.scrollToItem(state.selectedIndex + 1)
}) {} },
) {}
} }
} }

View File

@ -25,7 +25,8 @@ import kotlin.math.min
@Stable @Stable
internal class Geometry( internal class Geometry(
internal val params: CoverflowParams = CoverflowParams(), internal val params: CoverflowParams = CoverflowParams(),
private val size: IntSize = IntSize.Zero) { private val size: IntSize = IntSize.Zero,
) {
@Stable @Stable
private val shortEdge private val shortEdge
get() = min(size.width, size.height) get() = min(size.width, size.height)

View File

@ -41,7 +41,8 @@ internal fun CoverflowItem(
geometry: Geometry, geometry: Geometry,
onClickHandler: () -> Unit = {}, onClickHandler: () -> Unit = {},
onSelectedHandler: (Boolean) -> Unit, onSelectedHandler: (Boolean) -> Unit,
content: @Composable () -> Unit) { content: @Composable () -> Unit,
) {
var horizontalPosition by remember { mutableStateOf<Float?>(null) } var horizontalPosition by remember { mutableStateOf<Float?>(null) }
val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f) val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f)
@ -52,7 +53,8 @@ internal fun CoverflowItem(
.onGloballyPositioned { coordinates -> .onGloballyPositioned { coordinates ->
horizontalPosition = coordinates.positionInParent().x horizontalPosition = coordinates.positionInParent().x
}, },
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center,
) {
if (horizontalPosition != null) { if (horizontalPosition != null) {
onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f)) onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f))
@ -63,16 +65,20 @@ internal fun CoverflowItem(
rotationY = geometry.rotation(distanceToCenter), rotationY = geometry.rotation(distanceToCenter),
translationX = geometry.translationX(distanceToCenter), translationX = geometry.translationX(distanceToCenter),
scaleY = geometry.scale(distanceToCenter), scaleY = geometry.scale(distanceToCenter),
scaleX = geometry.scale(distanceToCenter)), scaleX = geometry.scale(distanceToCenter),
propagateMinConstraints = true) { ),
propagateMinConstraints = true,
) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
if (geometry.params.mirror) { if (geometry.params.mirror) {
Mirror( Mirror(
modifier = Modifier.clickable( modifier = Modifier.clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
indication = null, indication = null,
onClick = onClickHandler), onClick = onClickHandler,
coverSize = geometry.coverSize) { ),
coverSize = geometry.coverSize,
) {
content() content()
} }
} else { } else {
@ -80,7 +86,9 @@ internal fun CoverflowItem(
modifier = Modifier.clickable( modifier = Modifier.clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
indication = null, indication = null,
onClick = onClickHandler)) { onClick = onClickHandler,
),
) {
content() content()
} }
} }

View File

@ -26,4 +26,5 @@ data class CoverflowParams(
val angle: Float = 45f, val angle: Float = 45f,
val shift: Float = 0.4f, val shift: Float = 0.4f,
val zoom: Float = 0.8f, val zoom: Float = 0.8f,
val mirror: Boolean = false) val mirror: Boolean = false,
)

View File

@ -26,32 +26,37 @@ interface CoverflowScope {
onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> }, onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> },
key: ((index: Int) -> Any)? = null, key: ((index: Int) -> Any)? = null,
contentType: (index: Int) -> Any? = { null }, contentType: (index: Int) -> Any? = { null },
itemContent: @Composable ((item: T) -> Unit)) itemContent: @Composable ((item: T) -> Unit),
)
fun items( fun items(
count: Int, count: Int,
onSelectHandler: (index: Int) -> Unit = {}, onSelectHandler: (index: Int) -> Unit = {},
key: ((index: Int) -> Any)? = null, key: ((index: Int) -> Any)? = null,
contentType: (index: Int) -> Any? = { null }, contentType: (index: Int) -> Any? = { null },
itemContent: @Composable ((index: Int) -> Unit)) itemContent: @Composable ((index: Int) -> Unit),
)
} }
internal class CoverflowScopeImpl( internal class CoverflowScopeImpl(
private val geometry: Geometry, private val geometry: Geometry,
private val lazyListScope: LazyListScope, private val lazyListScope: LazyListScope,
private val coverflowState: CoverflowState) : CoverflowScope { private val coverflowState: CoverflowState,
) : CoverflowScope {
override fun <T> items( override fun <T> items(
items: List<T>, items: List<T>,
onSelectHandler: (item: T, index: Int) -> Unit, onSelectHandler: (item: T, index: Int) -> Unit,
key: ((index: Int) -> Any)?, key: ((index: Int) -> Any)?,
contentType: (index: Int) -> Any?, contentType: (index: Int) -> Any?,
itemContent: @Composable (item: T) -> Unit) = items( itemContent: @Composable (item: T) -> Unit,
) = items(
count = items.size, count = items.size,
onSelectHandler = { onSelectHandler = {
onSelectHandler(items[it], it) onSelectHandler(items[it], it)
}, },
key = key, key = key,
contentType = contentType) { contentType = contentType,
) {
itemContent(items[it]) itemContent(items[it])
} }
@ -60,10 +65,12 @@ internal class CoverflowScopeImpl(
onSelectHandler: (index: Int) -> Unit, onSelectHandler: (index: Int) -> Unit,
key: ((index: Int) -> Any)?, key: ((index: Int) -> Any)?,
contentType: (index: Int) -> Any?, contentType: (index: Int) -> Any?,
itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items( itemContent: @Composable (index: Int) -> Unit,
) = lazyListScope.items(
count, count,
key, key,
contentType) { contentType,
) {
CoverflowItem( CoverflowItem(
onClickHandler = { coverflowState.scrollToItem(it) }, onClickHandler = { coverflowState.scrollToItem(it) },
onSelectedHandler = { isSelected: Boolean -> onSelectedHandler = { isSelected: Boolean ->
@ -71,14 +78,16 @@ internal class CoverflowScopeImpl(
onSelectHandler(it) onSelectHandler(it)
} }
}, },
geometry = geometry) { geometry = geometry,
) {
itemContent(it) itemContent(it)
} }
} }
private fun selectHandler( private fun selectHandler(
index: Int, index: Int,
isSelected: Boolean): Boolean { isSelected: Boolean,
): Boolean {
if (!isSelected && index == coverflowState.selectedIndex) { if (!isSelected && index == coverflowState.selectedIndex) {
coverflowState.selectedIndex = -1 coverflowState.selectedIndex = -1
} else if (isSelected && index != coverflowState.selectedIndex) { } else if (isSelected && index != coverflowState.selectedIndex) {

View File

@ -28,16 +28,19 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun rememberCoverflowState( fun rememberCoverflowState(
initialIndex: Int = 0, initialIndex: Int = 0,
onSelectHandler: (Int) -> Unit = {}): CoverflowState { onSelectHandler: (Int) -> Unit = {},
): CoverflowState {
val lazyListState: LazyListState = rememberLazyListState( val lazyListState: LazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = initialIndex + 1) initialFirstVisibleItemIndex = initialIndex + 1,
)
val coroutineScope: CoroutineScope = rememberCoroutineScope() val coroutineScope: CoroutineScope = rememberCoroutineScope()
return remember { return remember {
CoverflowState( CoverflowState(
lazyListState, lazyListState,
coroutineScope, coroutineScope,
onSelectHandler, onSelectHandler,
initialIndex) initialIndex,
)
} }
} }
@ -45,7 +48,8 @@ class CoverflowState internal constructor(
internal val lazyListState: LazyListState, internal val lazyListState: LazyListState,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val onSelectHandler: (Int) -> Unit = {}, private val onSelectHandler: (Int) -> Unit = {},
initialIndex: Int = 0) { initialIndex: Int = 0,
) {
internal var geometry: Geometry? = null internal var geometry: Geometry? = null
internal var initialScrollDone: Boolean = false internal var initialScrollDone: Boolean = false
@ -61,7 +65,8 @@ class CoverflowState internal constructor(
coroutineScope.launch { coroutineScope.launch {
lazyListState.animateScrollToItem( lazyListState.animateScrollToItem(
index + 1, index + 1,
-geometryCopy.spacerWidth) -geometryCopy.spacerWidth,
)
} }
} }
} }

View File

@ -48,20 +48,23 @@ import androidx.compose.ui.unit.times
internal fun Mirror( internal fun Mirror(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
coverSize: Float, coverSize: Float,
content: @Composable () -> Unit) { content: @Composable () -> Unit,
) {
val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() } val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() }
Column( Column(
modifier = modifier modifier = modifier
.padding(top = coverSizeDp) .padding(top = coverSizeDp)
.fillMaxWidth() .fillMaxWidth()
.requiredHeight(2 * coverSizeDp)) { .requiredHeight(2 * coverSizeDp),
) {
Box( Box(
propagateMinConstraints = true, propagateMinConstraints = true,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth()
.height(coverSizeDp)) { .height(coverSizeDp),
) {
content() content()
} }
Box( Box(
@ -72,27 +75,34 @@ internal fun Mirror(
.height(coverSizeDp) .height(coverSizeDp)
.graphicsLayer( .graphicsLayer(
compositingStrategy = CompositingStrategy.Offscreen, compositingStrategy = CompositingStrategy.Offscreen,
rotationX = 180f) rotationX = 180f,
)
.clip(HalfSizeShape) .clip(HalfSizeShape)
.drawWithContent { .drawWithContent {
val colors = listOf( val colors = listOf(
Color.Transparent, Color.Transparent,
Color.Transparent, Color.Transparent,
Color.Black) Color.Black,
)
drawContent() drawContent()
drawRect( drawRect(
topLeft = Offset( topLeft = Offset(
0f, 0f,
this.size.height / 2), this.size.height / 2,
),
size = Size( size = Size(
this.size.width, this.size.width,
this.size.height / 2), this.size.height / 2,
),
brush = Brush.verticalGradient(colors), brush = Brush.verticalGradient(colors),
blendMode = BlendMode.DstIn) blendMode = BlendMode.DstIn,
)
} }
.blur( .blur(
radiusX = 1.dp, radiusX = 1.dp,
radiusY = 3.dp)) { radiusY = 3.dp,
),
) {
content() content()
} }
} }
@ -102,12 +112,17 @@ private object HalfSizeShape : Shape {
override fun createOutline( override fun createOutline(
size: Size, size: Size,
layoutDirection: LayoutDirection, layoutDirection: LayoutDirection,
density: Density): Outline = Outline.Rectangle( density: Density,
): Outline = Outline.Rectangle(
Rect( Rect(
Offset( Offset(
0f, 0f,
size.height / 2), size.height / 2,
),
Size( Size(
size.width, size.width,
size.height))) size.height,
),
),
)
} }

View File

@ -17,11 +17,10 @@
package dev.krtirtho.spotube.core.ui.misc package dev.krtirtho.spotube.core.ui.misc
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.TextAutoSize
import com.composeunstyled.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import com.composeunstyled.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
@ -39,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import com.eygraber.compose.placeholder.material3.placeholder
import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.rememberShimmer
import com.valentinilk.shimmer.shimmer import com.valentinilk.shimmer.shimmer
@ -84,7 +84,11 @@ fun Modifier.shimmerApply(
): Modifier = composed { ): Modifier = composed {
val isTreeLoading = LocalSkeletonLoading.current val isTreeLoading = LocalSkeletonLoading.current
if (isTreeLoading) this.background(color) else this this.placeholder(
visible = isTreeLoading,
color = color,
shape = null,
)
} }
@Composable @Composable
@ -106,23 +110,23 @@ fun TextWithShimmer(
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1, minLines: Int = 1,
onTextLayout: ((TextLayoutResult) -> Unit)? = null, onTextLayout: ((TextLayoutResult) -> Unit)? = null,
style: TextStyle = LocalTextStyle.current) { style: TextStyle = LocalTextStyle.current,
// While a skeleton tree is loading, the placeholder glyphs must not be readable: ) {
// only the shimmer bars (from `shimmerApply`) should show through.
val isLoadingSkeleton = LocalSkeletonLoading.current
Text( Text(
text = text, text = text,
color = if (isLoadingSkeleton) Color.Transparent else color, color = color,
modifier = Modifier.shimmerApply().then(modifier), modifier = Modifier.shimmerApply().then(modifier),
autoSize = autoSize, autoSize = autoSize,
fontSize = fontSize, fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight, fontWeight = fontWeight,
fontFamily = fontFamily, fontFamily = fontFamily,
letterSpacing = letterSpacing, letterSpacing = letterSpacing,
textDecoration = textDecoration, textDecoration = textDecoration,
textAlign = textAlign ?: TextAlign.Start, textAlign = textAlign,
lineHeight = lineHeight, lineHeight = lineHeight,
overflow = overflow, overflow = overflow,
softWrap = softWrap,
maxLines = maxLines, maxLines = maxLines,
minLines = minLines, minLines = minLines,
onTextLayout = onTextLayout, onTextLayout = onTextLayout,

View File

@ -18,202 +18,39 @@
package dev.krtirtho.spotube.core.ui.theming package dev.krtirtho.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import com.materialkolor.rememberDynamicColorScheme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import org.spotube.millennium.composables.MillenniumTooltipHost
import org.spotube.millennium.theme.MillenniumColors
import org.spotube.millennium.theme.MillenniumPalette
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.MillenniumThemeConfig
/**
* Semantic color roles used across the app, resolved from the active Millennium palette.
*
* This is not a second palette: every role is derived from [MillenniumColors], so the
* whole app follows the selected Millennium accent family and light/dark appearance.
*/
@Immutable
data class AppColors(
val primary: Color,
val onPrimary: Color,
val primaryContainer: Color,
val onPrimaryContainer: Color,
val secondary: Color,
val onSecondary: Color,
val secondaryContainer: Color,
val onSecondaryContainer: Color,
val tertiaryContainer: Color,
val onTertiaryContainer: Color,
val background: Color,
val onBackground: Color,
val surface: Color,
val onSurface: Color,
val surfaceVariant: Color,
val onSurfaceVariant: Color,
val surfaceContainer: Color,
val surfaceContainerHigh: Color,
val surfaceContainerHighest: Color,
val inverseSurface: Color,
val inverseOnSurface: Color,
val outline: Color,
val outlineVariant: Color,
val error: Color,
val onError: Color,
val errorContainer: Color,
val onErrorContainer: Color,
val scrim: Color
)
/** Maps Millennium's semantic palette onto the app's role names. */
fun appColorsFrom(m: MillenniumColors): AppColors = AppColors(
primary = m.accent,
onPrimary = m.onAccent,
primaryContainer = m.raised,
onPrimaryContainer = m.text,
secondary = m.accent,
onSecondary = m.onAccent,
secondaryContainer = m.raised,
onSecondaryContainer = m.text,
tertiaryContainer = m.raised,
onTertiaryContainer = m.text,
background = m.background,
onBackground = m.text,
surface = m.surface,
onSurface = m.text,
surfaceVariant = m.inset,
onSurfaceVariant = m.muted,
surfaceContainer = m.surface,
surfaceContainerHigh = m.raised,
surfaceContainerHighest = m.raised,
inverseSurface = m.text,
inverseOnSurface = m.background,
outline = m.border,
outlineVariant = m.border,
error = m.danger,
onError = m.background,
errorContainer = m.danger.copy(alpha = 0.16f),
onErrorContainer = m.danger,
scrim = m.scrim
)
/** Named text styles used by the app, independent of Material typography. */
@Immutable
data class AppTypography(
val displaySmall: TextStyle,
val headlineLarge: TextStyle,
val headlineMedium: TextStyle,
val headlineSmall: TextStyle,
val titleLarge: TextStyle,
val titleMedium: TextStyle,
val titleSmall: TextStyle,
val bodyLarge: TextStyle,
val bodyMedium: TextStyle,
val bodySmall: TextStyle,
val labelLarge: TextStyle,
val labelMedium: TextStyle,
val labelSmall: TextStyle
)
private fun style(
size: Int,
lineHeight: Int,
weight: FontWeight = FontWeight.Normal
): TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = size.sp,
lineHeight = lineHeight.sp,
fontWeight = weight
)
val AppDefaultTypography = AppTypography(
displaySmall = style(36, 44),
headlineLarge = style(32, 40),
headlineMedium = style(28, 36),
headlineSmall = style(24, 32),
titleLarge = style(22, 28, FontWeight.Medium),
titleMedium = style(16, 24, FontWeight.Medium),
titleSmall = style(14, 20, FontWeight.Medium),
bodyLarge = style(16, 24),
bodyMedium = style(14, 20),
bodySmall = style(12, 16),
labelLarge = style(14, 20, FontWeight.Medium),
labelMedium = style(12, 16, FontWeight.Medium),
labelSmall = style(11, 16, FontWeight.Medium)
)
/** Corner shapes used by the app. */
@Immutable
data class AppShapes(
val small: Shape,
val medium: Shape,
val large: Shape
)
val AppDefaultShapes = AppShapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp)
)
val LocalAppColors =
staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) }
val LocalAppTypography = staticCompositionLocalOf { AppDefaultTypography }
val LocalAppShapes = staticCompositionLocalOf { AppDefaultShapes }
/** Reads the active app theme tokens, all backed by Millennium. */
object AppTheme {
val colors: AppColors @Composable get() = LocalAppColors.current
val typography: AppTypography @Composable get() = LocalAppTypography.current
val shapes: AppShapes @Composable get() = LocalAppShapes.current
}
/**
* The app's single source of truth for colour.
*
* Installs [MillenniumTheme] with the user's selected [UserSettings.palette] and light/dark
* [UserSettings.theme], then derives the app's semantic [AppColors], [AppTypography] and
* [AppShapes] tokens from the active Millennium theme.
*/
@Composable @Composable
fun SpotubeTheme( fun SpotubeTheme(
settings: UserSettings, settings: UserSettings,
content: @Composable () -> Unit content: @Composable () -> Unit,
) { ) {
val isDarkTheme = when (settings.theme) { val isDarkTheme = when (settings.theme) {
Theme.LIGHT -> false Theme.LIGHT -> false
Theme.DARK -> true Theme.DARK -> true
Theme.SYSTEM -> isSystemInDarkTheme() Theme.SYSTEM -> isSystemInDarkTheme()
} }
val palette: MillenniumPalette = settings.palette
MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) { val accent = if (isDarkTheme) {
val colors = appColorsFrom(MillenniumTheme.colors) settings.accentColor.toDarkColor()
MillenniumTooltipHost { } else {
CompositionLocalProvider( settings.accentColor.toLightColor()
LocalAppColors provides colors, }
LocalAppTypography provides AppDefaultTypography,
LocalAppShapes provides AppDefaultShapes, val colorScheme = rememberDynamicColorScheme(
content = content seedColor = accent,
) isDark = isDarkTheme,
} )
MaterialTheme(
colorScheme = colorScheme,
) {
Surface(content = content)
} }
} }
/** Whether the active Millennium appearance is light. */
@Composable
fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f

View File

@ -1,230 +1,221 @@
/* /*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import androidx.compose.foundation.BorderStroke
import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.layout.Arrangement
import org.spotube.millennium.composables.MillenniumIconButton import androidx.compose.foundation.layout.Column
import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.border import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.height import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.IconButton
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Scaffold
import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Surface
import androidx.compose.ui.draw.clip import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import com.composeunstyled.Text import androidx.compose.runtime.remember
import androidx.compose.runtime.Composable import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment
import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier
import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.Alignment import androidx.compose.ui.unit.dp
import androidx.compose.ui.Modifier import compose.icons.FeatherIcons
import androidx.compose.ui.graphics.Color import compose.icons.feathericons.ChevronLeft
import androidx.compose.ui.text.style.TextAlign import compose.icons.feathericons.ChevronRight
import androidx.compose.ui.unit.dp import compose.icons.feathericons.X
import compose.icons.FeatherIcons import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
import compose.icons.feathericons.ChevronLeft import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import compose.icons.feathericons.ChevronRight import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
import compose.icons.feathericons.X import dev.nucleusframework.webview.jsbridge.JsMessage
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.nucleusframework.webview.web.WebView
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler import dev.nucleusframework.webview.web.WebViewNavigator
import dev.nucleusframework.webview.jsbridge.JsMessage import dev.nucleusframework.webview.web.WebViewState
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge import dev.nucleusframework.webview.web.rememberWebViewNavigator
import dev.nucleusframework.webview.web.WebView
import dev.nucleusframework.webview.web.WebViewNavigator class PostMessageHandler(
import dev.nucleusframework.webview.web.WebViewState private val onMessageReceived: (String) -> Unit = {}
import dev.nucleusframework.webview.web.rememberWebViewNavigator ) : IJsMessageHandler {
override fun methodName(): String {
class PostMessageHandler( return "sendMessage"
private val onMessageReceived: (String) -> Unit = {} }
) : IJsMessageHandler {
override fun methodName(): String { override fun handle(
return "sendMessage" message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
} ) {
onMessageReceived(message.params)
override fun handle( callback(message.params)
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit }
) { }
onMessageReceived(message.params)
callback(message.params) @Composable
} fun PlatformWebViewScreen(webViewController: WebViewController) {
} if (webViewController.getContent() == null) {
// This should never happen, but just in case
@Composable Text("No URL to load")
fun PlatformWebViewScreen(webViewController: WebViewController) { return
if (webViewController.getContent() == null) { }
// This should never happen, but just in case
Text("No URL to load") val state = remember {
return WebViewState(
} webViewController.getWebContent(
additionalHttpHeaders = mapOf(
val state = remember { "User-Agent" to UserAgents.random()
WebViewState( )
webViewController.getWebContent( )
additionalHttpHeaders = mapOf( )
"User-Agent" to UserAgents.random() }.apply {
) this.content = webViewController.getWebContent()
) platformWebviewConfig(this, webViewController.currentPluginId)
) }
}.apply {
this.content = webViewController.getWebContent() val navigator = rememberWebViewNavigator()
platformWebviewConfig(this, webViewController.currentPluginId) val webViewBridge = rememberWebViewJsBridge(navigator)
}
val bridgeBootstrapScript = remember {
val navigator = rememberWebViewNavigator() """
val webViewBridge = rememberWebViewJsBridge(navigator) (function() {
if (typeof window.sendMessage !== "function") {
val bridgeBootstrapScript = remember { window.sendMessage = function(message) {
""" if (typeof message !== "string") {
(function() { throw new TypeError("[window.sendMessage] Message must be a string");
if (typeof window.sendMessage !== "function") { }
window.sendMessage = function(message) { window.kmpJsBridge.callNative("sendMessage", message);
if (typeof message !== "string") { };
throw new TypeError("[window.sendMessage] Message must be a string"); }
}
window.kmpJsBridge.callNative("sendMessage", message); if (!window.bridgeReady) {
}; const event = new CustomEvent("onBridgeReady");
} window.dispatchEvent(event);
window.bridgeReady = true;
if (!window.bridgeReady) { }
const event = new CustomEvent("onBridgeReady"); })();
window.dispatchEvent(event); """.trimIndent()
window.bridgeReady = true; }
}
})(); LaunchedEffect(state) {
""".trimIndent() snapshotFlow { state.lastLoadedUrl }.collect { url ->
} if (url != null) {
webViewController.emitUrlChange(url)
LaunchedEffect(state) { navigator.evaluateJavaScript(bridgeBootstrapScript)
snapshotFlow { state.lastLoadedUrl }.collect { url -> webViewController.emitWebViewCreated()
if (url != null) { }
webViewController.emitUrlChange(url) }
navigator.evaluateJavaScript(bridgeBootstrapScript) }
webViewController.emitWebViewCreated()
} LaunchedEffect(state.cookieManager, navigator) {
} webViewController.setCookieManager(cookieManager = state.cookieManager)
} webViewController.webViewNavigator = navigator
}
LaunchedEffect(state.cookieManager, navigator) {
webViewController.setCookieManager(cookieManager = state.cookieManager) LaunchedEffect(webViewBridge) {
webViewController.webViewNavigator = navigator webViewBridge.register(PostMessageHandler { message ->
} webViewController.emitPostMessage(message)
})
LaunchedEffect(webViewBridge) { }
webViewBridge.register(PostMessageHandler { message ->
webViewController.emitPostMessage(message) DisposableEffect(Unit) {
}) onDispose {
} webViewController.dispose()
}
DisposableEffect(Unit) { }
onDispose {
webViewController.dispose() Scaffold(
} contentWindowInsets = WindowInsets.statusBars,
} topBar = {
ApplicationMainBar(
Column(modifier = Modifier.fillMaxSize()) { title = {
ApplicationMainBar( Row(
title = { modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
Row( horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.SpaceBetween, ) {
verticalAlignment = Alignment.CenterVertically Row(
) { verticalAlignment = Alignment.CenterVertically,
Row( horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.height(56.dp)
horizontalArrangement = Arrangement.Start, ) {
modifier = Modifier.height(56.dp) IconButton(
) { onClick = {
MillenniumIconButton( navigator.navigateBack()
onClick = { }, enabled = navigator.canGoBack
navigator.navigateBack() ) {
}, Icon(
enabled = navigator.canGoBack, FeatherIcons.ChevronLeft,
style = MillenniumIconButtonStyle.Quiet contentDescription = "Go back to browser history"
) { )
MillenniumIcon( }
FeatherIcons.ChevronLeft, IconButton(
contentDescription = "Go back to browser history" onClick = {
) navigator.navigateForward()
} }, enabled = navigator.canGoForward
MillenniumIconButton( ) {
onClick = { Icon(
navigator.navigateForward() FeatherIcons.ChevronRight,
}, contentDescription = "Go forward to browser history"
enabled = navigator.canGoForward, )
style = MillenniumIconButtonStyle.Quiet }
) { }
MillenniumIcon( Surface(
FeatherIcons.ChevronRight, modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
contentDescription = "Go forward to browser history" shape = RoundedCornerShape(18.dp),
) color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
} border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
} ) {
Box( BasicTextField(
modifier = Modifier value = state.lastLoadedUrl ?: "",
.weight(1f) onValueChange = {}, // Read-only
.height(36.dp) readOnly = true,
.padding(horizontal = 4.dp) singleLine = true,
.clip(RoundedCornerShape(18.dp)) textStyle = MaterialTheme.typography.bodyMedium.copy(
.background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f)) color = MaterialTheme.colorScheme.onSurface,
.border( textAlign = TextAlign.Start
BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)), ),
RoundedCornerShape(18.dp)), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
contentAlignment = Alignment.CenterStart .wrapContentHeight(Alignment.CenterVertically)
) { )
BasicTextField( }
value = state.lastLoadedUrl ?: "", }
onValueChange = {}, // Read-only }
readOnly = true, )
singleLine = true, })
textStyle = AppTheme.typography.bodyMedium.copy( { innerPadding ->
color = AppTheme.colors.onSurface, WebView(
textAlign = TextAlign.Start state = state,
), modifier = Modifier.padding(innerPadding).fillMaxSize(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) navigator = navigator,
.wrapContentHeight(Alignment.CenterVertically) webViewJsBridge = webViewBridge,
) onCreated = { webView ->
} navigator.evaluateJavaScript(bridgeBootstrapScript)
} },
} )
) }
WebView(
state = state,
modifier = Modifier.weight(1f).fillMaxWidth(),
navigator = navigator,
webViewJsBridge = webViewBridge,
onCreated = { webView ->
navigator.evaluateJavaScript(bridgeBootstrapScript)
})
}
} }

View File

@ -38,7 +38,8 @@ import org.koin.core.component.KoinComponent
import kotlin.reflect.KClass import kotlin.reflect.KClass
class BuiltInPluginService( class BuiltInPluginService(
private val pluginInfo: PluginEntry) : PluginService, KoinComponent { private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent {
override val pluginId: String = pluginInfo.id override val pluginId: String = pluginInfo.id
private val loggedInStateFlow = MutableStateFlow(false) private val loggedInStateFlow = MutableStateFlow(false)
@ -57,6 +58,7 @@ class BuiltInPluginService(
} }
} }
override suspend fun start() { override suspend fun start() {
when (pluginInfo) { when (pluginInfo) {
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> { NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {

View File

@ -1,344 +1,347 @@
/* /*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.core.zipline package dev.krtirtho.spotube.core.zipline
import app.cash.zipline.Zipline import app.cash.zipline.Zipline
import app.cash.zipline.ZiplineService import app.cash.zipline.ZiplineService
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
import app.cash.zipline.loader.LoadResult import app.cash.zipline.loader.LoadResult
import app.cash.zipline.loader.ManifestVerifier import app.cash.zipline.loader.ManifestVerifier
import app.cash.zipline.loader.ZiplineLoader import app.cash.zipline.loader.ZiplineLoader
import dev.krtirtho.plugin_interfaces.core.Initializer import dev.krtirtho.plugin_interfaces.core.Initializer
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
import dev.krtirtho.spotube.modules.plugin.PluginAbility import dev.krtirtho.spotube.modules.plugin.PluginAbility
import dev.krtirtho.spotube.modules.plugin.PluginCapability import dev.krtirtho.spotube.modules.plugin.PluginCapability
import dev.krtirtho.spotube.modules.plugin.PluginEntry import dev.krtirtho.spotube.modules.plugin.PluginEntry
import io.ktor.http.URLBuilder import io.ktor.http.URLBuilder
import io.ktor.http.decodeURLQueryComponent import io.ktor.http.decodeURLQueryComponent
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.Path.Companion.toPath import okio.Path.Companion.toPath
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import kotlin.reflect.KClass import kotlin.reflect.KClass
/**
* Manages the lifecycle of a Zipline plugin. /**
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements. * Manages the lifecycle of a Zipline plugin.
* Anything it provides, must be called within that dispatcher context. * It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
* The [use] function must be used. * Anything it provides, must be called within that dispatcher context.
* * The [use] function must be used.
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well, *
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in * The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows. * so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
* * `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called. *
*/ * The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
open class ZiplinePluginService( */
val applicationName: String, open class ZiplinePluginService(
private val manifestUrl: String, val applicationName: String,
private val pluginInfo: PluginEntry) : PluginService, KoinComponent { private val manifestUrl: String,
private val pluginInfo: PluginEntry,
override val pluginId: String = pluginInfo.id ) : PluginService, KoinComponent {
// QuickJS compile() uses deep C-level recursion on the native thread stack. override val pluginId: String = pluginInfo.id
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing. // QuickJS compile() uses deep C-level recursion on the native thread stack.
// We use a custom EventListener to increase maxStackSize right after the Zipline // Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
// instance is created, before any modules are loaded. // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
private val ziplineDispatcher = createZiplineDispatcher() // We use a custom EventListener to increase maxStackSize right after the Zipline
// instance is created, before any modules are loaded.
private fun trace(event: String) { private val ziplineDispatcher = createZiplineDispatcher()
logger.d { "[$applicationName] $event" }
} private fun trace(event: String) {
logger.d { "[$applicationName] $event" }
private val logger by injectLogger<ZiplinePluginService>() }
private val webViewController: WebViewController by inject()
private val logger by injectLogger<ZiplinePluginService>()
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher) private val webViewController: WebViewController by inject()
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
logger.e(throwable) { "Zipline Engine Error" } private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
} private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
private val lifecycleMutex = Mutex() logger.e(throwable) { "Zipline Engine Error" }
private var ziplineLoader: ZiplineLoader }
private var ziplineInstance: Zipline? = null private val lifecycleMutex = Mutex()
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>() private var ziplineLoader: ZiplineLoader
private var ziplineInstance: Zipline? = null
init { private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
val manifestPath = URLBuilder(manifestUrl)
val baseDir = init {
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent val manifestPath = URLBuilder(manifestUrl)
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.") val baseDir =
ziplineLoader = ZiplineLoader( manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
dispatcher = ziplineDispatcher.dispatcher, ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, ziplineLoader = ZiplineLoader(
httpClient = FileSystemHTTPClient(baseDir) dispatcher = ziplineDispatcher.dispatcher,
) manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
} httpClient = FileSystemHTTPClient(baseDir)
)
private val realHttpClientAPI = RealHttpClientAPI() }
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
private val realHttpClientAPI = RealHttpClientAPI()
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo) private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
private val systemInformationAPI = RealSystemInformationAPI() private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
private val loggedInStateFlow = MutableStateFlow(false) private val systemInformationAPI = RealSystemInformationAPI()
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
private val loggedInStateFlow = MutableStateFlow(false)
private fun bindHostServices(zipline: Zipline) { override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
trace("initializer(): binding host APIs")
logger.d { "[$applicationName] Binding host APIs in initializer" } private fun bindHostServices(zipline: Zipline) {
try { trace("initializer(): binding host APIs")
// Basic APIs logger.d { "[$applicationName] Binding host APIs in initializer" }
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI) try {
zipline.bind<SystemInformationAPI>( // Basic APIs
SystemInformationAPI_SERVICE_NAME, zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
systemInformationAPI zipline.bind<SystemInformationAPI>(
) SystemInformationAPI_SERVICE_NAME,
systemInformationAPI
// Conditional APIs based on plugin capabilities )
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
zipline.bind<HttpClientAPI>( // Conditional APIs based on plugin capabilities
HttpClientAPI_SERVICE_NAME, if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
realHttpClientAPI zipline.bind<HttpClientAPI>(
) HttpClientAPI_SERVICE_NAME,
} realHttpClientAPI
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { )
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI) }
} if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
zipline.bind<PersistedStorageAPI>( }
PersistedStorageAPI_SERVICE_NAME, if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
persistedStorageAPI zipline.bind<PersistedStorageAPI>(
) PersistedStorageAPI_SERVICE_NAME,
} persistedStorageAPI
} catch (e: Exception) { )
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" } }
throw e } catch (e: Exception) {
} logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
} throw e
}
private fun consumePluginServices(result: LoadResult.Success) { }
trace("start(): loadOnce success")
val apiMap = private fun consumePluginServices(result: LoadResult.Success) {
buildMap<KClass<*>, ZiplineService> { trace("start(): loadOnce success")
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME)) val apiMap =
buildMap<KClass<*>, ZiplineService> {
if (PluginAbility.METADATA in pluginInfo.abilities) { put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
put(
MetadataUserAPI::class, if (PluginAbility.METADATA in pluginInfo.abilities) {
result.zipline.take<MetadataUserAPI>( put(
MetadataUserAPI_SERVICE_NAME MetadataUserAPI::class,
) result.zipline.take<MetadataUserAPI>(
) MetadataUserAPI_SERVICE_NAME
put( )
MetadataTrackAPI::class, )
result.zipline.take<MetadataTrackAPI>( put(
MetadataTrackAPI_SERVICE_NAME MetadataTrackAPI::class,
) result.zipline.take<MetadataTrackAPI>(
) MetadataTrackAPI_SERVICE_NAME
put( )
MetadataAlbumAPI::class, )
result.zipline.take<MetadataAlbumAPI>( put(
MetadataAlbumAPI_SERVICE_NAME MetadataAlbumAPI::class,
) result.zipline.take<MetadataAlbumAPI>(
) MetadataAlbumAPI_SERVICE_NAME
put( )
MetadataArtistAPI::class, )
result.zipline.take<MetadataArtistAPI>( put(
MetadataArtistAPI_SERVICE_NAME MetadataArtistAPI::class,
) result.zipline.take<MetadataArtistAPI>(
) MetadataArtistAPI_SERVICE_NAME
put( )
MetadataPlaylistAPI::class, )
result.zipline.take<MetadataPlaylistAPI>( put(
MetadataPlaylistAPI_SERVICE_NAME MetadataPlaylistAPI::class,
) result.zipline.take<MetadataPlaylistAPI>(
) MetadataPlaylistAPI_SERVICE_NAME
put( )
MetadataBrowseAPI::class, )
result.zipline.take<MetadataBrowseAPI>( put(
MetadataBrowseAPI_SERVICE_NAME MetadataBrowseAPI::class,
) result.zipline.take<MetadataBrowseAPI>(
) MetadataBrowseAPI_SERVICE_NAME
put( )
MetadataSearchAPI::class, )
result.zipline.take<MetadataSearchAPI>( put(
MetadataSearchAPI_SERVICE_NAME MetadataSearchAPI::class,
) result.zipline.take<MetadataSearchAPI>(
) MetadataSearchAPI_SERVICE_NAME
} )
if (PluginAbility.AUDIO in pluginInfo.abilities) { )
put( }
AudioAPI::class, if (PluginAbility.AUDIO in pluginInfo.abilities) {
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME) put(
) AudioAPI::class,
} result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
if (PluginAbility.LYRICS in pluginInfo.abilities) { )
put( }
LyricsAPI::class, if (PluginAbility.LYRICS in pluginInfo.abilities) {
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME) put(
) LyricsAPI::class,
} result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
if (PluginAbility.SCROBBLE in pluginInfo.abilities) { )
put( }
ScrobbleAPI::class, if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME) put(
) ScrobbleAPI::class,
} result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
} )
}
serviceRegistry.putAll(apiMap) }
trace("start(): API ready")
} serviceRegistry.putAll(apiMap)
trace("start(): API ready")
private fun runLogInFlowObservers() = scope.launch { }
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
coreAPI.loggedInFlow.collect { isLoggedIn -> private fun runLogInFlowObservers() = scope.launch {
loggedInStateFlow.value = isLoggedIn val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
} coreAPI.loggedInFlow.collect { isLoggedIn ->
} loggedInStateFlow.value = isLoggedIn
}
override suspend fun start() { }
lifecycleMutex.withLock {
trace("start(): entered") override suspend fun start() {
if (serviceRegistry.isNotEmpty()) { lifecycleMutex.withLock {
trace("start(): already started, skipping") trace("start(): entered")
return if (serviceRegistry.isNotEmpty()) {
} trace("start(): already started, skipping")
return
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" } }
withContext(ziplineDispatcher.dispatcher) {
trace("start(): inside zipline dispatcher before loadOnce") logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
val result = ziplineLoader.loadOnce( withContext(ziplineDispatcher.dispatcher) {
applicationName = applicationName, trace("start(): inside zipline dispatcher before loadOnce")
manifestUrl = manifestUrl, val result = ziplineLoader.loadOnce(
freshnessChecker = DefaultFreshnessCheckerNotFresh) applicationName = applicationName,
when (result) { manifestUrl = manifestUrl,
is LoadResult.Success -> { freshnessChecker = DefaultFreshnessCheckerNotFresh,
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } )
ziplineInstance = result.zipline when (result) {
// Now we consume the initializer is LoadResult.Success -> {
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME) logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
// Bind host services before initialization, so plugins can use them in their initializer ziplineInstance = result.zipline
bindHostServices(result.zipline) // Now we consume the initializer
trace("start(): calling initializer.initialize()") val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
runCatching { initializer.initialize() } // Bind host services before initialization, so plugins can use them in their initializer
.onSuccess { bindHostServices(result.zipline)
consumePluginServices(result) trace("start(): calling initializer.initialize()")
runLogInFlowObservers() runCatching { initializer.initialize() }
} .onSuccess {
.onFailure { e -> consumePluginServices(result)
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" } runLogInFlowObservers()
throw e }
} .onFailure { e ->
} logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
throw e
is LoadResult.Failure -> { }
trace("start(): loadOnce failure: ${result.exception}") }
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
throw result.exception is LoadResult.Failure -> {
} trace("start(): loadOnce failure: ${result.exception}")
} logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
} throw result.exception
}
} }
} }
override suspend fun stop() { }
lifecycleMutex.withLock { }
trace("stop(): entered")
withContext(ziplineDispatcher.dispatcher) { override suspend fun stop() {
ziplineInstance?.close() lifecycleMutex.withLock {
ziplineInstance = null trace("stop(): entered")
for (service in serviceRegistry.values) { withContext(ziplineDispatcher.dispatcher) {
try { ziplineInstance?.close()
trace("stop(): closing service ${service::class.simpleName}") ziplineInstance = null
service.close() for (service in serviceRegistry.values) {
} catch (_: Exception) { try {
trace("stop(): error closing service ${service::class.simpleName}") trace("stop(): closing service ${service::class.simpleName}")
} service.close()
} } catch (_: Exception) {
} trace("stop(): error closing service ${service::class.simpleName}")
serviceRegistry.clear() }
scope.cancel() }
loggedInStateFlow.value = false }
ziplineDispatcher.close() serviceRegistry.clear()
trace("stop(): completed") scope.cancel()
} loggedInStateFlow.value = false
} ziplineDispatcher.close()
trace("stop(): completed")
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T { }
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) { }
// Create the scope with the current registry
val scope = PluginServiceScope(serviceRegistry) override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
// Execute the block with 'scope' as 'this' return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
scope.block() // Create the scope with the current registry
} val scope = PluginServiceScope(serviceRegistry)
} // Execute the block with 'scope' as 'this'
scope.block()
}
}
} }

View File

@ -185,6 +185,7 @@ fun AESEncodingFormats.format(): AES.Key.Format {
} }
} }
class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
private val provider = CryptographyProvider.Default private val provider = CryptographyProvider.Default
@ -192,6 +193,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
return CryptographyRandom.nextBytes(size) return CryptographyRandom.nextBytes(size)
} }
override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray { override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray {
val hasher = provider.get(algorithm.algorithm()).hasher() val hasher = provider.get(algorithm.algorithm()).hasher()
return hasher.hash(data) return hasher.hash(data)
@ -299,7 +301,8 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
val keyPair = provider.get(RSA.PSS).keyPairGenerator( val keyPair = provider.get(RSA.PSS).keyPairGenerator(
algorithm.keySizeBits.bits, algorithm.keySizeBits.bits,
algorithm.hashAlgorithm.algorithm(), algorithm.hashAlgorithm.algorithm(),
algorithm.publicExponent.toBigInt()).generateKey() algorithm.publicExponent.toBigInt(),
).generateKey()
Pair( Pair(
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()), keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
@ -363,7 +366,8 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
is SignAlgorithms.ECDSA -> is SignAlgorithms.ECDSA ->
provider.get(ECDSA).privateKeyDecoder( provider.get(ECDSA).privateKeyDecoder(
algorithm.curve.curve()).decodeFromByteArray( algorithm.curve.curve(),
).decodeFromByteArray(
algorithm.format.privateKeyFormat(), algorithm.format.privateKeyFormat(),
privateKey privateKey
).signatureGenerator( ).signatureGenerator(
@ -373,7 +377,8 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
is SignAlgorithms.EdDSA -> is SignAlgorithms.EdDSA ->
provider.get(EdDSA).privateKeyDecoder( provider.get(EdDSA).privateKeyDecoder(
algorithm.curve.curve()).decodeFromByteArray( algorithm.curve.curve(),
).decodeFromByteArray(
algorithm.format.privateKeyFormat(), algorithm.format.privateKeyFormat(),
privateKey privateKey
).signatureGenerator().generateSignature(data) ).signatureGenerator().generateSignature(data)
@ -417,7 +422,8 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
is SignAlgorithms.ECDSA -> is SignAlgorithms.ECDSA ->
provider.get(ECDSA).publicKeyDecoder( provider.get(ECDSA).publicKeyDecoder(
algorithm.curve.curve()).decodeFromByteArray( algorithm.curve.curve(),
).decodeFromByteArray(
algorithm.format.publicKeyFormat(), algorithm.format.publicKeyFormat(),
publicKey publicKey
).signatureVerifier( ).signatureVerifier(
@ -430,7 +436,8 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
is SignAlgorithms.EdDSA -> is SignAlgorithms.EdDSA ->
provider.get(EdDSA).publicKeyDecoder( provider.get(EdDSA).publicKeyDecoder(
algorithm.curve.curve()).decodeFromByteArray( algorithm.curve.curve(),
).decodeFromByteArray(
algorithm.format.publicKeyFormat(), algorithm.format.publicKeyFormat(),
publicKey publicKey
).signatureVerifier().verifySignature( ).signatureVerifier().verifySignature(

View File

@ -28,6 +28,7 @@ import io.ktor.client.statement.bodyAsText
import io.ktor.http.takeFrom import io.ktor.http.takeFrom
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
class RealHttpClientAPI : HttpClientAPI, KoinComponent { class RealHttpClientAPI : HttpClientAPI, KoinComponent {
val logger by injectLogger<RealHttpClientAPI>() val logger by injectLogger<RealHttpClientAPI>()
val httpClient = HttpClient() val httpClient = HttpClient()

View File

@ -1,73 +1,74 @@
/* /*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.core.zipline.host_apis package dev.krtirtho.spotube.core.zipline.host_apis
import dev.krtirtho.plugin_interfaces.host_apis.Cookie import dev.krtirtho.plugin_interfaces.host_apis.Cookie
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.core.webview.WebViewController
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class RealWebViewAPI( class RealWebViewAPI(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val webViewController: WebViewController, private val webViewController: WebViewController,
private val pluginId: String) : WebViewAPI { private val pluginId: String,
override fun navigateTo(url: String) { ) : WebViewAPI {
scope.launch(Dispatchers.Main) { override fun navigateTo(url: String) {
webViewController.navigateTo(url, pluginId) scope.launch(Dispatchers.Main) {
} webViewController.navigateTo(url, pluginId)
} }
}
override fun navigateToHTML(html: String) {
scope.launch { override fun navigateToHTML(html: String) {
webViewController.navigateToHTML(html, pluginId) scope.launch {
} webViewController.navigateToHTML(html, pluginId)
} }
}
override suspend fun getCookies(url: String): List<Cookie> {
return withContext(Dispatchers.Main) { override suspend fun getCookies(url: String): List<Cookie> {
webViewController.getCookies(url) return withContext(Dispatchers.Main) {
} webViewController.getCookies(url)
} }
}
override suspend fun evaluateJavaScript(script: String): String? {
return withContext(Dispatchers.Main) { override suspend fun evaluateJavaScript(script: String): String? {
webViewController.evaluateJavascript(script) return withContext(Dispatchers.Main) {
} webViewController.evaluateJavascript(script)
} }
}
override fun urlChangeFlow(): Flow<String> {
return webViewController.urlChangedFlow override fun urlChangeFlow(): Flow<String> {
} return webViewController.urlChangedFlow
override fun webviewCreatedFlow(): Flow<Unit> { }
return webViewController.webviewCreatedFlow override fun webviewCreatedFlow(): Flow<Unit> {
} return webViewController.webviewCreatedFlow
override fun postMessagesFlow(): Flow<String> { }
return webViewController.postMessagesFlow override fun postMessagesFlow(): Flow<String> {
} return webViewController.postMessagesFlow
}
override fun exitWebView() {
scope.launch(Dispatchers.Main) { override fun exitWebView() {
webViewController.closeWebview() scope.launch(Dispatchers.Main) {
} webViewController.closeWebview()
} }
}
} }

View File

@ -45,7 +45,8 @@ data class LRCLibResponse(
val artistName: String, val artistName: String,
val albumName: String, val albumName: String,
val plainLyrics: String?, val plainLyrics: String?,
val syncedLyrics: String?) val syncedLyrics: String?,
)
class RealLRCLibLyricsAPI : LyricsAPI { class RealLRCLibLyricsAPI : LyricsAPI {
private val httpClient = HttpClient { private val httpClient = HttpClient {
@ -94,6 +95,7 @@ class RealLRCLibLyricsAPI : LyricsAPI {
val text = line.substring(match.range.last + 1).trim() val text = line.substring(match.range.last + 1).trim()
LyricsLine(time = timeMs, text = text) LyricsLine(time = timeMs, text = text)
}, },
plainLyrics = response.plainLyrics) plainLyrics = response.plainLyrics,
)
} }
} }

View File

@ -124,14 +124,16 @@ data class MusicGatewayResponse(
val externalIds: ExternalIds, val externalIds: ExternalIds,
val id: String, val id: String,
val name: String, val name: String,
val popularity: Int) { val popularity: Int,
) {
@Serializable @Serializable
data class Album( data class Album(
@SerialName("album_type") @SerialName("album_type")
val albumType: String, val albumType: String,
val artists: List<Artist>, val artists: List<Artist>,
val id: String, val id: String,
val name: String) val name: String,
)
@Serializable @Serializable
data class Artist( data class Artist(

View File

@ -57,6 +57,7 @@ class ISRCProviders {
} }
} }
private var ifpiSessionToken: String? = null private var ifpiSessionToken: String? = null
private val ifpiDefaultHeaders = mapOf( private val ifpiDefaultHeaders = mapOf(
"Content-Type" to "application/json", "Content-Type" to "application/json",
@ -75,6 +76,7 @@ class ISRCProviders {
"User-Agent" to UserAgents.random() "User-Agent" to UserAgents.random()
) )
private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) { private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) {
if (ifpiSessionToken != null) return@withContext ifpiSessionToken if (ifpiSessionToken != null) return@withContext ifpiSessionToken

View File

@ -65,10 +65,12 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
Thumbnail( Thumbnail(
url = video.thumbnailUrl, url = video.thumbnailUrl,
width = 300, width = 300,
height = 300) height = 300,
)
), ),
externalUri = "https://www.youtube.com/watch?v=${video.id}", externalUri = "https://www.youtube.com/watch?v=${video.id}",
confidence = confidence) confidence = confidence,
)
} }
override val supportedQualities: List<AudioFormat> = listOf( override val supportedQualities: List<AudioFormat> = listOf(
@ -77,13 +79,15 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000)) AudioQuality.Lossy(bitrate = 256_000),
)
), AudioFormat( ), AudioFormat(
codec = "aac", container = "mp4", qualities = listOf( codec = "aac", container = "mp4", qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000)) AudioQuality.Lossy(bitrate = 256_000),
)
) )
) )
@ -94,6 +98,7 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
)) ))
val isYouTubeID = track.id.matches(youtubeIDRegex) val isYouTubeID = track.id.matches(youtubeIDRegex)
logger.i { logger.i {
"Searching streams for track(${track.id}): ${track.title} by ${ "Searching streams for track(${track.id}): ${track.title} by ${
track.artists.joinToString( track.artists.joinToString(
@ -114,11 +119,13 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
Thumbnail( Thumbnail(
url = videoInfo.thumbnailUrl, url = videoInfo.thumbnailUrl,
width = 300, width = 300,
height = 300) height = 300,
)
), ),
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
confidence = 1.0f, confidence = 1.0f,
streams = videoInfo.audioStreams) streams = videoInfo.audioStreams,
)
) )
} }
} }
@ -158,7 +165,8 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
Thumbnail( Thumbnail(
url = videoInfo.thumbnailUrl, url = videoInfo.thumbnailUrl,
width = 300, width = 300,
height = 300) height = 300,
)
), ),
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
confidence = source.confidence, confidence = source.confidence,

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