Compare commits
15 Commits
1421e8d452
...
937edf8dfc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
937edf8dfc | ||
|
|
caad76ba95 | ||
|
|
bb9f225fd7 | ||
|
|
cfd472016e | ||
|
|
963b360a81 | ||
|
|
9f96b07ebd | ||
|
|
4da5b25a5c | ||
|
|
fa9ad9981c | ||
|
|
5c0652b395 | ||
|
|
71d7649db6 | ||
|
|
fab60107e3 | ||
|
|
eeaff3c1db | ||
|
|
0def3e2e49 | ||
|
|
4e2afeaea4 | ||
|
|
eb30f98056 |
10
.github/workflows/spotube-release-binary.yml
vendored
@ -106,7 +106,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -168,7 +168,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -290,7 +290,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -336,7 +336,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -408,7 +408,7 @@ jobs:
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
||||
|
||||
## UI component patterns
|
||||
- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `<string name="...">` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen.
|
||||
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
|
||||
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
|
||||
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 47 KiB |
@ -119,7 +119,6 @@ kotlin {
|
||||
// Jetpack Compose dependencies
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.ktor3)
|
||||
@ -132,6 +131,9 @@ kotlin {
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.dns.sd.kt)
|
||||
|
||||
// Millennium UI
|
||||
implementation(libs.millennium)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||
@ -164,12 +166,6 @@ kotlin {
|
||||
// icons
|
||||
implementation(libs.feather.icons)
|
||||
|
||||
// Material3 adaptive
|
||||
implementation(libs.jetbrains.material3.adaptive)
|
||||
implementation(libs.jetbrains.material3.adaptive.layout)
|
||||
implementation(libs.jetbrains.material3.adaptive.suite)
|
||||
implementation(libs.jetbrains.material3.adaptiveNavigation3)
|
||||
implementation(libs.jetbrains.material3.window.size)
|
||||
// androidx-datastore
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
@ -184,8 +180,6 @@ kotlin {
|
||||
|
||||
implementation(libs.semver)
|
||||
|
||||
implementation(libs.material.kolor)
|
||||
|
||||
// crypto
|
||||
implementation(libs.cryptography.core)
|
||||
implementation(libs.cryptography.provider.optimal)
|
||||
@ -206,7 +200,6 @@ kotlin {
|
||||
|
||||
// Shimmer effect
|
||||
implementation(libs.compose.shimmer)
|
||||
implementation(libs.compose.placeholder.material3)
|
||||
|
||||
// DLNA
|
||||
implementation(libs.dns.sd.kt)
|
||||
|
||||
@ -26,7 +26,6 @@ import org.schabi.newpipe.extractor.ServiceList.YouTube
|
||||
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
|
||||
actual class NewPipeService {
|
||||
actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
@ -47,8 +46,7 @@ actual class NewPipeService {
|
||||
url = item.url,
|
||||
uploader = item.uploaderName,
|
||||
durationMs = item.duration,
|
||||
thumbnailUrl = item.thumbnails.first().url,
|
||||
)
|
||||
thumbnailUrl = item.thumbnails.first().url)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@ -67,8 +65,7 @@ actual class NewPipeService {
|
||||
url = stream.content,
|
||||
codec = stream.codec,
|
||||
container = stream.format!!.suffix,
|
||||
bitrate = stream.bitrate,
|
||||
)
|
||||
bitrate = stream.bitrate)
|
||||
} + buildList {
|
||||
if (extractor.dashMpdUrl.isNotBlank()) add(
|
||||
AudioStream.Lossy(
|
||||
@ -90,7 +87,6 @@ actual class NewPipeService {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val videoStreams = extractor.videoStreams
|
||||
.filter { stream -> stream.isUrl && stream.format != null }
|
||||
.map { stream ->
|
||||
@ -98,8 +94,7 @@ actual class NewPipeService {
|
||||
url = stream.content,
|
||||
codec = stream.codec,
|
||||
bitrate = stream.bitrate,
|
||||
container = stream.format!!.suffix,
|
||||
)
|
||||
container = stream.format!!.suffix)
|
||||
}
|
||||
VideoInfo(
|
||||
id = id,
|
||||
|
||||
@ -45,7 +45,6 @@ actual val platformModules = module {
|
||||
collectionPlaybackHelper = get(),
|
||||
audioPlayerQueue = get(),
|
||||
settingsRepository = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
blacklistRepository = get())
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,14 +35,12 @@ import androidx.core.content.ContextCompat
|
||||
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* result is picked up by discovery/advertising retry loops */ }
|
||||
ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ }
|
||||
|
||||
return {
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) {
|
||||
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
}
|
||||
|
||||
@ -53,8 +53,7 @@ class MediaBrowseHelper(
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) {
|
||||
private val blacklistRepository: BlacklistRepository) {
|
||||
|
||||
companion object {
|
||||
const val MEDIA_ID_ROOT = "root"
|
||||
@ -253,8 +252,7 @@ class MediaBrowseHelper(
|
||||
.setIsPlayable(false)
|
||||
.build()
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build())
|
||||
}
|
||||
|
||||
private suspend fun buildBrowseItems(): List<MediaItem> {
|
||||
@ -390,8 +388,7 @@ class MediaBrowseHelper(
|
||||
.setIsBrowsable(true)
|
||||
.build()
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build())
|
||||
}
|
||||
|
||||
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
||||
@ -475,8 +472,7 @@ class MediaBrowseHelper(
|
||||
.setIsPlayable(false)
|
||||
.build()
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build())
|
||||
}
|
||||
|
||||
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
||||
|
||||
@ -32,8 +32,7 @@ import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
class AndroidLocalMediaDiscoveryService(
|
||||
private val context: Context,
|
||||
) : LocalMediaDiscoveryService, KoinComponent {
|
||||
private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
|
||||
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
||||
|
||||
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
|
||||
@ -59,8 +58,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
MediaStore.Audio.Media.ARTIST,
|
||||
MediaStore.Audio.Media.ALBUM,
|
||||
MediaStore.Audio.Media.DURATION,
|
||||
MediaStore.Audio.Media.MIME_TYPE,
|
||||
) + if (Build.VERSION.SDK_INT >= 29) {
|
||||
MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) {
|
||||
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
||||
} else {
|
||||
emptyArray()
|
||||
@ -131,8 +129,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
artists = listOfNotNull(artist),
|
||||
durationMs = durationMs,
|
||||
album = album,
|
||||
coverBytes = null,
|
||||
)
|
||||
coverBytes = null)
|
||||
|
||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
||||
acceptedRows += 1
|
||||
@ -150,8 +147,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
LocalMediaFolder(
|
||||
path = folderPath,
|
||||
name = normalizeFolderName(folderPath),
|
||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
||||
)
|
||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||
}
|
||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||
|
||||
@ -172,8 +168,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
|
||||
override fun observeChanges(
|
||||
roots: List<String>,
|
||||
onChanged: LocalMediaChangeCallback,
|
||||
): LocalMediaObservation? {
|
||||
onChanged: LocalMediaChangeCallback): LocalMediaObservation? {
|
||||
logger.i { "observeChanges: start roots=${roots.size}" }
|
||||
if (!hasReadPermission()) {
|
||||
logger.w { "observeChanges: missing media permission, observer not registered" }
|
||||
@ -190,8 +185,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
context.contentResolver.registerContentObserver(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
true,
|
||||
observer,
|
||||
)
|
||||
observer)
|
||||
|
||||
logger.i { "observeChanges: observer registered" }
|
||||
|
||||
@ -257,8 +251,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
artists = emptyList(),
|
||||
durationMs = 0L,
|
||||
album = null,
|
||||
coverBytes = null,
|
||||
)
|
||||
coverBytes = null)
|
||||
|
||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
||||
}
|
||||
@ -269,8 +262,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
LocalMediaFolder(
|
||||
path = folderPath,
|
||||
name = normalizeFolderName(folderPath),
|
||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
||||
)
|
||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||
}
|
||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||
|
||||
@ -292,8 +284,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||
)
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS))
|
||||
|
||||
return (configured + defaults)
|
||||
.map { it.absoluteFile }
|
||||
@ -320,8 +311,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
|
||||
private fun mergeFolders(
|
||||
mediaStoreFolders: List<LocalMediaFolder>,
|
||||
fallbackFolders: List<LocalMediaFolder>,
|
||||
): List<LocalMediaFolder> {
|
||||
fallbackFolders: List<LocalMediaFolder>): List<LocalMediaFolder> {
|
||||
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
||||
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
||||
|
||||
@ -342,8 +332,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
LocalMediaFolder(
|
||||
path = path,
|
||||
name = normalizeFolderName(path),
|
||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
||||
)
|
||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||
}
|
||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||
}
|
||||
@ -357,7 +346,6 @@ class AndroidLocalMediaDiscoveryService(
|
||||
"wav",
|
||||
"ogg",
|
||||
"opus",
|
||||
"wma",
|
||||
)
|
||||
"wma")
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,19 +42,16 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState {
|
||||
|
||||
var isGranted by remember {
|
||||
mutableStateOf(
|
||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
contract = ActivityResultContracts.RequestPermission()) { granted ->
|
||||
isGranted = granted ||
|
||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
return LocalMediaPermissionState(
|
||||
isGranted = isGranted,
|
||||
requestPermission = { launcher.launch(permission) },
|
||||
)
|
||||
requestPermission = { launcher.launch(permission) })
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
|
||||
<g id="Alapkör">
|
||||
<circle id="XMLID_23_" fill="#CD201F" cx="50" cy="50" r="50"/>
|
||||
</g>
|
||||
<g id="Elemek">
|
||||
<path id="XMLID_19_" fill="#FFFFFF" d="M47,28.2c-9-5.3-15.3-9-15.3-9v61.7c0,0,30.4-18,52.3-30.9C72.1,43,57.7,34.5,47,28.2z"/>
|
||||
</g>
|
||||
<g id="Fedő">
|
||||
<path id="XMLID_5_" fill="#CD201F" d="M48.4,40.1c-4.1-2.4-7-4.1-7-4.1V64c0,0,13.9-8.2,23.8-14C59.8,46.8,53.3,42.9,48.4,40.1z"/>
|
||||
<rect id="XMLID_4_" x="41.4" y="55.6" fill="#CD201F" width="6.2" height="21"/>
|
||||
</g>
|
||||
<g id="Vonalak">
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 787 B |
|
After Width: | Height: | Size: 144 KiB |
@ -16,16 +16,22 @@
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<string name="queue_title">Queue</string>
|
||||
<string name="queue_clear_queue">Clear Queue</string>
|
||||
<string name="queue_filter_queue">Filter queue...</string>
|
||||
<string name="queue_no_queue_entries">No queue entries</string>
|
||||
|
||||
<string name="settings_screen_title">Settings</string>
|
||||
|
||||
<string name="settings_section_plugins">Plugins</string>
|
||||
<string name="settings_section_language_region">Language and Region</string>
|
||||
<string name="settings_section_appearance">Appearance</string>
|
||||
<string name="settings_section_playback">Playback</string>
|
||||
<string name="settings_section_downloads">Downloads</string>
|
||||
<string name="settings_section_downloads">Downloads</string>f
|
||||
<string name="settings_section_caching">Caching</string>
|
||||
<string name="settings_section_desktop">Desktop</string>
|
||||
<string name="settings_section_updates">Updates</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
|
||||
<string name="settings_action_close">Close</string>
|
||||
<string name="settings_action_save">Save</string>
|
||||
@ -43,6 +49,14 @@
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_theme_system">System</string>
|
||||
|
||||
<string name="settings_surface_theme_title">Surface</string>
|
||||
<string name="settings_surface_theme_subtitle_current">Background material of the app. Current: %1$s</string>
|
||||
<string name="settings_surface_watery">Watery</string>
|
||||
<string name="settings_surface_icy">Icy</string>
|
||||
<string name="settings_surface_chrome">Chrome</string>
|
||||
<string name="settings_surface_acrylic">Acrylic</string>
|
||||
<string name="settings_surface_glass">Glass</string>
|
||||
|
||||
<string name="settings_accent_title">Accent color</string>
|
||||
<string name="settings_accent_subtitle_current">Current palette: %1$s</string>
|
||||
<string name="settings_accent_dialog_title">Accent color</string>
|
||||
@ -59,6 +73,24 @@
|
||||
<string name="settings_accent_midnight_blue">Midnight Blue</string>
|
||||
<string name="settings_accent_metallic_slate">Metallic Slate</string>
|
||||
|
||||
<string name="settings_appearance_title">Look & feel</string>
|
||||
<string name="settings_appearance_subtitle_current">Surface material and accent. Current: %1$s · %2$s</string>
|
||||
<string name="settings_appearance_dialog_title">Look & feel</string>
|
||||
<string name="settings_appearance_dialog_description">Pick a surface material, then an accent that pairs with it. Every option is a curated combination.</string>
|
||||
<string name="settings_appearance_surface_label">Surface</string>
|
||||
<string name="settings_appearance_accent_label">Accent</string>
|
||||
<string name="settings_preview_secondary">Secondary</string>
|
||||
|
||||
<string name="settings_palette_title">Palette</string>
|
||||
<string name="settings_palette_subtitle_current">Millennium accent palette. Current: %1$s</string>
|
||||
<string name="settings_palette_dialog_title">Palette</string>
|
||||
<string name="settings_palette_dialog_description">Choose a Millennium accent palette and preview it in light and dark.</string>
|
||||
<string name="settings_palette_label">Palette</string>
|
||||
<string name="settings_appearance_subtitle_palette">Millennium palette and accent. Current: %1$s</string>
|
||||
<string name="settings_palette_blue">Blue</string>
|
||||
<string name="settings_palette_orange">Orange</string>
|
||||
<string name="settings_palette_lime">Lime</string>
|
||||
|
||||
<string name="settings_download_folder_title">Download folder override</string>
|
||||
<string name="settings_download_folder_current">Current folder: %1$s</string>
|
||||
<string name="settings_download_folder_default">Current folder: default music folder</string>
|
||||
@ -198,4 +230,102 @@
|
||||
<string name="player_go_to_album">Go to album</string>
|
||||
<string name="player_sleep_timer">Sleep timer</string>
|
||||
<string name="player_track_details">Track details</string>
|
||||
|
||||
<string name="update_action_ignore">Ignore</string>
|
||||
<string name="update_action_update">Update</string>
|
||||
<string name="app_update_dialog_title">Spotube %1$s is available</string>
|
||||
<string name="app_update_dialog_no_notes">See what’s new in this release on the Spotube downloads page.</string>
|
||||
<string name="plugin_update_dialog_title">%1$s %2$s is available</string>
|
||||
<string name="plugin_update_dialog_current_version">Currently installed: %1$s</string>
|
||||
<string name="plugin_update_dialog_no_notes">See what’s new in this update from the plugin’s page.</string>
|
||||
|
||||
<string name="welcome_skip">Skip</string>
|
||||
<string name="welcome_next">Next</string>
|
||||
<string name="welcome_back">Back</string>
|
||||
<string name="welcome_get_started">Get Started</string>
|
||||
<string name="welcome_app_name">Spotube</string>
|
||||
<string name="welcome_brand_freedom">Music, without the strings.</string>
|
||||
<string name="welcome_brand_quiet_description">An open-source player for a world of music.\nYours to explore. Yours to make your own.</string>
|
||||
<string name="welcome_donate_quiet_title">Keep music free.</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_how_quiet_title">A familiar player. A different kind of freedom.</string>
|
||||
<string name="welcome_how_quiet_description">Browse through your chosen catalog. Press play and your audio plugin finds the stream.\n\nYour device handles playback and caching. Add synced lyrics, save tracks for later, or listen together with Group Jam.</string>
|
||||
<string name="welcome_demo_streaming">DEMONSTRATION</string>
|
||||
<string name="welcome_demo_paused">PAUSED</string>
|
||||
<string name="welcome_demo_pause_action">Pause demonstration</string>
|
||||
<string name="welcome_demo_play_action">Play demonstration</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>
|
||||
|
||||
<!-- Page 3: plugins -->
|
||||
<string name="welcome_plugins_eyebrow">Modular Architecture</string>
|
||||
<string name="welcome_plugins_title">Choose where your music comes from.</string>
|
||||
<string name="welcome_plugins_subtitle">Spotube separates catalog discovery, audio streaming, and synced lyrics. Configure your engines below or switch them anytime.</string>
|
||||
<string name="welcome_plugins_metadata_title">Metadata Engine</string>
|
||||
<string name="welcome_plugins_metadata_description">Powers music discovery, search, track listings, and playlists.</string>
|
||||
<string name="welcome_plugins_audio_title">Audio Engine</string>
|
||||
<string name="welcome_plugins_audio_description">Fetches and decodes the audio streams that play.</string>
|
||||
<string name="welcome_plugins_lyrics_title">Lyrics Engine</string>
|
||||
<string name="welcome_plugins_lyrics_description">Streams time-synced karaoke lyrics in real time.</string>
|
||||
<string name="welcome_plugins_scrobble_title">Scrobble Engine</string>
|
||||
<string name="welcome_plugins_scrobble_description">Records listening history to Last.fm or ListenBrainz.</string>
|
||||
<string name="welcome_plugins_badge_active">Active</string>
|
||||
<string name="welcome_plugins_badge_builtin">Built-in</string>
|
||||
<string name="welcome_plugins_badge_ready">Ready</string>
|
||||
<string name="welcome_plugins_badge_optional">Optional</string>
|
||||
<string name="welcome_plugins_none_selected">Select an engine</string>
|
||||
<string name="welcome_plugins_installed_section">Installed Engines</string>
|
||||
<string name="welcome_plugins_set_default">Set as default</string>
|
||||
<string name="welcome_plugins_builtin_note">NewPipe YouTube and LRCLib Lyrics are built-in and ready to stream out of the box.</string>
|
||||
<string name="welcome_plugins_tip">Manage or add new community plugins anytime in Settings → Plugins.</string>
|
||||
|
||||
<!-- Page 4: how it works -->
|
||||
<string name="welcome_how_eyebrow">Retro-Modern Architecture</string>
|
||||
<string name="welcome_how_title">Pure Hi-Fi, zero middlemen</string>
|
||||
<string name="welcome_how_subtitle">Here’s how Spotube orchestrates decentralized playback directly on your machine.</string>
|
||||
<string name="welcome_how_status_badge">DIRECT PEER-TO-PEER • 256 KBPS OPUS</string>
|
||||
<string name="welcome_how_mockup_title">Blue hour</string>
|
||||
<string name="welcome_how_mockup_artist">Spotube · player demonstration</string>
|
||||
<string name="welcome_how_mockup_lyric">A little music for the moments in between.</string>
|
||||
<string name="welcome_how_step_search_title">1. Decentralized Discovery</string>
|
||||
<string name="welcome_how_step_search_desc">Queries go straight to your chosen catalog plugin. Spotube never profiles your taste.</string>
|
||||
<string name="welcome_how_step_stream_title">2. Direct Stream Resolution</string>
|
||||
<string name="welcome_how_step_stream_desc">Audio streams directly from content servers to your machine. No proxy hops or remote data harvesting.</string>
|
||||
<string name="welcome_how_step_playback_title">3. Local DSP & Playback</string>
|
||||
<string name="welcome_how_step_playback_desc">Decoded and cached locally with equalizer controls and optional offline download.</string>
|
||||
<string name="welcome_how_step_lyrics_title">4. Synced Lyrics & Jam</string>
|
||||
<string name="welcome_how_step_lyrics_desc">Karaoke lyrics track the playback timestamp, with LAN remote control and Group Jam.</string>
|
||||
<string name="welcome_how_footer">No middleman servers. Playback happens right on your device — private by design.</string>
|
||||
</resources>
|
||||
|
||||
@ -22,39 +22,31 @@ import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
|
||||
import dev.krtirtho.spotube.core.navigation.toEntries
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
||||
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
import dev.krtirtho.spotube.modules.shell.AppShell
|
||||
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
|
||||
import dev.krtirtho.spotube.modules.update.AppUpdateDialog
|
||||
import dev.krtirtho.spotube.modules.update.AppUpdateService
|
||||
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
|
||||
import dev.krtirtho.spotube.modules.update.PluginUpdateService
|
||||
import dev.krtirtho.spotube.modules.webview.WebViewScreen
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||
@ -69,7 +61,6 @@ import dev.krtirtho.spotube.resources.iconsax.User
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.navigation3.koinEntryProvider
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
|
||||
interface NavigationItem {
|
||||
@ -85,7 +76,7 @@ interface NavigationItem {
|
||||
|
||||
private val tabs = listOf<NavigationItem>(
|
||||
NavigationItem.Tab<Any>("Home", Iconsax.IconsaxHome, Routes.Home),
|
||||
NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search),
|
||||
NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search)
|
||||
)
|
||||
|
||||
val sidebarTabs = tabs + listOf(
|
||||
@ -99,7 +90,7 @@ val sidebarTabs = tabs + listOf(
|
||||
LibraryTab.Playlists
|
||||
),
|
||||
NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums),
|
||||
NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists),
|
||||
NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists)
|
||||
)
|
||||
),
|
||||
NavigationItem.Group(
|
||||
@ -116,109 +107,95 @@ val sidebarTabs = tabs + listOf(
|
||||
Iconsax.IconsaxDirectboxReceive,
|
||||
Routes.Library,
|
||||
LibraryTab.Downloads
|
||||
),
|
||||
)
|
||||
)
|
||||
),
|
||||
NavigationItem.Group(
|
||||
"Connect",
|
||||
items = listOf(
|
||||
NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices),
|
||||
NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam),
|
||||
),
|
||||
),
|
||||
NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam)
|
||||
)
|
||||
)
|
||||
)
|
||||
val bottombarTabs = tabs + listOf(
|
||||
NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library),
|
||||
NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings)
|
||||
)
|
||||
|
||||
@OptIn(
|
||||
KoinExperimentalAPI::class,
|
||||
ExperimentalCoroutinesApi::class,
|
||||
ExperimentalSharedTransitionApi::class
|
||||
)
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@Composable
|
||||
fun App(
|
||||
content: @Composable () -> Unit = {}
|
||||
) {
|
||||
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
||||
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
||||
val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel()
|
||||
val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||
val appUpdateService: AppUpdateService = koinInject()
|
||||
val pluginUpdateService: PluginUpdateService = koinInject()
|
||||
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||
|
||||
val welcomeRepository: WelcomeRepository = koinInject()
|
||||
val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle()
|
||||
|
||||
// The app always renders the shell + NavDisplay; onboarding is just the start destination.
|
||||
// When it is completed the destination flips to Home and the navigation state is rebuilt.
|
||||
val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home
|
||||
|
||||
val navigationState = rememberNavigationState(
|
||||
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||
startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||
)
|
||||
val navigator = remember {
|
||||
val navigator = remember(navigationState) {
|
||||
Navigator(navigationState)
|
||||
}
|
||||
|
||||
SpotubeTheme(settings = userSettings) {
|
||||
val baseUITheme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val currentRoute =
|
||||
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppShell(navigator, navigationState) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
LocalSharedTransitionScope provides this@SharedTransitionLayout,
|
||||
) {
|
||||
Column {
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = navigator::pop,
|
||||
entries = navigationState.toEntries(koinEntryProvider())
|
||||
)
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppShell(navigator, navigationState) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
LocalSharedTransitionScope provides this@SharedTransitionLayout
|
||||
) {
|
||||
Column {
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = navigator::pop,
|
||||
entries = navigationState.toEntries(koinEntryProvider())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
when (currentRoute) {
|
||||
Routes.WebView -> WebViewScreen(koinInject())
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
content()
|
||||
if (availableUpdate != null) {
|
||||
val update = availableUpdate!!
|
||||
ThemedDialog(
|
||||
onDismissRequest = updateCheckerViewModel::dismissUpdate,
|
||||
title = {
|
||||
Text(
|
||||
text = "Spotube ${update.tag} is available",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) {
|
||||
Text("Ignore")
|
||||
}
|
||||
PrimaryButton(
|
||||
onClick = {
|
||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||
updateCheckerViewModel.dismissUpdate()
|
||||
},
|
||||
) {
|
||||
Text("Update")
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (update.releaseNotesMarkdown.isNotBlank()) {
|
||||
SelectionContainer {
|
||||
Markdown(
|
||||
content = update.releaseNotesMarkdown,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text("See what's new in this release on the Spotube downloads page.")
|
||||
}
|
||||
when (currentRoute) {
|
||||
Routes.WebView -> WebViewScreen(koinInject())
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
content()
|
||||
availableAppUpdate?.let { update ->
|
||||
AppUpdateDialog(
|
||||
update = update,
|
||||
onDismiss = appUpdateService::dismissUpdate,
|
||||
onIgnore = appUpdateService::ignoreUpdate,
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||
appUpdateService.dismissUpdate()
|
||||
})
|
||||
}
|
||||
availablePluginUpdate?.let { update ->
|
||||
PluginUpdateDialog(
|
||||
update = update,
|
||||
onDismiss = pluginUpdateService::dismissUpdate,
|
||||
onIgnore = pluginUpdateService::ignoreUpdate,
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
||||
pluginUpdateService.dismissUpdate()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 = true
|
||||
}
|
||||
@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
||||
interface AudioPlayerInterface {
|
||||
// // Playback
|
||||
suspend fun play()
|
||||
|
||||
@ -30,8 +30,7 @@ data class MediaItem(
|
||||
val duration: Duration,
|
||||
val coverURL: String,
|
||||
val url: String,
|
||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||
)
|
||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE)
|
||||
|
||||
enum class LoopState {
|
||||
NONE, ONE, ALL;
|
||||
@ -60,8 +59,7 @@ sealed interface QueueEntry {
|
||||
val track: MetadataTrack,
|
||||
override val url: String,
|
||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||
override val addedBy: String = "",
|
||||
) : QueueEntry
|
||||
override val addedBy: String = "") : QueueEntry
|
||||
|
||||
@Serializable
|
||||
@SerialName("local")
|
||||
@ -72,8 +70,7 @@ sealed interface QueueEntry {
|
||||
val album: String?,
|
||||
val coverBytes: ByteArray?,
|
||||
override val url: String,
|
||||
override val addedBy: String = "",
|
||||
) : QueueEntry {
|
||||
override val addedBy: String = "") : QueueEntry {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
@ -111,14 +108,12 @@ sealed interface QueueCollectionEntry {
|
||||
@Serializable
|
||||
@SerialName("playlist")
|
||||
data class Playlist(
|
||||
override val id: String,
|
||||
) : QueueCollectionEntry
|
||||
override val id: String) : QueueCollectionEntry
|
||||
|
||||
@Serializable
|
||||
@SerialName("album")
|
||||
data class Album(
|
||||
override val id: String,
|
||||
) : QueueCollectionEntry
|
||||
override val id: String) : QueueCollectionEntry
|
||||
|
||||
@Serializable
|
||||
@SerialName("saved_tracks")
|
||||
@ -132,5 +127,4 @@ data class PersistedQueueState(
|
||||
val entries: List<QueueEntry>,
|
||||
val currentIndex: Int,
|
||||
val currentCollectionEntry: QueueCollectionEntry? = null,
|
||||
val collectionHistory: List<QueueCollectionEntry> = emptyList(),
|
||||
)
|
||||
val collectionHistory: List<QueueCollectionEntry> = emptyList())
|
||||
|
||||
@ -29,8 +29,7 @@ interface AudioPlayerQueue {
|
||||
entries: List<QueueEntry>,
|
||||
autoPlay: Boolean = true,
|
||||
startPosition: Int = 0,
|
||||
collectionEntry: QueueCollectionEntry? = null,
|
||||
)
|
||||
collectionEntry: QueueCollectionEntry? = null)
|
||||
suspend fun addToQueue(entry: QueueEntry)
|
||||
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
|
||||
suspend fun addAllAfterCurrent(entries: List<QueueEntry>)
|
||||
|
||||
@ -45,8 +45,7 @@ class DeviceAudioPlayerQueue(
|
||||
private val settingsProvider: SettingsProvider,
|
||||
private val repository: QueueStateRepository,
|
||||
private val pluginProvider: PluginProvider,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) : AudioPlayerQueue, KoinComponent {
|
||||
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||
|
||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||
|
||||
@ -60,8 +59,7 @@ class DeviceAudioPlayerQueue(
|
||||
|
||||
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
||||
audioPlayer.playlistFlow,
|
||||
entryByMediaUrl,
|
||||
) { playlist, knownEntries ->
|
||||
entryByMediaUrl) { playlist, knownEntries ->
|
||||
playlist.map { mediaItem ->
|
||||
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
||||
}
|
||||
@ -69,8 +67,7 @@ class DeviceAudioPlayerQueue(
|
||||
|
||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
entryByMediaUrl,
|
||||
) { mediaItem, knownEntries ->
|
||||
entryByMediaUrl) { mediaItem, knownEntries ->
|
||||
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
@ -107,8 +104,7 @@ class DeviceAudioPlayerQueue(
|
||||
combine(
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
audioPlayer.playlistFlow,
|
||||
audioPlayer.loopStateFlow,
|
||||
) { currentMedia, playlist, loopState ->
|
||||
audioPlayer.loopStateFlow) { currentMedia, playlist, loopState ->
|
||||
Triple(currentMedia, playlist, loopState)
|
||||
}.collect { (currentMedia, playlist, loopState) ->
|
||||
if (currentMedia == null || playlist.isEmpty()) return@collect
|
||||
@ -129,8 +125,7 @@ class DeviceAudioPlayerQueue(
|
||||
entries: List<QueueEntry>,
|
||||
autoPlay: Boolean,
|
||||
startPosition: Int,
|
||||
collectionEntry: QueueCollectionEntry?,
|
||||
) {
|
||||
collectionEntry: QueueCollectionEntry?) {
|
||||
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
||||
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
||||
if (mediaItems.isEmpty()) {
|
||||
@ -436,8 +431,7 @@ class DeviceAudioPlayerQueue(
|
||||
entries = queueEntries,
|
||||
currentIndex = normalizedCurrentIndex,
|
||||
currentCollectionEntry = currentCollectionEntryState.value,
|
||||
collectionHistory = collectionHistoryState.value,
|
||||
)
|
||||
collectionHistory = collectionHistoryState.value)
|
||||
)
|
||||
}
|
||||
|
||||
@ -471,8 +465,7 @@ class DeviceAudioPlayerQueue(
|
||||
duration = track.durationMs.milliseconds,
|
||||
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
|
||||
url = resolvedUrl,
|
||||
protocol = protocol,
|
||||
)
|
||||
protocol = protocol)
|
||||
}
|
||||
|
||||
is QueueEntry.LocalTrack -> {
|
||||
@ -483,8 +476,7 @@ class DeviceAudioPlayerQueue(
|
||||
album = album.orEmpty(),
|
||||
duration = duration.milliseconds,
|
||||
coverURL = "",
|
||||
url = url,
|
||||
)
|
||||
url = url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -496,8 +488,7 @@ class DeviceAudioPlayerQueue(
|
||||
duration = duration.inWholeMilliseconds,
|
||||
album = album.ifBlank { null },
|
||||
coverBytes = null,
|
||||
url = url,
|
||||
)
|
||||
url = url)
|
||||
}
|
||||
|
||||
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
|
||||
|
||||
@ -23,10 +23,25 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
import okio.SYSTEM
|
||||
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
class Database(val paths: Paths) {
|
||||
/**
|
||||
* True when Spotube had already written its own data before this process started. Captured
|
||||
* synchronously at construction time — the earliest point, before any eager service can
|
||||
* create a fresh store — so it reliably tells a brand-new install apart from an upgrade of
|
||||
* an existing one (used to skip the first-run onboarding on upgrades).
|
||||
*/
|
||||
val isExistingInstall: Boolean = runCatching {
|
||||
val dataDirPath = paths.getApplicationDataDirPath()
|
||||
EXISTING_INSTALL_PROBE_FILES.any { fileName ->
|
||||
FileSystem.SYSTEM.exists("$dataDirPath/$fileName".toPath())
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
val settingsDataStore: DataStore<Preferences> by lazy {
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() }
|
||||
@ -61,6 +76,18 @@ class Database(val paths: Paths) {
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Preference files written by previous launches; their presence means an upgrade. */
|
||||
val EXISTING_INSTALL_PROBE_FILES = listOf(
|
||||
"spotube.preferences_pb",
|
||||
"spotube_plugins.preferences_pb",
|
||||
"spotube_matched_tracks.preferences_pb",
|
||||
"spotube_audio_player_queue.preferences_pb",
|
||||
"spotube_local_media.preferences_pb",
|
||||
"spotube_blacklist.preferences_pb",
|
||||
"spotube_cookies.preferences_pb")
|
||||
}
|
||||
}
|
||||
|
||||
object DatabaseKeys {
|
||||
|
||||
@ -32,6 +32,7 @@ import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
||||
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
||||
import dev.krtirtho.spotube.core.remote.RemoteControlService
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.scrobble.ScrobbleManager
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
||||
import dev.krtirtho.spotube.core.server.CacheManager
|
||||
@ -69,11 +70,13 @@ import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginViewModel
|
||||
import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||
import dev.krtirtho.spotube.modules.search.SearchRepository
|
||||
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
|
||||
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
|
||||
import dev.krtirtho.spotube.modules.update.AppUpdateService
|
||||
import dev.krtirtho.spotube.modules.update.PluginUpdateService
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
|
||||
@ -83,6 +86,8 @@ import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeViewModel
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.createdAtStart
|
||||
@ -129,6 +134,7 @@ val sharedModules = module {
|
||||
singleOf(::PluginManager) {
|
||||
bind<PluginProvider>()
|
||||
bind<AudioPluginSource>()
|
||||
bind<ScrobblePluginSource>()
|
||||
}
|
||||
viewModelOf(::PluginViewModel)
|
||||
|
||||
@ -136,7 +142,14 @@ val sharedModules = module {
|
||||
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||
viewModelOf(::JamSettingsViewModel)
|
||||
viewModelOf(::UpdateCheckerViewModel)
|
||||
|
||||
// Welcome / Onboarding
|
||||
singleOf(::WelcomeRepository)
|
||||
viewModelOf(::WelcomeViewModel)
|
||||
|
||||
// Updates
|
||||
single { AppUpdateService(get()) } withOptions { createdAtStart() }
|
||||
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
|
||||
|
||||
// Downloads
|
||||
singleOf(::DownloadManager)
|
||||
@ -155,8 +168,7 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Saved Tracks
|
||||
@ -170,8 +182,7 @@ val sharedModules = module {
|
||||
libraryRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Artist
|
||||
@ -186,8 +197,7 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Blacklist
|
||||
@ -199,8 +209,7 @@ val sharedModules = module {
|
||||
JamViewModel(
|
||||
jamRoomService = get(),
|
||||
shareService = get(),
|
||||
settingsProvider = get(),
|
||||
)
|
||||
settingsProvider = get())
|
||||
}
|
||||
|
||||
// Album
|
||||
@ -216,8 +225,7 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Lyrics
|
||||
@ -231,8 +239,7 @@ val sharedModules = module {
|
||||
savedTracksRepository = get(),
|
||||
artistRepository = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
blacklistRepository = get())
|
||||
}
|
||||
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
|
||||
single {
|
||||
@ -240,8 +247,7 @@ val sharedModules = module {
|
||||
pluginManager = get(),
|
||||
audioPlayerQueue = get(),
|
||||
matchedTracksRepository = get(),
|
||||
settingsRepository = get(),
|
||||
)
|
||||
settingsRepository = get())
|
||||
}
|
||||
singleOf(::CacheManager)
|
||||
singleOf(::AlternativeTracksRepository)
|
||||
@ -264,6 +270,9 @@ val sharedModules = module {
|
||||
|
||||
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }
|
||||
|
||||
// Scrobbling
|
||||
single { ScrobbleManager(get(), get(), get()) } withOptions { createdAtStart() }
|
||||
|
||||
factory { (tag: String?) ->
|
||||
if (tag != null) {
|
||||
Logger.withTag(tag)
|
||||
|
||||
@ -34,8 +34,7 @@ import kotlin.time.Clock
|
||||
|
||||
class DiscordRpcService(
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val settingsProvider: SettingsProvider,
|
||||
) : KoinComponent, AutoCloseable {
|
||||
private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable {
|
||||
|
||||
private val logger by injectLogger<DiscordRpcService>()
|
||||
|
||||
@ -61,8 +60,7 @@ class DiscordRpcService(
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
audioPlayer.positionFlow,
|
||||
audioPlayer.durationFlow,
|
||||
) { settings, playerState, mediaItem, position, duration ->
|
||||
audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration ->
|
||||
DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration)
|
||||
}.collect { state ->
|
||||
handleStateChange(state)
|
||||
@ -86,8 +84,7 @@ class DiscordRpcService(
|
||||
album = mediaItem.album,
|
||||
coverUrl = mediaItem.coverURL,
|
||||
positionMs = state.position.inWholeMilliseconds,
|
||||
durationMs = state.duration.inWholeMilliseconds,
|
||||
)
|
||||
durationMs = state.duration.inWholeMilliseconds)
|
||||
}
|
||||
PlayerState.PAUSED -> {
|
||||
val mediaItem = state.mediaItem ?: return
|
||||
@ -98,8 +95,7 @@ class DiscordRpcService(
|
||||
album = mediaItem.album,
|
||||
coverUrl = mediaItem.coverURL,
|
||||
positionMs = state.position.inWholeMilliseconds,
|
||||
durationMs = state.duration.inWholeMilliseconds,
|
||||
)
|
||||
durationMs = state.duration.inWholeMilliseconds)
|
||||
}
|
||||
PlayerState.IDLE, PlayerState.COMPLETED -> {
|
||||
clearPresence()
|
||||
@ -158,8 +154,7 @@ class DiscordRpcService(
|
||||
album: String,
|
||||
coverUrl: String,
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
) {
|
||||
durationMs: Long) {
|
||||
val client = rpcClient ?: return
|
||||
try {
|
||||
client.updatePresence(
|
||||
@ -168,8 +163,7 @@ class DiscordRpcService(
|
||||
album = album,
|
||||
coverUrl = coverUrl,
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
durationMs = durationMs)
|
||||
} catch (e: Exception) {
|
||||
rpcClient = null
|
||||
connectionFailed = true
|
||||
@ -210,6 +204,5 @@ class DiscordRpcService(
|
||||
val playerState: PlayerState,
|
||||
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||
val position: kotlin.time.Duration,
|
||||
val duration: kotlin.time.Duration,
|
||||
)
|
||||
val duration: kotlin.time.Duration)
|
||||
}
|
||||
|
||||
@ -30,8 +30,7 @@ data class DiscoveredDevice(
|
||||
val type: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val deviceId: String,
|
||||
) {
|
||||
val deviceId: String) {
|
||||
val key: String get() = "$name$type".replace(".", "")
|
||||
}
|
||||
|
||||
@ -55,8 +54,7 @@ class DeviceDiscoveryService {
|
||||
type = event.service.type,
|
||||
host = event.service.host,
|
||||
port = event.service.port,
|
||||
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
|
||||
)
|
||||
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
|
||||
DiscoveryState.Discovered(device = device, resolve = event.resolve)
|
||||
}
|
||||
|
||||
@ -66,8 +64,7 @@ class DeviceDiscoveryService {
|
||||
type = event.service.type,
|
||||
host = event.service.host,
|
||||
port = event.service.port,
|
||||
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
|
||||
)
|
||||
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
|
||||
DiscoveryState.Resolved(device = device)
|
||||
}
|
||||
|
||||
@ -77,8 +74,7 @@ class DeviceDiscoveryService {
|
||||
type = event.service.type,
|
||||
host = event.service.host,
|
||||
port = event.service.port,
|
||||
deviceId = "",
|
||||
)
|
||||
deviceId = "")
|
||||
DiscoveryState.Removed(device = device)
|
||||
}
|
||||
}
|
||||
@ -88,8 +84,7 @@ class DeviceDiscoveryService {
|
||||
name: String,
|
||||
port: Int,
|
||||
deviceId: String,
|
||||
registerTimeoutMs: Long = 5_000,
|
||||
): NetService {
|
||||
registerTimeoutMs: Long = 5_000): NetService {
|
||||
val service = createService(name, port, deviceId)
|
||||
service.register(timeoutInMs = registerTimeoutMs)
|
||||
return service
|
||||
@ -98,11 +93,9 @@ class DeviceDiscoveryService {
|
||||
fun createService(
|
||||
name: String,
|
||||
port: Int,
|
||||
deviceId: String,
|
||||
): NetService = createNetService(
|
||||
deviceId: String): NetService = createNetService(
|
||||
type = SERVICE_TYPE,
|
||||
name = name,
|
||||
port = port,
|
||||
txt = mapOf(TXT_DEVICE_ID to deviceId),
|
||||
)
|
||||
txt = mapOf(TXT_DEVICE_ID to deviceId))
|
||||
}
|
||||
@ -46,8 +46,7 @@ sealed class JamMessage {
|
||||
*/
|
||||
val follow: Boolean = false,
|
||||
/** Host's live play state — late-joining guests start with it. */
|
||||
val isPlaying: Boolean = false,
|
||||
) : JamMessage()
|
||||
val isPlaying: Boolean = false) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("playbackCommand")
|
||||
@ -57,22 +56,19 @@ sealed class JamMessage {
|
||||
@SerialName("suggestTrack")
|
||||
data class SuggestTrack(
|
||||
val mediaItem: JamMediaItem,
|
||||
val addedBy: String = "",
|
||||
) : JamMessage()
|
||||
val addedBy: String = "") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("suggestPlaylist")
|
||||
data class SuggestPlaylist(
|
||||
val tracks: List<JamMediaItem>,
|
||||
val addedBy: String = "",
|
||||
) : JamMessage()
|
||||
val addedBy: String = "") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("kick")
|
||||
data class Kick(
|
||||
val participantId: String,
|
||||
val reason: String = "kicked",
|
||||
) : JamMessage()
|
||||
val reason: String = "kicked") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("leave")
|
||||
@ -104,8 +100,7 @@ data class JamPresence(
|
||||
val clientId: String,
|
||||
val displayName: String,
|
||||
val isHost: Boolean,
|
||||
val left: Boolean = false,
|
||||
)
|
||||
val left: Boolean = false)
|
||||
|
||||
@Serializable
|
||||
data class JamMediaItem(
|
||||
@ -117,8 +112,7 @@ data class JamMediaItem(
|
||||
val durationMs: Long,
|
||||
val coverUrl: String,
|
||||
val protocol: String,
|
||||
val addedBy: String = "",
|
||||
) {
|
||||
val addedBy: String = "") {
|
||||
companion object {
|
||||
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
||||
is QueueEntry.StreamingTrack -> JamMediaItem(
|
||||
@ -132,8 +126,7 @@ data class JamMediaItem(
|
||||
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
.orEmpty(),
|
||||
protocol = entry.protocol.name,
|
||||
addedBy = entry.addedBy,
|
||||
)
|
||||
addedBy = entry.addedBy)
|
||||
|
||||
is QueueEntry.LocalTrack -> JamMediaItem(
|
||||
url = entry.url,
|
||||
@ -144,8 +137,7 @@ data class JamMediaItem(
|
||||
durationMs = entry.duration,
|
||||
coverUrl = "",
|
||||
protocol = "PROGRESSIVE",
|
||||
addedBy = entry.addedBy,
|
||||
)
|
||||
addedBy = entry.addedBy)
|
||||
}
|
||||
|
||||
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
|
||||
@ -158,8 +150,7 @@ data class JamMediaItem(
|
||||
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
.orEmpty(),
|
||||
protocol = "PROGRESSIVE",
|
||||
)
|
||||
protocol = "PROGRESSIVE")
|
||||
|
||||
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
|
||||
url = item.url,
|
||||
@ -168,8 +159,7 @@ data class JamMediaItem(
|
||||
album = item.album,
|
||||
durationMs = item.duration.inWholeMilliseconds,
|
||||
coverUrl = item.coverURL,
|
||||
protocol = item.protocol.name,
|
||||
)
|
||||
protocol = item.protocol.name)
|
||||
|
||||
fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
|
||||
title = item.title,
|
||||
@ -179,8 +169,7 @@ data class JamMediaItem(
|
||||
coverURL = item.coverUrl,
|
||||
url = item.url,
|
||||
protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
.valueOf(item.protocol.ifBlank { "PROGRESSIVE" }),
|
||||
)
|
||||
.valueOf(item.protocol.ifBlank { "PROGRESSIVE" }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,8 +177,7 @@ data class JamMediaItem(
|
||||
data class JamParticipant(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val isHost: Boolean,
|
||||
)
|
||||
val isHost: Boolean)
|
||||
|
||||
@Serializable
|
||||
enum class JamRole {
|
||||
|
||||
@ -30,10 +30,8 @@ import com.ditchoom.mqtt.controlpacket.TopicName
|
||||
import com.ditchoom.mqtt.controlpacket.WillConfig
|
||||
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
|
||||
import dev.krtirtho.spotube.modules.settings.JamBroker
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -47,6 +45,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
|
||||
@ -114,8 +113,7 @@ class JamRoomClient : KoinComponent {
|
||||
code: String,
|
||||
clientId: String,
|
||||
displayName: String,
|
||||
isHost: Boolean,
|
||||
): Result<Unit> {
|
||||
isHost: Boolean): Result<Unit> {
|
||||
disconnect()
|
||||
if (broker.host.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("No jam broker configured"))
|
||||
@ -126,8 +124,7 @@ class JamRoomClient : KoinComponent {
|
||||
clientId = clientId,
|
||||
displayName = displayName,
|
||||
isHost = isHost,
|
||||
left = false,
|
||||
)
|
||||
left = false)
|
||||
|
||||
val mqtt = startClient(broker, clientId)
|
||||
client = mqtt
|
||||
@ -167,8 +164,7 @@ class JamRoomClient : KoinComponent {
|
||||
publishJson(
|
||||
presenceTopic(code, presence.clientId),
|
||||
json.encodeToString(JamPresence.serializer(), presence),
|
||||
retain = true,
|
||||
)
|
||||
retain = true)
|
||||
}
|
||||
|
||||
/** Marks us as the host in presence (host takeover). */
|
||||
@ -186,8 +182,7 @@ class JamRoomClient : KoinComponent {
|
||||
publishJson(
|
||||
presenceTopic(code, presence.clientId),
|
||||
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
|
||||
retain = true,
|
||||
)
|
||||
retain = true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -208,26 +203,22 @@ class JamRoomClient : KoinComponent {
|
||||
host = broker.host,
|
||||
port = broker.port,
|
||||
tlsEnabled = broker.useTls,
|
||||
connectionTimeout = broker.connectionTimeoutSeconds.seconds,
|
||||
)
|
||||
connectionTimeout = broker.connectionTimeoutSeconds.seconds)
|
||||
val code = roomCode ?: "unset"
|
||||
val will = WillConfig.Enabled(
|
||||
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
|
||||
payload = json.encodeToString(
|
||||
JamPresence.serializer(),
|
||||
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true),
|
||||
).toReadBuffer(Charset.UTF8),
|
||||
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8),
|
||||
qos = QualityOfService.AT_LEAST_ONCE,
|
||||
retain = true,
|
||||
)
|
||||
retain = true)
|
||||
val request = ConnectionRequest(
|
||||
clientId = clientId,
|
||||
keepAliveSeconds = broker.keepAliveSeconds,
|
||||
cleanStart = true,
|
||||
userName = broker.username,
|
||||
password = broker.password,
|
||||
will = will,
|
||||
)
|
||||
will = will)
|
||||
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
|
||||
val brokerRef = persistence.addBroker(connection, request)
|
||||
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
|
||||
@ -265,8 +256,7 @@ class JamRoomClient : KoinComponent {
|
||||
val operation = mqtt.subscribe(
|
||||
roomFilter(code),
|
||||
OpaquePublishPayloadCodec,
|
||||
QualityOfService.AT_LEAST_ONCE,
|
||||
) { publish, payload ->
|
||||
QualityOfService.AT_LEAST_ONCE) { publish, payload ->
|
||||
route(publish.topic.toString(), payload)
|
||||
}
|
||||
runCatching { operation.subAck.await() }
|
||||
@ -309,8 +299,7 @@ class JamRoomClient : KoinComponent {
|
||||
topicName = topic,
|
||||
qos = QualityOfService.AT_LEAST_ONCE,
|
||||
payload = payload.toReadBuffer(Charset.UTF8),
|
||||
retain = retain,
|
||||
)
|
||||
retain = retain)
|
||||
}.onFailure { e ->
|
||||
log.w(e) { "Failed to publish to $topic" }
|
||||
}
|
||||
|
||||
@ -59,15 +59,13 @@ private data class PlaybackBroadcast(
|
||||
val queue: List<QueueEntry>,
|
||||
val current: QueueEntry?,
|
||||
val shuffle: Boolean,
|
||||
val playerState: PlayerState,
|
||||
)
|
||||
val playerState: PlayerState)
|
||||
|
||||
class JamRoomService(
|
||||
private val jamClient: JamRoomClient,
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
private val settingsRepository: SettingsRepository) {
|
||||
private val log = Logger.withTag("JamRoomService")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@ -171,8 +169,7 @@ class JamRoomService(
|
||||
code = code,
|
||||
clientId = localClientId,
|
||||
displayName = name,
|
||||
isHost = true,
|
||||
).map {
|
||||
isHost = true).map {
|
||||
_role.value = JamRole.Host
|
||||
_roomCode.value = code
|
||||
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
|
||||
@ -206,8 +203,7 @@ class JamRoomService(
|
||||
code = normalized,
|
||||
clientId = localClientId,
|
||||
displayName = name,
|
||||
isHost = false,
|
||||
).map {
|
||||
isHost = false).map {
|
||||
_role.value = JamRole.Guest
|
||||
_roomCode.value = normalized
|
||||
_participants.value = emptyList()
|
||||
@ -261,8 +257,7 @@ class JamRoomService(
|
||||
jamClient.publishCommand(
|
||||
JamMessage.SuggestTrack(
|
||||
mediaItem = JamMediaItem.fromTrack(track),
|
||||
addedBy = localClientId,
|
||||
)
|
||||
addedBy = localClientId)
|
||||
)
|
||||
}
|
||||
|
||||
@ -271,8 +266,7 @@ class JamRoomService(
|
||||
jamClient.publishCommand(
|
||||
JamMessage.SuggestPlaylist(
|
||||
tracks = tracks.map(JamMediaItem::fromTrack),
|
||||
addedBy = localClientId,
|
||||
)
|
||||
addedBy = localClientId)
|
||||
)
|
||||
}
|
||||
|
||||
@ -296,8 +290,7 @@ class JamRoomService(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
audioPlayer.shuffleModeFlow,
|
||||
audioPlayer.playerStateFlow,
|
||||
) { queue, current, shuffle, playerState ->
|
||||
audioPlayer.playerStateFlow) { queue, current, shuffle, playerState ->
|
||||
PlaybackBroadcast(queue, current, shuffle, playerState)
|
||||
}
|
||||
.onEach { broadcast ->
|
||||
@ -314,8 +307,7 @@ class JamRoomService(
|
||||
currentIndex = index.coerceAtLeast(0),
|
||||
shuffleEnabled = broadcast.shuffle,
|
||||
follow = now() > autoAdvanceDeadlineMs,
|
||||
isPlaying = broadcast.playerState == PlayerState.PLAYING,
|
||||
)
|
||||
isPlaying = broadcast.playerState == PlayerState.PLAYING)
|
||||
)
|
||||
}
|
||||
.launchIn(this)
|
||||
@ -374,8 +366,7 @@ class JamRoomService(
|
||||
audioPlayerQueue.load(
|
||||
entries = items.map { it.toQueueEntry() },
|
||||
autoPlay = wasPlaying,
|
||||
startPosition = startIndex,
|
||||
)
|
||||
startPosition = startIndex)
|
||||
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
||||
return
|
||||
}
|
||||
@ -524,13 +515,11 @@ class JamRoomService(
|
||||
explicit = null,
|
||||
popularity = null,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
url = "",
|
||||
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
||||
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
||||
addedBy = addedBy,
|
||||
)
|
||||
addedBy = addedBy)
|
||||
|
||||
else -> QueueEntry.LocalTrack(
|
||||
name = title,
|
||||
@ -539,8 +528,7 @@ class JamRoomService(
|
||||
album = album.ifBlank { null },
|
||||
coverBytes = null,
|
||||
url = url,
|
||||
addedBy = addedBy,
|
||||
)
|
||||
addedBy = addedBy)
|
||||
}
|
||||
|
||||
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
|
||||
|
||||
@ -34,6 +34,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
|
||||
import dev.krtirtho.spotube.modules.search.SearchScreen
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsScreen
|
||||
import dev.krtirtho.spotube.modules.settings.JamSettingsScreen
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
@ -91,6 +92,9 @@ sealed interface Routes : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object JamSettings : Routes
|
||||
|
||||
@Serializable
|
||||
data object Welcome : Routes
|
||||
}
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@ -121,8 +125,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Artist> {
|
||||
ArtistScreen(
|
||||
@ -132,8 +135,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Album> {
|
||||
AlbumScreen(
|
||||
@ -144,8 +146,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Lyrics> {
|
||||
LyricsScreen(viewModel = koinViewModel())
|
||||
@ -158,8 +159,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Blacklist> {
|
||||
BlacklistScreen()
|
||||
@ -178,4 +178,7 @@ val navigationModule = module {
|
||||
navigation<Routes.JamSettings> {
|
||||
JamSettingsScreen(viewModel = koinViewModel())
|
||||
}
|
||||
navigation<Routes.Welcome> {
|
||||
WelcomeScreen(viewModel = koinViewModel())
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,6 @@ import androidx.compose.runtime.saveable.rememberSerializable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
@ -45,8 +44,9 @@ val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.Home,
|
||||
Routes.Search,
|
||||
Routes.Library,
|
||||
Routes.Settings
|
||||
)
|
||||
Routes.Settings,
|
||||
// Bootstrap destination: needs its own back stack when the app starts on it.
|
||||
Routes.Welcome)
|
||||
|
||||
val serializersConfig = SavedStateConfiguration {
|
||||
serializersModule = SerializersModule {
|
||||
@ -63,6 +63,7 @@ val serializersConfig = SavedStateConfiguration {
|
||||
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
||||
subclass(Routes.Devices::class, Routes.Devices.serializer())
|
||||
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
||||
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -121,9 +122,7 @@ fun NavigationState.toEntries(
|
||||
): SnapshotStateList<NavEntry<NavKey>> {
|
||||
val decoratedEntries = backStacks.mapValues { (_, stack) ->
|
||||
val decorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
)
|
||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>())
|
||||
rememberDecoratedNavEntries(
|
||||
backStack = stack,
|
||||
entryDecorators = decorators,
|
||||
|
||||
@ -49,5 +49,4 @@ data class MediaStream(
|
||||
val container: String,
|
||||
val bitrate: Int,
|
||||
val isAudio: Boolean,
|
||||
val isVideo: Boolean,
|
||||
)
|
||||
val isVideo: Boolean)
|
||||
@ -34,8 +34,7 @@ class CollectionPlaybackHelper(
|
||||
private val savedTracksRepository: SavedTracksRepository,
|
||||
private val artistRepository: ArtistRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) {
|
||||
private val blacklistRepository: BlacklistRepository) {
|
||||
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
|
||||
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
|
||||
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
|
||||
@ -44,8 +43,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,8 +52,7 @@ class CollectionPlaybackHelper(
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllToQueue(
|
||||
entries,
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,8 +84,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||
}
|
||||
|
||||
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
|
||||
@ -99,8 +95,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,8 +104,7 @@ class CollectionPlaybackHelper(
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllToQueue(
|
||||
entries,
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,8 +136,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||
}
|
||||
|
||||
suspend fun playSavedTracks(shuffle: Boolean = false) {
|
||||
@ -154,8 +147,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,8 +158,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -190,8 +181,7 @@ class CollectionPlaybackHelper(
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllToQueue(
|
||||
entries,
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||
}
|
||||
}
|
||||
|
||||
@ -216,8 +206,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||
}
|
||||
|
||||
private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> {
|
||||
@ -313,8 +302,7 @@ class CollectionPlaybackHelper(
|
||||
private fun isTrackBlacklisted(
|
||||
track: MetadataTrack,
|
||||
blacklistedTrackIds: Set<String>,
|
||||
blacklistedArtistIds: Set<String>,
|
||||
): Boolean {
|
||||
blacklistedArtistIds: Set<String>): Boolean {
|
||||
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
|
||||
@ -17,17 +17,24 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.remote
|
||||
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumDialog
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
@ -42,50 +49,45 @@ fun ConnectionRequestDialogHost() {
|
||||
}
|
||||
|
||||
pendingRequest?.let { request ->
|
||||
AdaptiveDialogBottomSheet(
|
||||
onDismiss = {
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||
pendingRequest = null
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Remote Control Request",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
content = {
|
||||
Text(
|
||||
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
Button(
|
||||
title = "Remote Control Request") {
|
||||
Text(
|
||||
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Start)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||
pendingRequest = null
|
||||
},
|
||||
) {
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Deny")
|
||||
}
|
||||
Button(
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
||||
pendingRequest = null
|
||||
},
|
||||
) {
|
||||
}) {
|
||||
Text("Allow")
|
||||
}
|
||||
Button(
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
||||
pendingRequest = null
|
||||
},
|
||||
) {
|
||||
style = MillenniumButtonStyle.Quiet) {
|
||||
Text("Allow Always")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -162,8 +162,7 @@ class RemoteControlClient {
|
||||
|
||||
val envelope = CommandEnvelope(
|
||||
commandId = randomShortId(),
|
||||
command = command,
|
||||
)
|
||||
command = command)
|
||||
|
||||
try {
|
||||
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)
|
||||
|
||||
@ -48,13 +48,11 @@ class RemoteControlHandler(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
) : KoinComponent {
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent {
|
||||
val logger by injectLogger<RemoteControlHandler>()
|
||||
|
||||
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
|
||||
extraBufferCapacity = 16,
|
||||
)
|
||||
extraBufferCapacity = 16)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@ -88,8 +86,7 @@ class RemoteControlHandler(
|
||||
deviceId = deviceId ?: "unknown",
|
||||
deviceName = deviceName,
|
||||
sessionId = session.call.request.headers["X-Request-Id"]
|
||||
?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}",
|
||||
)
|
||||
?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}")
|
||||
incomingConnectionRequests.emit(request)
|
||||
val response = waitForRequestResolution(request.sessionId)
|
||||
if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) {
|
||||
@ -225,8 +222,7 @@ class RemoteControlHandler(
|
||||
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
is RemoteControlCommand.AddTrackToQueue -> {
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
|
||||
@ -300,8 +296,7 @@ class RemoteControlHandler(
|
||||
private suspend fun handleCollectionSource(
|
||||
source: String,
|
||||
action: RemoteCollectionAction,
|
||||
shuffle: Boolean = false,
|
||||
) {
|
||||
shuffle: Boolean = false) {
|
||||
logger.d { "Remote collection $action for source: $source" }
|
||||
when {
|
||||
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
|
||||
@ -359,8 +354,7 @@ class RemoteControlHandler(
|
||||
currentTrackTitle = current?.titleOrNull(),
|
||||
currentTrackArtists = current?.artistsOrNull(),
|
||||
currentTrackAlbum = current?.albumOrNull(),
|
||||
currentTrackCoverUrl = current?.coverUrlOrNull(),
|
||||
)
|
||||
currentTrackCoverUrl = current?.coverUrlOrNull())
|
||||
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
@ -375,8 +369,7 @@ class RemoteControlHandler(
|
||||
}
|
||||
val event = RemoteControlEvent.QueueUpdated(
|
||||
entries = queue.map { it.toRemoteQueueEntry() },
|
||||
currentIndex = currentIndex,
|
||||
)
|
||||
currentIndex = currentIndex)
|
||||
val text = json.encodeToString(RemoteControlEvent.serializer(), event)
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
@ -403,8 +396,7 @@ class RemoteControlHandler(
|
||||
artists = track.artists.joinToString(", ") { artist -> artist.name },
|
||||
album = track.album?.title,
|
||||
coverUrl = coverUrlOrNull(),
|
||||
durationMs = track.durationMs,
|
||||
)
|
||||
durationMs = track.durationMs)
|
||||
|
||||
is QueueEntry.LocalTrack -> RemoteQueueEntry(
|
||||
mediaUrl = url,
|
||||
@ -413,8 +405,7 @@ class RemoteControlHandler(
|
||||
artists = artists.joinToString(", "),
|
||||
album = album,
|
||||
coverUrl = null,
|
||||
durationMs = duration,
|
||||
)
|
||||
durationMs = duration)
|
||||
}
|
||||
|
||||
private fun QueueEntry.mediaKey(): String = when (this) {
|
||||
@ -447,8 +438,7 @@ class RemoteControlHandler(
|
||||
@Serializable
|
||||
data class CommandEnvelope(
|
||||
val commandId: String,
|
||||
val command: RemoteControlCommand,
|
||||
)
|
||||
val command: RemoteControlCommand)
|
||||
|
||||
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
|
||||
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
|
||||
@ -464,8 +454,7 @@ enum class RemoteCollectionAction {
|
||||
data class ConnectionRequest(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val sessionId: String,
|
||||
)
|
||||
val sessionId: String)
|
||||
|
||||
enum class ConnectionRequestResponse {
|
||||
Allow,
|
||||
|
||||
@ -119,15 +119,13 @@ sealed class RemoteControlEvent {
|
||||
val currentTrackTitle: String?,
|
||||
val currentTrackArtists: String?,
|
||||
val currentTrackAlbum: String?,
|
||||
val currentTrackCoverUrl: String?,
|
||||
) : RemoteControlEvent()
|
||||
val currentTrackCoverUrl: String?) : RemoteControlEvent()
|
||||
|
||||
@Serializable
|
||||
@SerialName("queueUpdated")
|
||||
data class QueueUpdated(
|
||||
val entries: List<RemoteQueueEntry>,
|
||||
val currentIndex: Int,
|
||||
) : RemoteControlEvent()
|
||||
val currentIndex: Int) : RemoteControlEvent()
|
||||
|
||||
@Serializable
|
||||
@SerialName("ack")
|
||||
@ -146,5 +144,4 @@ data class RemoteQueueEntry(
|
||||
val artists: String,
|
||||
val album: String?,
|
||||
val coverUrl: String?,
|
||||
val durationMs: Long,
|
||||
)
|
||||
val durationMs: Long)
|
||||
|
||||
@ -53,8 +53,7 @@ import kotlin.random.Random
|
||||
class RemoteControlService(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val discoveryService: DeviceDiscoveryService,
|
||||
private val localServer: LocalServer,
|
||||
) {
|
||||
private val localServer: LocalServer) {
|
||||
private val log = Logger.withTag("RemoteControlService")
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@ -78,8 +77,7 @@ class RemoteControlService(
|
||||
scope.launch {
|
||||
combine(
|
||||
settingsRepository.userSettings,
|
||||
localServer.port,
|
||||
) { settings, port -> settings to port }
|
||||
localServer.port) { settings, port -> settings to port }
|
||||
.distinctUntilChanged()
|
||||
.collect { (settings, port) ->
|
||||
if (settings.allowRemoteControl && port != null) {
|
||||
@ -139,8 +137,7 @@ class RemoteControlService(
|
||||
val service = discoveryService.createService(
|
||||
name = serviceName,
|
||||
port = port,
|
||||
deviceId = deviceId,
|
||||
)
|
||||
deviceId = deviceId)
|
||||
pendingService = service
|
||||
try {
|
||||
service.register(timeoutInMs = REGISTER_TIMEOUT_MS)
|
||||
|
||||
@ -64,20 +64,17 @@ sealed interface PlaybackDestinationRequest {
|
||||
val type: RemoteCollectionType,
|
||||
val id: String,
|
||||
val startTrack: MetadataTrack? = null,
|
||||
val shuffle: Boolean = false,
|
||||
) : PlaybackDestinationRequest
|
||||
val shuffle: Boolean = false) : PlaybackDestinationRequest
|
||||
|
||||
data class Track(
|
||||
override val title: String,
|
||||
override val action: PlaybackDestinationAction,
|
||||
val track: MetadataTrack,
|
||||
) : PlaybackDestinationRequest
|
||||
val track: MetadataTrack) : PlaybackDestinationRequest
|
||||
|
||||
data class Tracks(
|
||||
override val title: String,
|
||||
override val action: PlaybackDestinationAction,
|
||||
val tracks: List<MetadataTrack>,
|
||||
) : PlaybackDestinationRequest
|
||||
val tracks: List<MetadataTrack>) : PlaybackDestinationRequest
|
||||
}
|
||||
|
||||
/**
|
||||
@ -90,8 +87,7 @@ class RemotePlaybackController(
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val jamRoomService: JamRoomService,
|
||||
) : KoinComponent {
|
||||
private val jamRoomService: JamRoomService) : KoinComponent {
|
||||
private val logger = Logger.withTag("RemotePlaybackController")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@ -113,8 +109,7 @@ class RemotePlaybackController(
|
||||
id: String,
|
||||
title: String,
|
||||
startTrack: MetadataTrack? = null,
|
||||
shuffle: Boolean = false,
|
||||
) {
|
||||
shuffle: Boolean = false) {
|
||||
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
|
||||
}
|
||||
|
||||
@ -205,8 +200,7 @@ class RemotePlaybackController(
|
||||
QueueEntry.StreamingTrack(
|
||||
track = track,
|
||||
url = "",
|
||||
addedBy = jamRoomService.participantClientId,
|
||||
)
|
||||
addedBy = jamRoomService.participantClientId)
|
||||
}
|
||||
)
|
||||
|
||||
@ -291,8 +285,7 @@ class RemotePlaybackController(
|
||||
entries = listOf(entry),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> {
|
||||
@ -319,8 +312,7 @@ class RemotePlaybackController(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> {
|
||||
|
||||
@ -0,0 +1,265 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.scrobble
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Watches local playback and submits scrobbles through the currently selected scrobble plugin.
|
||||
*
|
||||
* Scrobbling follows the standard Last.fm rules, which plugins are expected to honour as well:
|
||||
* - Tracks shorter than [MIN_TRACK_DURATION_MS] are never scrobbled.
|
||||
* - The track must be listened to for at least half of its duration, or [MAX_SCROBBLE_THRESHOLD_MS],
|
||||
* whichever comes first.
|
||||
*
|
||||
* Only time during which the player is actively [PlayerState.PLAYING] is counted, so pauses are
|
||||
* ignored, and forward seeks don't credit unheard audio. The timestamp sent with the scrobble is
|
||||
* the moment playback of the track started. Each play is scrobbled at most once; replaying or
|
||||
* repeating the track starts a new scrobble session.
|
||||
*/
|
||||
class ScrobbleManager(
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val scrobblePluginSource: ScrobblePluginSource,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<ScrobbleManager>()
|
||||
|
||||
private var activeTrackKey: String? = null
|
||||
private var playbackStartedAtEpochSeconds: Long? = null
|
||||
private var listenedMs: Long = 0L
|
||||
private var lastPositionMs: Long? = null
|
||||
private var scrobbled = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
combine(
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.positionFlow,
|
||||
audioPlayer.durationFlow) { entry, playerState, position, duration ->
|
||||
PlaybackSnapshot(
|
||||
entry = entry,
|
||||
playerState = playerState,
|
||||
positionMs = position.inWholeMilliseconds,
|
||||
playerDurationMs = duration.inWholeMilliseconds)
|
||||
}.collect(::onPlaybackSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) {
|
||||
val entry = snapshot.entry
|
||||
if (entry == null) {
|
||||
clearSession()
|
||||
return
|
||||
}
|
||||
|
||||
val trackKey = entry.trackKey()
|
||||
if (trackKey != activeTrackKey) {
|
||||
beginSession(trackKey, snapshot.positionMs)
|
||||
return
|
||||
}
|
||||
|
||||
val previousPositionMs = lastPositionMs
|
||||
lastPositionMs = snapshot.positionMs
|
||||
|
||||
if (snapshot.playerState != PlayerState.PLAYING) return
|
||||
if (previousPositionMs == null) {
|
||||
playbackStartedAtEpochSeconds = playbackStartedAtEpochSeconds ?: nowEpochSeconds()
|
||||
return
|
||||
}
|
||||
|
||||
if (playbackStartedAtEpochSeconds == null) {
|
||||
playbackStartedAtEpochSeconds = nowEpochSeconds()
|
||||
}
|
||||
|
||||
val deltaMs = snapshot.positionMs - previousPositionMs
|
||||
if (deltaMs < 0) {
|
||||
// A backward seek to the very beginning means the play started over
|
||||
// (e.g. repeat one), so allow it to be scrobbled again.
|
||||
if (snapshot.positionMs <= RESTART_POSITION_THRESHOLD_MS) {
|
||||
restartSession(snapshot.positionMs)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A jump larger than a couple of position ticks is a forward seek or a
|
||||
// buffering/app-suspension stall. Never credit unplayed audio for it.
|
||||
if (deltaMs > MAX_COUNTED_POSITION_DELTA_MS) return
|
||||
|
||||
listenedMs += deltaMs
|
||||
if (scrobbled) return
|
||||
|
||||
val durationMs = entry.durationMs().takeIf { it > 0 } ?: snapshot.playerDurationMs
|
||||
val thresholdMs = scrobbleThresholdMs(durationMs) ?: return
|
||||
if (listenedMs < thresholdMs) return
|
||||
|
||||
if (scrobblePluginSource.selectedScrobblePlugin.value == null) {
|
||||
// No scrobble plugin selected yet: keep the session hot so it can be
|
||||
// submitted if the user selects one while the track is still playing.
|
||||
return
|
||||
}
|
||||
|
||||
scrobbled = true
|
||||
val track = entry.toScrobbleTrack(
|
||||
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
|
||||
durationMs = durationMs)
|
||||
scope.launch { submitScrobble(track) }
|
||||
}
|
||||
|
||||
private suspend fun submitScrobble(track: ScrobbleTrack) {
|
||||
val plugin = scrobblePluginSource.selectedScrobblePlugin.value ?: return
|
||||
try {
|
||||
plugin.use { scrobbleAPI.scrobble(track) }
|
||||
logger.d { "Scrobbled \"${track.trackName}\" by ${track.artistName} via ${plugin.pluginId}" }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to scrobble \"${track.trackName}\" by ${track.artistName}" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginSession(trackKey: String, positionMs: Long) {
|
||||
activeTrackKey = trackKey
|
||||
restartSession(positionMs)
|
||||
}
|
||||
|
||||
private fun restartSession(positionMs: Long) {
|
||||
listenedMs = 0L
|
||||
playbackStartedAtEpochSeconds = null
|
||||
lastPositionMs = positionMs
|
||||
scrobbled = false
|
||||
}
|
||||
|
||||
private fun clearSession() {
|
||||
activeTrackKey = null
|
||||
listenedMs = 0L
|
||||
playbackStartedAtEpochSeconds = null
|
||||
lastPositionMs = null
|
||||
scrobbled = false
|
||||
}
|
||||
|
||||
private fun nowEpochSeconds(): Long = Clock.System.now().epochSeconds
|
||||
|
||||
private fun QueueEntry.trackKey(): String = when (this) {
|
||||
is QueueEntry.StreamingTrack -> track.id
|
||||
is QueueEntry.LocalTrack -> url
|
||||
}
|
||||
|
||||
private fun QueueEntry.durationMs(): Long = when (this) {
|
||||
is QueueEntry.StreamingTrack -> track.durationMs
|
||||
is QueueEntry.LocalTrack -> duration
|
||||
}
|
||||
|
||||
private fun QueueEntry.toScrobbleTrack(timestamp: Long, durationMs: Long): ScrobbleTrack {
|
||||
return when (this) {
|
||||
is QueueEntry.StreamingTrack -> ScrobbleTrack(
|
||||
timestamp = timestamp,
|
||||
trackId = track.id,
|
||||
artistId = track.artists.firstOrNull()?.id.orEmpty(),
|
||||
albumId = track.album?.id,
|
||||
trackName = track.title,
|
||||
artistName = track.artists.joinToString(", ") { it.name },
|
||||
albumName = track.album?.title,
|
||||
streamingProvider = resolveStreamingProvider(track),
|
||||
durationMs = durationMs)
|
||||
|
||||
is QueueEntry.LocalTrack -> ScrobbleTrack(
|
||||
timestamp = timestamp,
|
||||
trackId = url,
|
||||
artistId = "",
|
||||
albumId = null,
|
||||
trackName = name,
|
||||
artistName = artists.joinToString(", "),
|
||||
albumName = album,
|
||||
streamingProvider = LOCAL_PROVIDER,
|
||||
durationMs = durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a track's external URI to the streaming provider key scrobble plugins expect. */
|
||||
private fun resolveStreamingProvider(track: MetadataTrack): String {
|
||||
val host = track.externalUri
|
||||
?.substringAfter("://", missingDelimiterValue = "")
|
||||
?.substringBefore('/')
|
||||
?.substringAfter('@')
|
||||
?.substringBefore(':')
|
||||
?.lowercase()
|
||||
?.removePrefix("www.")
|
||||
.orEmpty()
|
||||
|
||||
if (host.isBlank()) return UNKNOWN_PROVIDER
|
||||
|
||||
return when {
|
||||
"spotify" in host -> "spotify"
|
||||
"apple" in host -> "apple_music"
|
||||
"music.youtube" in host -> "youtube_music"
|
||||
"youtube" in host || "youtu.be" in host -> "youtube"
|
||||
"deezer" in host -> "deezer"
|
||||
"tidal" in host -> "tidal"
|
||||
"soundcloud" in host -> "soundcloud"
|
||||
"amazon" in host -> "amazon_music"
|
||||
"pandora" in host -> "pandora"
|
||||
else -> host.substringBefore('.')
|
||||
}
|
||||
}
|
||||
|
||||
private data class PlaybackSnapshot(
|
||||
val entry: QueueEntry?,
|
||||
val playerState: PlayerState,
|
||||
val positionMs: Long,
|
||||
val playerDurationMs: Long)
|
||||
|
||||
companion object {
|
||||
/** Tracks shorter than this are never scrobbled (Last.fm rule). */
|
||||
private const val MIN_TRACK_DURATION_MS = 30_000L
|
||||
|
||||
/** Listening threshold is capped at four minutes (Last.fm rule). */
|
||||
private const val MAX_SCROBBLE_THRESHOLD_MS = 4 * 60 * 1000L
|
||||
|
||||
/** Largest position delta credited as listening, covers position polling jitter. */
|
||||
private const val MAX_COUNTED_POSITION_DELTA_MS = 2_000L
|
||||
|
||||
/** Positions at or below this are treated as the start of a fresh play. */
|
||||
private const val RESTART_POSITION_THRESHOLD_MS = 5_000L
|
||||
|
||||
private const val UNKNOWN_PROVIDER = "unknown"
|
||||
private const val LOCAL_PROVIDER = "local"
|
||||
|
||||
private fun scrobbleThresholdMs(durationMs: Long): Long? {
|
||||
if (durationMs < MIN_TRACK_DURATION_MS) return null
|
||||
return minOf(durationMs / 2, MAX_SCROBBLE_THRESHOLD_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,8 +29,7 @@ class AlternativeTracksRepository(
|
||||
private val matchedTracksRepository: TrackSourceRepository,
|
||||
private val streamingUrlRepository: StreamingUrlRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val cacheManager: CacheManager,
|
||||
) : KoinComponent {
|
||||
private val cacheManager: CacheManager) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<AlternativeTracksRepository>()
|
||||
|
||||
|
||||
@ -51,13 +51,11 @@ data class CacheEntry(
|
||||
val filename: String,
|
||||
val sizeBytes: Long,
|
||||
val createdAtMs: Long,
|
||||
val contentType: String = "application/octet-stream",
|
||||
)
|
||||
val contentType: String = "application/octet-stream")
|
||||
|
||||
class CacheManager(
|
||||
private val paths: Paths,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
private val settingsRepository: SettingsRepository) {
|
||||
private val fileSystem: FileSystem = FileSystem.SYSTEM
|
||||
private val cacheMutex: Mutex = Mutex()
|
||||
private val json: Json = Json { ignoreUnknownKeys = true }
|
||||
@ -160,15 +158,13 @@ class CacheManager(
|
||||
trackId: String,
|
||||
filename: String,
|
||||
allBytes: ByteArray,
|
||||
contentType: String,
|
||||
) {
|
||||
contentType: String) {
|
||||
val entry = CacheEntry(
|
||||
trackId = trackId,
|
||||
filename = filename,
|
||||
sizeBytes = allBytes.size.toLong(),
|
||||
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
||||
contentType = contentType,
|
||||
)
|
||||
contentType = contentType)
|
||||
cacheMutex.withLock {
|
||||
val index = readCacheIndex()
|
||||
writeCacheIndex(CacheIndex(
|
||||
|
||||
@ -60,8 +60,7 @@ class LocalServer(
|
||||
private val streamingUrlRepository: StreamingUrlRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val cacheManager: CacheManager,
|
||||
private val remoteControlHandler: RemoteControlHandler,
|
||||
) : KoinComponent {
|
||||
private val remoteControlHandler: RemoteControlHandler) : KoinComponent {
|
||||
|
||||
val logger by injectLogger<LocalServer>()
|
||||
private val httpClient = HttpClient {
|
||||
@ -99,8 +98,7 @@ class LocalServer(
|
||||
audioPlayerQueue = audioPlayerQueue,
|
||||
isCachingEnabled = { cachedCacheEnabled.value },
|
||||
activePort = { activePort.value },
|
||||
scope = scope,
|
||||
)
|
||||
scope = scope)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@ -37,8 +37,7 @@ import kotlinx.serialization.json.Json
|
||||
@Serializable
|
||||
data class PluginTaggedAudioSource(
|
||||
val pluginId: String,
|
||||
val source: AudioSource.Basic,
|
||||
)
|
||||
val source: AudioSource.Basic)
|
||||
|
||||
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
|
||||
interface TrackSourceRepository {
|
||||
|
||||
@ -62,8 +62,7 @@ internal class StreamProxy(
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val isCachingEnabled: () -> Boolean,
|
||||
private val activePort: () -> Int?,
|
||||
private val scope: CoroutineScope,
|
||||
): KoinComponent {
|
||||
private val scope: CoroutineScope): KoinComponent {
|
||||
val logger by injectLogger<StreamProxy>()
|
||||
|
||||
companion object {
|
||||
@ -414,8 +413,7 @@ internal class StreamProxy(
|
||||
trackId = trackIdForCache,
|
||||
filename = cacheFilename,
|
||||
allBytes = allBytes,
|
||||
contentType = cacheContentType,
|
||||
)
|
||||
contentType = cacheContentType)
|
||||
scope.launch {
|
||||
cacheManager.evictIfNeeded()
|
||||
}
|
||||
|
||||
@ -48,28 +48,24 @@ data class CachedStreamUrl(
|
||||
// The id of the audio plugin that produced this entry. Guards against a stale entry
|
||||
// (e.g. a YouTube video URL) being served after the user switches to a different
|
||||
// audio plugin, which would otherwise play back completely unrelated content.
|
||||
val pluginId: String,
|
||||
)
|
||||
val pluginId: String)
|
||||
|
||||
private data class CachedAlternatives(
|
||||
val pluginId: String,
|
||||
val sources: List<AudioSource>,
|
||||
)
|
||||
val sources: List<AudioSource>)
|
||||
|
||||
data class StreamInfo(
|
||||
val url: String,
|
||||
val protocol: StreamProtocol,
|
||||
val codec: String,
|
||||
val container: String,
|
||||
)
|
||||
val container: String)
|
||||
|
||||
class StreamingUrlRepository(
|
||||
private val pluginManager: AudioPluginSource,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val matchedTracksRepository: TrackSourceRepository,
|
||||
private val settingsRepository: UserSettingsSource,
|
||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
|
||||
) : KoinComponent {
|
||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<StreamingUrlRepository>()
|
||||
private val streamUrlCacheMutex = Mutex()
|
||||
@ -133,8 +129,7 @@ class StreamingUrlRepository(
|
||||
val resolvedStream = selectPreferredAudioStream(
|
||||
stream.streams,
|
||||
preferredFormat,
|
||||
preferredQuality,
|
||||
)
|
||||
preferredQuality)
|
||||
if (resolvedStream != null) {
|
||||
val info = resolvedStream.toStreamInfo()
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||
@ -193,8 +188,7 @@ class StreamingUrlRepository(
|
||||
val resolvedStream = selectPreferredAudioStream(
|
||||
stream.streams,
|
||||
preferredFormat,
|
||||
preferredQuality,
|
||||
)
|
||||
preferredQuality)
|
||||
if (resolvedStream != null) {
|
||||
val info = resolvedStream.toStreamInfo()
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||
@ -207,15 +201,13 @@ class StreamingUrlRepository(
|
||||
url = normalizeManifestUrl(url, protocol),
|
||||
protocol = protocol,
|
||||
codec = codec,
|
||||
container = container,
|
||||
)
|
||||
container = container)
|
||||
|
||||
private suspend fun getCachedStreamUrl(
|
||||
trackId: String,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality,
|
||||
pluginId: String,
|
||||
): StreamInfo? {
|
||||
pluginId: String): StreamInfo? {
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
return streamUrlCacheMutex.withLock {
|
||||
val cached = streamUrlCache[trackId] ?: return@withLock null
|
||||
@ -241,8 +233,7 @@ class StreamingUrlRepository(
|
||||
preferredQuality: AudioQuality,
|
||||
source: AudioSource.Streamed,
|
||||
selectedStream: AudioStream,
|
||||
pluginId: String,
|
||||
) {
|
||||
pluginId: String) {
|
||||
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
|
||||
streamUrlCacheMutex.withLock {
|
||||
streamUrlCache[trackId] = CachedStreamUrl(
|
||||
@ -255,8 +246,7 @@ class StreamingUrlRepository(
|
||||
selectedStream = selectedStream,
|
||||
preferredFormat = preferredFormat,
|
||||
preferredQuality = preferredQuality,
|
||||
pluginId = pluginId,
|
||||
)
|
||||
pluginId = pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,8 +316,7 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
|
||||
internal fun selectPreferredAudioStream(
|
||||
streams: List<AudioStream>,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality,
|
||||
): AudioStream? = streams.minWithOrNull(
|
||||
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
|
||||
compareBy<AudioStream> { stream ->
|
||||
formatMismatchCount(stream, preferredFormat)
|
||||
}.thenBy { stream ->
|
||||
|
||||
@ -1,483 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
||||
|
||||
private val AutocompleteTextFieldMinHeight = 44.dp
|
||||
private val AutocompleteMenuDefaultMaxHeight = 300.dp
|
||||
private val AutocompleteMenuDefaultOffset = 4.dp
|
||||
|
||||
private data class ResolvedAutoCompleteTextFieldState(
|
||||
val background: Brush,
|
||||
val highlight: Color,
|
||||
val shape: Shape,
|
||||
val border: BaseUITheme.Border,
|
||||
val shadow: BaseUITheme.Shadow,
|
||||
val foreground: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun resolveTextFieldState(
|
||||
theme: BaseUITheme.TextFieldTheme,
|
||||
isFocused: Boolean,
|
||||
isHovered: Boolean,
|
||||
): ResolvedAutoCompleteTextFieldState {
|
||||
return when {
|
||||
isFocused -> ResolvedAutoCompleteTextFieldState(
|
||||
theme.background.focused,
|
||||
theme.highlight.focused,
|
||||
theme.shape.focused,
|
||||
theme.border.focused,
|
||||
theme.shadow.focused,
|
||||
theme.foreground.focused
|
||||
)
|
||||
|
||||
isHovered -> ResolvedAutoCompleteTextFieldState(
|
||||
theme.background.hovered,
|
||||
theme.highlight.hovered,
|
||||
theme.shape.hovered,
|
||||
theme.border.hovered,
|
||||
theme.shadow.hovered,
|
||||
theme.foreground.hovered
|
||||
)
|
||||
|
||||
else -> ResolvedAutoCompleteTextFieldState(
|
||||
theme.background.normal,
|
||||
theme.highlight.normal,
|
||||
theme.shape.normal,
|
||||
theme.border.normal,
|
||||
theme.shadow.normal,
|
||||
theme.foreground.normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> AutocompleteTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
items: List<T>,
|
||||
itemContent: @Composable (item: T, isSelected: Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onItemSelected: (T) -> Unit = {},
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
singleLine: Boolean = true,
|
||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
isError: Boolean = false,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
textStyle: TextStyle = TextStyle.Default,
|
||||
cursorBrush: Color? = null,
|
||||
onKeyEvent: ((KeyEvent) -> Boolean)? = null,
|
||||
menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight,
|
||||
menuOffset: Dp = AutocompleteMenuDefaultOffset,
|
||||
textFieldTheme: BaseUITheme.TextFieldTheme? = null,
|
||||
menuTheme: BaseUITheme.AutoCompleteMenuTheme? = null,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val baseTextFieldTheme = LocalBaseUITheme.current.textField
|
||||
val baseMenuTheme = LocalBaseUITheme.current.autoCompleteMenuTheme
|
||||
val resolvedTextFieldTheme = textFieldTheme ?: baseTextFieldTheme
|
||||
val resolvedMenuTheme = menuTheme ?: baseMenuTheme
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
|
||||
var isMenuOpen by remember { mutableStateOf(false) }
|
||||
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val menuListState = rememberLazyListState()
|
||||
val textFieldFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val menuExpanded = isMenuOpen && items.isNotEmpty()
|
||||
|
||||
LaunchedEffect(isFocused) {
|
||||
if (isFocused) {
|
||||
isMenuOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(value) {
|
||||
if (selectedIndex != -1) selectedIndex = -1
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedIndex) {
|
||||
if (selectedIndex >= 0 && menuExpanded) {
|
||||
menuListState.scrollToItem(selectedIndex)
|
||||
}
|
||||
}
|
||||
|
||||
val wrappedKeyboardActions = remember(keyboardActions, menuExpanded, selectedIndex, items) {
|
||||
KeyboardActions(
|
||||
onSearch = {
|
||||
if (menuExpanded && selectedIndex in items.indices) {
|
||||
onItemSelected(items[selectedIndex])
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
} else {
|
||||
keyboardActions.onSearch?.invoke(this)
|
||||
}
|
||||
},
|
||||
onDone = keyboardActions.onDone,
|
||||
onGo = keyboardActions.onGo,
|
||||
onNext = keyboardActions.onNext,
|
||||
onPrevious = keyboardActions.onPrevious,
|
||||
onSend = keyboardActions.onSend,
|
||||
)
|
||||
}
|
||||
|
||||
val fieldState = resolveTextFieldState(resolvedTextFieldTheme, isFocused, isHovered)
|
||||
|
||||
val resolvedBorder = if (isError) {
|
||||
BaseUITheme.Border(MaterialTheme.colorScheme.error, fieldState.border.width)
|
||||
} else {
|
||||
fieldState.border
|
||||
}
|
||||
|
||||
val contentColor = when {
|
||||
!enabled -> fieldState.foreground.copy(alpha = 0.38f)
|
||||
else -> fieldState.foreground
|
||||
}
|
||||
|
||||
val cursor = cursorBrush?.let { SolidColor(it) } ?: resolvedTextFieldTheme.cursor
|
||||
|
||||
fun navigate(delta: Int) {
|
||||
if (items.isEmpty()) return
|
||||
selectedIndex = ((selectedIndex + delta) % items.size + items.size) % items.size
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = label != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
label?.invoke()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { textFieldSize = it }
|
||||
.defaultMinSize(minHeight = AutocompleteTextFieldMinHeight)
|
||||
.then(
|
||||
if (fieldState.shadow.elevation > 0.dp) {
|
||||
Modifier.shadow(
|
||||
elevation = fieldState.shadow.elevation,
|
||||
shape = fieldState.shape,
|
||||
ambientColor = fieldState.shadow.ambientColor,
|
||||
spotColor = fieldState.shadow.spotColor,
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.clip(fieldState.shape)
|
||||
.background(fieldState.background, fieldState.shape)
|
||||
.border(
|
||||
BorderStroke(resolvedBorder.width, resolvedBorder.color),
|
||||
fieldState.shape
|
||||
)
|
||||
.highlight(fieldState.highlight)
|
||||
.padding(resolvedTextFieldTheme.padding),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (leadingIcon != null) {
|
||||
leadingIcon()
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.hoverable(
|
||||
interactionSource = interactionSource,
|
||||
enabled = enabled,
|
||||
)
|
||||
.focusRequester(textFieldFocusRequester)
|
||||
.onFocusChanged { state ->
|
||||
if (!state.isFocused) {
|
||||
isMenuOpen = false
|
||||
selectedIndex = -1
|
||||
}
|
||||
}
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (onKeyEvent?.invoke(event) == true) {
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
if (!menuExpanded) return@onPreviewKeyEvent false
|
||||
when (event.key) {
|
||||
Key.DirectionDown -> {
|
||||
navigate(1)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionUp -> {
|
||||
if (selectedIndex >= 0) {
|
||||
navigate(-1)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
|
||||
Key.Enter -> {
|
||||
if (selectedIndex in items.indices) {
|
||||
onItemSelected(items[selectedIndex])
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
Key.Escape -> {
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
textStyle = textStyle.copy(color = contentColor),
|
||||
cursorBrush = cursor,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = wrappedKeyboardActions,
|
||||
singleLine = singleLine,
|
||||
maxLines = maxLines,
|
||||
visualTransformation = visualTransformation,
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = { innerTextField ->
|
||||
if (value.isEmpty() && placeholder != null && !isFocused) {
|
||||
CompositionLocalProvider(LocalTextStyle provides textStyle) {
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
placeholder()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (trailingIcon != null) {
|
||||
trailingIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (menuExpanded && textFieldSize.width > 0) {
|
||||
val textFieldHeightPx = textFieldSize.height
|
||||
val menuOffsetPx = with(density) { menuOffset.roundToPx() }
|
||||
val textFieldWidthDp = with(density) { textFieldSize.width.toDp() }
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
|
||||
properties = PopupProperties(focusable = false),
|
||||
onDismissRequest = {
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(textFieldWidthDp)
|
||||
.heightIn(max = menuMaxHeight)
|
||||
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
|
||||
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
|
||||
.clip(resolvedMenuTheme.shape),
|
||||
) {
|
||||
LazyColumn(
|
||||
state = menuListState,
|
||||
modifier = Modifier
|
||||
.heightIn(max = menuMaxHeight),
|
||||
) {
|
||||
items(items.size) { index ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onItemSelected(items[index])
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
textFieldFocusRequester.requestFocus()
|
||||
},
|
||||
) {
|
||||
itemContent(items[index], selectedIndex == index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AutocompleteTextFieldPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
val artists = remember {
|
||||
listOf(
|
||||
"Twenty One Pilots",
|
||||
"The Beatles",
|
||||
"Adele",
|
||||
"Drake",
|
||||
"Taylor Swift",
|
||||
"Arctic Monkeys",
|
||||
"Billie Eilish",
|
||||
)
|
||||
}
|
||||
var value by remember { mutableStateOf("") }
|
||||
|
||||
AutocompleteTextField(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it
|
||||
},
|
||||
items = artists.filter {
|
||||
it.contains(value, ignoreCase = true) && value.isNotEmpty()
|
||||
},
|
||||
onItemSelected = { selected ->
|
||||
value = selected
|
||||
},
|
||||
placeholder = { Text("Search artists...") },
|
||||
leadingIcon = {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = "Search",
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
itemContent = { item, isSelected ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = item,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,844 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
|
||||
private val BadgeShape = RoundedCornerShape(11.dp)
|
||||
private const val DisabledContentAlpha = 0.38f
|
||||
|
||||
private val ButtonMinHeight = 40.dp
|
||||
private val SquareButtonSize = 40.dp
|
||||
|
||||
private data class ResolvedButtonState(
|
||||
val colors: BaseUITheme.ButtonColors,
|
||||
val shape: Shape,
|
||||
val shadow: BaseUITheme.Shadow,
|
||||
val border: BaseUITheme.Border,
|
||||
val padding: PaddingValues,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun resolveButtonState(
|
||||
style: BaseUITheme.ButtonStyle,
|
||||
isPressed: Boolean,
|
||||
isHovered: Boolean,
|
||||
): ResolvedButtonState {
|
||||
return when {
|
||||
isPressed -> ResolvedButtonState(
|
||||
style.colors.pressed,
|
||||
style.shape.pressed,
|
||||
style.shadow.pressed,
|
||||
style.border.pressed,
|
||||
style.padding.pressed
|
||||
)
|
||||
|
||||
isHovered -> ResolvedButtonState(
|
||||
style.colors.hovered,
|
||||
style.shape.hovered,
|
||||
style.shadow.hovered,
|
||||
style.border.hovered,
|
||||
style.padding.hovered
|
||||
)
|
||||
|
||||
else -> ResolvedButtonState(
|
||||
style.colors.normal,
|
||||
style.shape.normal,
|
||||
style.shadow.normal,
|
||||
style.border.normal,
|
||||
style.padding.normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.applyShadow(
|
||||
shadow: BaseUITheme.Shadow,
|
||||
shape: Shape,
|
||||
): Modifier {
|
||||
return if (shadow.elevation > 0.dp) {
|
||||
this.shadow(
|
||||
elevation = shadow.elevation,
|
||||
shape = shape,
|
||||
ambientColor = shadow.ambientColor,
|
||||
spotColor = shadow.spotColor,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OutlineButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
hoverOnly: Boolean = false,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.buttons.outline
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val state = resolveButtonState(style, isPressed, isHovered)
|
||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = ButtonMinHeight)
|
||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
||||
.graphicsLayer { translationY = lift.toPx() }
|
||||
.then(
|
||||
if (hoverOnly && !isHovered) Modifier else Modifier.applyShadow(
|
||||
state.shadow,
|
||||
state.shape
|
||||
)
|
||||
)
|
||||
.clip(state.shape)
|
||||
.then(
|
||||
if (hoverOnly && !isHovered) Modifier else Modifier.background(
|
||||
state.colors.background,
|
||||
state.shape
|
||||
)
|
||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||
)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
onClick = onClick,
|
||||
)
|
||||
.then(if (hoverOnly && !isHovered) Modifier else Modifier.highlight(state.colors.highlight))
|
||||
.padding(state.padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = {
|
||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||
content()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrimaryButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.buttons.primary
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val state = resolveButtonState(style, isPressed, isHovered)
|
||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = ButtonMinHeight)
|
||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
||||
.graphicsLayer { translationY = lift.toPx() }
|
||||
.applyShadow(state.shadow, state.shape)
|
||||
.clip(state.shape)
|
||||
.background(state.colors.background, state.shape)
|
||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
onClick = onClick,
|
||||
)
|
||||
.highlight(state.colors.highlight)
|
||||
.padding(state.padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||
Row(
|
||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SecondaryButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.buttons.secondary
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val state = resolveButtonState(style, isPressed, isHovered)
|
||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = ButtonMinHeight)
|
||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
||||
.graphicsLayer { translationY = lift.toPx() }
|
||||
.applyShadow(state.shadow, state.shape)
|
||||
.clip(state.shape)
|
||||
.background(state.colors.background, state.shape)
|
||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
onClick = onClick,
|
||||
)
|
||||
.highlight(state.colors.highlight)
|
||||
.padding(state.padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||
Row(
|
||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.iconButtons.outline
|
||||
OutlineButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
theme = style,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GhostButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.buttons.ghost
|
||||
OutlineButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
hoverOnly = true,
|
||||
theme = style,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GhostIconButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.iconButtons.ghost
|
||||
OutlineButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
hoverOnly = true,
|
||||
theme = style,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrimaryIconButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.iconButtons.primary
|
||||
PrimaryButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
theme = style,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SecondaryIconButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.iconButtons.secondary
|
||||
SecondaryButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
theme = style,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ButtonBadge(
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.buttons.outline
|
||||
val state = resolveButtonState(style, isPressed = false, isHovered = false)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.heightIn(min = 22.dp)
|
||||
.defaultMinSize(minWidth = 22.dp)
|
||||
.background(state.colors.background, BadgeShape)
|
||||
.border(1.dp, state.border.color, BadgeShape)
|
||||
.padding(horizontal = 7.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
color = state.colors.foreground.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.buttons.outline
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val state = resolveButtonState(style, isPressed, isHovered)
|
||||
val overlay = when {
|
||||
isPressed -> state.colors.background
|
||||
isHovered -> state.colors.background
|
||||
else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
|
||||
}
|
||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = ButtonMinHeight)
|
||||
.graphicsLayer { translationY = lift.toPx() }
|
||||
.background(overlay)
|
||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(state.padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupIconButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.buttons.outline
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val state = resolveButtonState(style, isPressed, isHovered)
|
||||
val overlay = when {
|
||||
isPressed -> state.colors.background
|
||||
isHovered -> state.colors.background
|
||||
else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
|
||||
}
|
||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(SquareButtonSize)
|
||||
.graphicsLayer { translationY = lift.toPx() }
|
||||
.background(overlay)
|
||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
onClick = onClick,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ButtonGroup(
|
||||
modifier: Modifier = Modifier,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.buttons.outline
|
||||
val state = resolveButtonState(style, isPressed = false, isHovered = false)
|
||||
val groupShape = state.shape
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = ButtonMinHeight)
|
||||
.applyShadow(state.shadow, groupShape),
|
||||
shape = groupShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(state.border.width, state.border.color),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.background(state.colors.background, groupShape),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ButtonGroupDivider(
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val style = theme ?: baseTheme.buttons.outline
|
||||
val state = resolveButtonState(style, isPressed = false, isHovered = false)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.heightIn(min = 20.dp)
|
||||
.background(state.border.color),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ButtonsRow1Preview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlineButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxShare,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
"Copy link",
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
OutlineButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Login", maxLines = 1, softWrap = false)
|
||||
}
|
||||
PrimaryButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
"Sign Up",
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ButtonsRow2Preview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ButtonGroup {
|
||||
GroupButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxDocumentText,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Documents", maxLines = 1, softWrap = false)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
GroupButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxShare,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Export", maxLines = 1, softWrap = false)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
ButtonGroup {
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.ArrowLeft3,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxNext,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ButtonsRow3Preview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlineButton(onClick = {}) {
|
||||
Text("Cancel", maxLines = 1, softWrap = false)
|
||||
}
|
||||
PrimaryButton(onClick = {}) {
|
||||
Text(
|
||||
"Done",
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMagic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
OutlineButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxHeart,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Like", maxLines = 1, softWrap = false)
|
||||
ButtonBadge(count = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ButtonsRow4Preview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ButtonGroup {
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.ArrowLeft3,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxNext,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlineButton(onClick = {}) {
|
||||
Text("Forward", maxLines = 1, softWrap = false)
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxArrowSquareUp,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ButtonStylesPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlineButton(onClick = {}) {
|
||||
Text("Outline", maxLines = 1, softWrap = false)
|
||||
}
|
||||
SecondaryButton(onClick = {}) {
|
||||
Text("Secondary", maxLines = 1, softWrap = false)
|
||||
}
|
||||
PrimaryButton(onClick = {}) {
|
||||
Text(
|
||||
"Primary",
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class VariableIconButtonVariant {
|
||||
Outline,
|
||||
Ghost,
|
||||
Primary,
|
||||
Secondary,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VariableIconButton(
|
||||
variant: VariableIconButtonVariant = VariableIconButtonVariant.Outline,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ButtonStyle? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
when (variant) {
|
||||
VariableIconButtonVariant.Outline -> IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
theme = theme,
|
||||
content = content,
|
||||
)
|
||||
|
||||
VariableIconButtonVariant.Ghost -> GhostIconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
theme = theme,
|
||||
content = content,
|
||||
)
|
||||
|
||||
VariableIconButtonVariant.Primary -> PrimaryIconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
theme = theme,
|
||||
content = content,
|
||||
)
|
||||
|
||||
VariableIconButtonVariant.Secondary -> SecondaryIconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
theme = theme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun Card(
|
||||
modifier: Modifier = Modifier,
|
||||
theme: BaseUITheme.CardTheme? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val cardTheme = theme ?: LocalBaseUITheme.current.card
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (cardTheme.shadow.elevation > 0.dp) {
|
||||
Modifier.shadow(
|
||||
elevation = cardTheme.shadow.elevation,
|
||||
shape = cardTheme.shape,
|
||||
ambientColor = cardTheme.shadow.ambientColor,
|
||||
spotColor = cardTheme.shadow.spotColor,
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.clip(cardTheme.shape)
|
||||
.background(cardTheme.background, cardTheme.shape)
|
||||
.border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape)
|
||||
.padding(cardTheme.padding),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@androidx.compose.ui.tooling.preview.Preview
|
||||
private fun CardPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Card {
|
||||
Box(modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
enum class CheckBoxState {
|
||||
SELECTED,
|
||||
UNSELECTED,
|
||||
INDETERMINATE,
|
||||
@Deprecated("Use INDETERMINATE", ReplaceWith("CheckBoxState.INDETERMINATE"))
|
||||
CLEAR;
|
||||
}
|
||||
|
||||
private val CheckBoxSize = 22.dp
|
||||
|
||||
private data class ResolvedCheckBoxState(
|
||||
val colors: BaseUITheme.ButtonColors,
|
||||
val shape: Shape,
|
||||
val shadow: BaseUITheme.Shadow,
|
||||
val border: BaseUITheme.Border,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun resolveCheckBoxState(
|
||||
style: BaseUITheme.ButtonStyle,
|
||||
isPressed: Boolean,
|
||||
isHovered: Boolean,
|
||||
): ResolvedCheckBoxState {
|
||||
return when {
|
||||
isPressed -> ResolvedCheckBoxState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed)
|
||||
isHovered -> ResolvedCheckBoxState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered)
|
||||
else -> ResolvedCheckBoxState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckBox(
|
||||
state: CheckBoxState = CheckBoxState.UNSELECTED,
|
||||
onClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
theme: BaseUITheme.CheckBoxTheme? = null,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current.checkBox
|
||||
val checkBoxTheme = theme ?: baseTheme
|
||||
val source = interactionSource ?: remember { MutableInteractionSource() }
|
||||
val isPressed by source.collectIsPressedAsState()
|
||||
val isHovered by source.collectIsHoveredAsState()
|
||||
|
||||
val isSelected = state == CheckBoxState.SELECTED
|
||||
val isIndeterminate = state == CheckBoxState.INDETERMINATE
|
||||
val isFilled = isSelected || isIndeterminate
|
||||
|
||||
val style = if (isFilled) checkBoxTheme.selected else checkBoxTheme.unselected
|
||||
val resolved = resolveCheckBoxState(style, isPressed, isHovered)
|
||||
val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp
|
||||
|
||||
val borderColor = when {
|
||||
!enabled -> resolved.colors.foreground.copy(alpha = 0.38f)
|
||||
else -> resolved.border.color
|
||||
}
|
||||
|
||||
val checkProgress by animateFloatAsState(
|
||||
targetValue = if (isSelected) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 180),
|
||||
label = "checkProgress",
|
||||
)
|
||||
val dashProgress by animateFloatAsState(
|
||||
targetValue = if (isIndeterminate) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "dashProgress",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(CheckBoxSize)
|
||||
.graphicsLayer { translationY = lift.toPx() }
|
||||
.then(
|
||||
if (resolved.shadow.elevation > 0.dp) {
|
||||
Modifier.shadow(
|
||||
elevation = resolved.shadow.elevation,
|
||||
shape = resolved.shape,
|
||||
ambientColor = resolved.shadow.ambientColor,
|
||||
spotColor = resolved.shadow.spotColor,
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.clip(resolved.shape)
|
||||
.background(resolved.colors.background, resolved.shape)
|
||||
.border(BorderStroke(0.5.dp, borderColor), resolved.shape)
|
||||
.highlight(resolved.colors.highlight)
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier
|
||||
.hoverable(interactionSource = source, enabled = enabled)
|
||||
.clickable(
|
||||
interactionSource = source,
|
||||
indication = ripple(),
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.graphicsLayer { alpha = checkProgress },
|
||||
) {
|
||||
androidx.compose.foundation.Canvas(modifier = Modifier.size(14.dp)) {
|
||||
val stroke = 2.dp.toPx()
|
||||
val path = Path().apply {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
moveTo(w * 0.2f, h * 0.52f)
|
||||
lineTo(w * 0.42f, h * 0.74f)
|
||||
lineTo(w * 0.82f, h * 0.28f)
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = checkBoxTheme.checkmarkColor,
|
||||
style = Stroke(
|
||||
width = stroke,
|
||||
cap = StrokeCap.Round,
|
||||
join = StrokeJoin.Round,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (isIndeterminate) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 10.dp, height = 2.dp)
|
||||
.graphicsLayer { alpha = dashProgress }
|
||||
.clip(RoundedCornerShape(1.dp))
|
||||
.background(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
checkBoxTheme.checkmarkColor,
|
||||
checkBoxTheme.checkmarkColor.copy(alpha = 0.92f),
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun CheckBoxPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CheckBox(state = CheckBoxState.SELECTED, onClick = {})
|
||||
Text("Selected", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CheckBox(state = CheckBoxState.UNSELECTED, onClick = {})
|
||||
Text("Unselected", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CheckBox(state = CheckBoxState.INDETERMINATE, onClick = {})
|
||||
Text("Indeterminate", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CheckBox(state = CheckBoxState.SELECTED, onClick = {}, enabled = false)
|
||||
Text("Disabled", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,171 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val ChipTabMinHeight = 36.dp
|
||||
|
||||
private data class ResolvedChipState(
|
||||
val colors: BaseUITheme.ButtonColors,
|
||||
val shape: Shape,
|
||||
val shadow: BaseUITheme.Shadow,
|
||||
val border: BaseUITheme.Border,
|
||||
val padding: androidx.compose.foundation.layout.PaddingValues,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun resolveChipState(
|
||||
style: BaseUITheme.ButtonStyle,
|
||||
isPressed: Boolean,
|
||||
isHovered: Boolean,
|
||||
): ResolvedChipState {
|
||||
return when {
|
||||
isPressed -> ResolvedChipState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed, style.padding.pressed)
|
||||
isHovered -> ResolvedChipState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered, style.padding.hovered)
|
||||
else -> ResolvedChipState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal, style.padding.normal)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.applyChipShadow(
|
||||
shadow: BaseUITheme.Shadow,
|
||||
shape: Shape,
|
||||
): Modifier {
|
||||
return if (shadow.elevation > 0.dp) {
|
||||
this.shadow(
|
||||
elevation = shadow.elevation,
|
||||
shape = shape,
|
||||
ambientColor = shadow.ambientColor,
|
||||
spotColor = shadow.spotColor,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChipTab(
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.ChipTabTheme? = null,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current.chipTab
|
||||
val chipTheme = theme ?: baseTheme
|
||||
val style = if (selected) chipTheme.selected else chipTheme.unselected
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val state = resolveChipState(style, isPressed, isHovered)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = ChipTabMinHeight)
|
||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
||||
.applyChipShadow(state.shadow, state.shape)
|
||||
.clip(state.shape)
|
||||
.background(state.colors.background, state.shape)
|
||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
onClick = onClick,
|
||||
)
|
||||
.highlight(state.colors.highlight)
|
||||
.padding(state.padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides state.colors.foreground,
|
||||
LocalTextStyle provides LocalTextStyle.current.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChipTab(
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
theme: BaseUITheme.ChipTabTheme? = null,
|
||||
) {
|
||||
ChipTab(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
theme = theme,
|
||||
) {
|
||||
if (leadingIcon != null) {
|
||||
leadingIcon()
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,186 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
private val DefaultDialogMaxWidth: Dp = 440.dp
|
||||
|
||||
@Composable
|
||||
fun ThemedDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
actions: @Composable (() -> Unit)? = null,
|
||||
theme: BaseUITheme.DialogTheme? = null,
|
||||
properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val dialogTheme = theme ?: LocalBaseUITheme.current.dialog
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = properties,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = scaleIn(
|
||||
initialScale = 0.92f,
|
||||
animationSpec = tween(280),
|
||||
) + fadeIn(animationSpec = tween(200)),
|
||||
exit = scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(180),
|
||||
) + fadeOut(animationSpec = tween(150)),
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = DefaultDialogMaxWidth)
|
||||
.shadow(
|
||||
elevation = dialogTheme.shadow.elevation,
|
||||
shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) },
|
||||
ambientColor = dialogTheme.shadow.ambientColor,
|
||||
spotColor = dialogTheme.shadow.spotColor,
|
||||
)
|
||||
.clip(dialogTheme.shape)
|
||||
.background(dialogTheme.background, dialogTheme.shape)
|
||||
.border(
|
||||
BorderStroke(dialogTheme.border.width, dialogTheme.border.color),
|
||||
dialogTheme.shape
|
||||
)
|
||||
.highlight(Color.White.copy(alpha = 0.08f)),
|
||||
) {
|
||||
Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) {
|
||||
if (title != null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, top = 24.dp),
|
||||
) {
|
||||
title()
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(
|
||||
start = 24.dp,
|
||||
end = 24.dp,
|
||||
top = if (title == null) 24.dp else 0.dp,
|
||||
),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (actions != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ThemedDialogPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
var show by remember { mutableStateOf(true) }
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
PrimaryButton(onClick = { show = true }) {
|
||||
Text("Open Dialog")
|
||||
}
|
||||
if (show) {
|
||||
ThemedDialog(onDismissRequest = { show = false }) {
|
||||
Text(
|
||||
"This is a themed dialog with glass-like gradient, shadow, and scale animation.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,172 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun DropdownMenu(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
offset: DpOffset = DpOffset(0.dp, 4.dp),
|
||||
theme: BaseUITheme.DropdownMenuTheme? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu
|
||||
|
||||
androidx.compose.material3.DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
offset = offset,
|
||||
modifier = modifier
|
||||
.widthIn(min = 180.dp)
|
||||
.shadow(
|
||||
elevation = menuTheme.shadow.elevation,
|
||||
shape = menuTheme.shape,
|
||||
ambientColor = menuTheme.shadow.ambientColor,
|
||||
spotColor = menuTheme.shadow.spotColor,
|
||||
)
|
||||
.clip(menuTheme.shape)
|
||||
.background(menuTheme.background, menuTheme.shape)
|
||||
.border(
|
||||
BorderStroke(menuTheme.border.width, menuTheme.border.color),
|
||||
menuTheme.shape,
|
||||
),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DropdownMenuItem(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: ImageVector? = null,
|
||||
selected: Boolean = false,
|
||||
theme: BaseUITheme.DropdownMenuTheme? = null,
|
||||
) {
|
||||
val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(menuTheme.shape)
|
||||
.hoverable(interactionSource)
|
||||
.then(
|
||||
if (isHovered && enabled) {
|
||||
Modifier.background(menuTheme.itemHoverBackground)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (isHovered && enabled) {
|
||||
Modifier.highlight(menuTheme.itemHighlight)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(menuTheme.itemPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (leadingIcon != null) {
|
||||
Icon(
|
||||
imageVector = leadingIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (enabled) {
|
||||
menuTheme.itemForeground
|
||||
} else {
|
||||
menuTheme.itemForeground.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (enabled) {
|
||||
menuTheme.itemForeground
|
||||
} else {
|
||||
menuTheme.itemForeground.copy(alpha = 0.38f)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DropdownMenuDivider(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(0.5.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,132 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ListRowTile(
|
||||
selected: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
subtitle: @Composable (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val rowTheme = LocalBaseUITheme.current.listRowTile
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
|
||||
val background = when {
|
||||
isPressed -> rowTheme.background.pressed
|
||||
isHovered -> rowTheme.background.hovered
|
||||
isFocused -> rowTheme.background.focused
|
||||
selected -> rowTheme.background.selected
|
||||
else -> rowTheme.background.normal
|
||||
}
|
||||
val shape = when {
|
||||
isPressed -> rowTheme.shape.pressed
|
||||
isHovered -> rowTheme.shape.hovered
|
||||
isFocused -> rowTheme.shape.focused
|
||||
selected -> rowTheme.shape.selected
|
||||
else -> rowTheme.shape.normal
|
||||
}
|
||||
val borderDef = when {
|
||||
isPressed -> rowTheme.border.pressed
|
||||
isHovered -> rowTheme.border.hovered
|
||||
isFocused -> rowTheme.border.focused
|
||||
selected -> rowTheme.border.selected
|
||||
else -> rowTheme.border.normal
|
||||
}
|
||||
val shadowDef = when {
|
||||
isPressed -> rowTheme.shadow.pressed
|
||||
isHovered -> rowTheme.shadow.hovered
|
||||
isFocused -> rowTheme.shadow.focused
|
||||
selected -> rowTheme.shadow.selected
|
||||
else -> rowTheme.shadow.normal
|
||||
}
|
||||
|
||||
val mod = modifier
|
||||
.fillMaxWidth()
|
||||
.shadow(shadowDef.elevation, shape, shadowDef.clip, shadowDef.ambientColor, shadowDef.spotColor)
|
||||
.clip(shape)
|
||||
.border(borderDef.width, borderDef.color, shape)
|
||||
.background(background, shape)
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.padding(rowTheme.padding)
|
||||
|
||||
Row(
|
||||
modifier = mod,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (leading != null) {
|
||||
leading()
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (title != null) {
|
||||
title()
|
||||
}
|
||||
if (subtitle != null) {
|
||||
Spacer(Modifier.width(2.dp))
|
||||
subtitle()
|
||||
}
|
||||
}
|
||||
if (trailing != null) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,177 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val RadioSize = 22.dp
|
||||
private val RadioDotSize = 10.dp
|
||||
|
||||
private data class ResolvedRadioState(
|
||||
val colors: BaseUITheme.ButtonColors,
|
||||
val shape: Shape,
|
||||
val shadow: BaseUITheme.Shadow,
|
||||
val border: BaseUITheme.Border,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun resolveRadioState(
|
||||
style: BaseUITheme.ButtonStyle,
|
||||
isPressed: Boolean,
|
||||
isHovered: Boolean,
|
||||
): ResolvedRadioState {
|
||||
return when {
|
||||
isPressed -> ResolvedRadioState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed)
|
||||
isHovered -> ResolvedRadioState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered)
|
||||
else -> ResolvedRadioState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Radio(
|
||||
selected: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
theme: BaseUITheme.CheckBoxTheme? = null,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current.checkBox
|
||||
val radioTheme = theme ?: baseTheme
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val isPressed by source.collectIsPressedAsState()
|
||||
val isHovered by source.collectIsHoveredAsState()
|
||||
|
||||
val style = if (selected) radioTheme.selected else radioTheme.unselected
|
||||
val resolved = resolveRadioState(style, isPressed, isHovered)
|
||||
val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp
|
||||
|
||||
val dotProgress by animateFloatAsState(
|
||||
targetValue = if (selected) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 180),
|
||||
label = "radioDot",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(RadioSize)
|
||||
.graphicsLayer {
|
||||
translationY = lift.toPx()
|
||||
shape = CircleShape
|
||||
clip = true
|
||||
}
|
||||
.then(
|
||||
if (resolved.shadow.elevation > 0.dp) {
|
||||
Modifier.shadow(
|
||||
elevation = resolved.shadow.elevation,
|
||||
shape = CircleShape,
|
||||
ambientColor = resolved.shadow.ambientColor,
|
||||
spotColor = resolved.shadow.spotColor,
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.background(resolved.colors.background, CircleShape)
|
||||
.border(BorderStroke(0.5.dp, resolved.border.color), CircleShape)
|
||||
.highlight(resolved.colors.highlight)
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier
|
||||
.hoverable(interactionSource = source, enabled = enabled)
|
||||
.clickable(
|
||||
interactionSource = source,
|
||||
indication = ripple(bounded = true, radius = RadioSize / 2),
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(RadioDotSize * dotProgress)
|
||||
.clip(CircleShape)
|
||||
.background(radioTheme.checkmarkColor, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RadioPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Radio(selected = true, onClick = {})
|
||||
Text("Selected", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Radio(selected = false, onClick = {})
|
||||
Text("Unselected", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,313 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val SliderTrackHeight = 7.dp
|
||||
private val SliderThumbSize = 24.dp
|
||||
private val SliderMinHeight = 32.dp
|
||||
|
||||
@Composable
|
||||
fun Slider(
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||
steps: Int = 0,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
onValueChangeFinished: () -> Unit = {},
|
||||
theme: BaseUITheme.SliderTheme? = null,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current.slider
|
||||
val sliderTheme = theme ?: baseTheme
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val density = LocalDensity.current
|
||||
var sliderWidth by remember { mutableIntStateOf(0) }
|
||||
|
||||
val range = valueRange.endInclusive - valueRange.start
|
||||
val fraction = if (range > 0f) ((value - valueRange.start) / range).coerceIn(0f, 1f) else 0f
|
||||
|
||||
val stateColor = when {
|
||||
isPressed -> sliderTheme.trackActiveColor.pressed
|
||||
isHovered -> sliderTheme.trackActiveColor.hovered
|
||||
else -> sliderTheme.trackActiveColor.normal
|
||||
}
|
||||
val inactiveColor = when {
|
||||
isPressed -> sliderTheme.trackInactiveColor.pressed
|
||||
isHovered -> sliderTheme.trackInactiveColor.hovered
|
||||
else -> sliderTheme.trackInactiveColor.normal
|
||||
}
|
||||
val thumbColor = when {
|
||||
isPressed -> sliderTheme.thumbColor.pressed
|
||||
isHovered -> sliderTheme.thumbColor.hovered
|
||||
else -> sliderTheme.thumbColor.normal
|
||||
}
|
||||
val thumbShadowState = when {
|
||||
isPressed -> sliderTheme.thumbShadow.pressed
|
||||
isHovered -> sliderTheme.thumbShadow.hovered
|
||||
else -> sliderTheme.thumbShadow.normal
|
||||
}
|
||||
|
||||
val trackActiveColor = if (enabled) stateColor else stateColor.copy(alpha = 0.38f)
|
||||
val trackInactiveColor = if (enabled) inactiveColor else inactiveColor.copy(alpha = 0.38f)
|
||||
val resolvedThumbColor = if (enabled) thumbColor else thumbColor.copy(alpha = 0.38f)
|
||||
|
||||
val thumbScale by animateFloatAsState(
|
||||
targetValue = when {
|
||||
isPressed -> 1.15f
|
||||
isHovered -> 1.08f
|
||||
else -> 1f
|
||||
},
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "thumbScale",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = SliderMinHeight)
|
||||
.onSizeChanged { sliderWidth = it.width }
|
||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
||||
.pointerInput(enabled, valueRange, steps) {
|
||||
if (!enabled) return@pointerInput
|
||||
|
||||
val stepSize = if (steps > 0) range / (steps + 1) else 0f
|
||||
val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f }
|
||||
|
||||
fun valueFromX(x: Float): Float {
|
||||
val width = size.width.toFloat()
|
||||
val trackWidth = width - 2 * thumbHalfPx
|
||||
if (trackWidth <= 0f) return valueRange.start
|
||||
val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start
|
||||
val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive)
|
||||
return if (steps > 0) {
|
||||
val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt()
|
||||
(valueRange.start + stepIndex * stepSize).coerceIn(
|
||||
valueRange.start,
|
||||
valueRange.endInclusive,
|
||||
)
|
||||
} else {
|
||||
coerced
|
||||
}
|
||||
}
|
||||
|
||||
detectTapGestures { offset ->
|
||||
onValueChange(valueFromX(offset.x))
|
||||
onValueChangeFinished()
|
||||
}
|
||||
}
|
||||
.pointerInput(enabled, valueRange, steps) {
|
||||
if (!enabled) return@pointerInput
|
||||
|
||||
val stepSize = if (steps > 0) range / (steps + 1) else 0f
|
||||
val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f }
|
||||
|
||||
fun valueFromX(x: Float): Float {
|
||||
val width = size.width.toFloat()
|
||||
val trackWidth = width - 2 * thumbHalfPx
|
||||
if (trackWidth <= 0f) return valueRange.start
|
||||
val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start
|
||||
val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive)
|
||||
return if (steps > 0) {
|
||||
val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt()
|
||||
(valueRange.start + stepIndex * stepSize).coerceIn(
|
||||
valueRange.start,
|
||||
valueRange.endInclusive,
|
||||
)
|
||||
} else {
|
||||
coerced
|
||||
}
|
||||
}
|
||||
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = { onValueChangeFinished() },
|
||||
) { change, _ ->
|
||||
change.consume()
|
||||
onValueChange(valueFromX(change.position.x))
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(SliderTrackHeight)
|
||||
.align(Alignment.CenterStart),
|
||||
) {
|
||||
val thumbHalfPx = SliderThumbSize.toPx() / 2f
|
||||
val trackY = size.height / 2f
|
||||
val trackStart = thumbHalfPx
|
||||
val trackEnd = size.width - thumbHalfPx
|
||||
val trackWidth = trackEnd - trackStart
|
||||
|
||||
if (trackWidth > 0f) {
|
||||
val trackRadius = CornerRadius(size.height / 2f)
|
||||
|
||||
drawRoundRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
trackInactiveColor.copy(alpha = 0.45f),
|
||||
trackInactiveColor,
|
||||
trackInactiveColor.copy(alpha = 0.7f),
|
||||
),
|
||||
),
|
||||
topLeft = Offset(trackStart, 0f),
|
||||
size = Size(trackWidth, size.height),
|
||||
cornerRadius = trackRadius,
|
||||
)
|
||||
drawLine(
|
||||
color = Color.White.copy(alpha = 0.15f),
|
||||
start = Offset(trackStart + size.height / 2f, 1.dp.toPx()),
|
||||
end = Offset(trackEnd - size.height / 2f, 1.dp.toPx()),
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
|
||||
val activeEnd = trackStart + trackWidth * fraction
|
||||
drawRoundRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
trackActiveColor.copy(alpha = 0.75f),
|
||||
trackActiveColor,
|
||||
trackActiveColor.copy(alpha = 0.9f),
|
||||
),
|
||||
),
|
||||
topLeft = Offset(trackStart, 0f),
|
||||
size = Size((activeEnd - trackStart).coerceAtLeast(0f), size.height),
|
||||
cornerRadius = trackRadius,
|
||||
)
|
||||
drawLine(
|
||||
color = Color.White.copy(alpha = 0.22f),
|
||||
start = Offset(trackStart + size.height / 2f, 1.dp.toPx()),
|
||||
end = Offset(activeEnd - size.height / 2f, 1.dp.toPx()),
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
|
||||
if (steps > 0 && enabled) {
|
||||
val stepSpacing = trackWidth / (steps + 1)
|
||||
for (i in 1 downTo steps) {
|
||||
val x = trackStart + stepSpacing * i
|
||||
drawCircle(
|
||||
color = if (x <= activeEnd) {
|
||||
sliderTheme.stepActiveColor
|
||||
} else {
|
||||
sliderTheme.stepInactiveColor
|
||||
},
|
||||
radius = 2.dp.toPx(),
|
||||
center = Offset(x, trackY),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(SliderThumbSize)
|
||||
.offset {
|
||||
val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f }
|
||||
val trackWidth = sliderWidth.toFloat() - 2 * thumbHalfPx
|
||||
val x = thumbHalfPx + trackWidth * fraction - thumbHalfPx
|
||||
IntOffset(x.roundToInt(), 0)
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = thumbScale
|
||||
scaleY = thumbScale
|
||||
}
|
||||
.shadow(
|
||||
elevation = thumbShadowState.elevation,
|
||||
shape = CircleShape,
|
||||
ambientColor = thumbShadowState.ambientColor,
|
||||
spotColor = thumbShadowState.spotColor,
|
||||
)
|
||||
.background(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.6f),
|
||||
resolvedThumbColor.copy(alpha = 0.95f),
|
||||
resolvedThumbColor,
|
||||
),
|
||||
center = Offset(0f, -0.55f),
|
||||
radius = 1.1f,
|
||||
),
|
||||
shape = CircleShape,
|
||||
)
|
||||
.border(
|
||||
width = 0.8.dp,
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.55f),
|
||||
Color.White.copy(alpha = 0.08f),
|
||||
),
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,243 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
|
||||
private val TextFieldMinHeight = 44.dp
|
||||
|
||||
private data class ResolvedTextFieldState(
|
||||
val background: Brush,
|
||||
val highlight: Color,
|
||||
val shape: Shape,
|
||||
val border: BaseUITheme.Border,
|
||||
val shadow: BaseUITheme.Shadow,
|
||||
val foreground: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun resolveTextFieldState(
|
||||
theme: BaseUITheme.TextFieldTheme,
|
||||
isFocused: Boolean,
|
||||
isHovered: Boolean,
|
||||
): ResolvedTextFieldState {
|
||||
return when {
|
||||
isFocused -> ResolvedTextFieldState(theme.background.focused, theme.highlight.focused, theme.shape.focused, theme.border.focused, theme.shadow.focused, theme.foreground.focused)
|
||||
isHovered -> ResolvedTextFieldState(theme.background.hovered, theme.highlight.hovered, theme.shape.hovered, theme.border.hovered, theme.shadow.hovered, theme.foreground.hovered)
|
||||
else -> ResolvedTextFieldState(theme.background.normal, theme.highlight.normal, theme.shape.normal, theme.border.normal, theme.shadow.normal, theme.foreground.normal)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
singleLine: Boolean = false,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
isError: Boolean = false,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
textStyle: TextStyle = TextStyle.Default,
|
||||
cursorBrush: Color? = null,
|
||||
theme: BaseUITheme.TextFieldTheme? = null,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current.textField
|
||||
val textFieldTheme = theme ?: baseTheme
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
|
||||
val state = resolveTextFieldState(textFieldTheme, isFocused, isHovered)
|
||||
|
||||
val resolvedBorder = if (isError) {
|
||||
BaseUITheme.Border(MaterialTheme.colorScheme.error, state.border.width)
|
||||
} else {
|
||||
state.border
|
||||
}
|
||||
|
||||
val contentColor = when {
|
||||
!enabled -> state.foreground.copy(alpha = 0.38f)
|
||||
else -> state.foreground
|
||||
}
|
||||
|
||||
val cursor = cursorBrush?.let { SolidColor(it) } ?: textFieldTheme.cursor
|
||||
|
||||
Column(modifier = modifier) {
|
||||
AnimatedVisibility(
|
||||
visible = label != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
label?.invoke()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minHeight = TextFieldMinHeight)
|
||||
.then(
|
||||
if (state.shadow.elevation > 0.dp) {
|
||||
Modifier.shadow(
|
||||
elevation = state.shadow.elevation,
|
||||
shape = state.shape,
|
||||
ambientColor = state.shadow.ambientColor,
|
||||
spotColor = state.shadow.spotColor,
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.clip(state.shape)
|
||||
.background(state.background, state.shape)
|
||||
.border(BorderStroke(resolvedBorder.width, resolvedBorder.color), state.shape)
|
||||
.highlight(state.highlight)
|
||||
.padding(textFieldTheme.padding),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (leadingIcon != null) {
|
||||
leadingIcon()
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.hoverable(interactionSource = interactionSource, enabled = enabled),
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
textStyle = textStyle.copy(color = contentColor),
|
||||
cursorBrush = cursor,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
singleLine = singleLine,
|
||||
maxLines = maxLines,
|
||||
visualTransformation = visualTransformation,
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = { innerTextField ->
|
||||
if (value.isEmpty() && placeholder != null && !isFocused) {
|
||||
CompositionLocalProvider(LocalTextStyle provides textStyle) {
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
placeholder()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (trailingIcon != null) {
|
||||
trailingIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun TextFieldPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
TextField(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
value = "Twenty One Pilots",
|
||||
onValueChange = {},
|
||||
placeholder = { Text("Placeholder") },
|
||||
label = {
|
||||
Text(
|
||||
"Search Field",
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = "Search Icon",
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Iconsax.IconsaxTrash,
|
||||
contentDescription = "Clear Icon",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,264 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val ToggleWidth = 44.dp
|
||||
private val ToggleHeight = 24.dp
|
||||
private val ThumbSize = 20.dp
|
||||
|
||||
private data class ResolvedToggleState(
|
||||
val colors: BaseUITheme.ButtonColors,
|
||||
val shape: Shape,
|
||||
val shadow: BaseUITheme.Shadow,
|
||||
val border: BaseUITheme.Border,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun resolveToggleState(
|
||||
style: BaseUITheme.ButtonStyle,
|
||||
isPressed: Boolean,
|
||||
isHovered: Boolean,
|
||||
): ResolvedToggleState {
|
||||
return when {
|
||||
isPressed -> ResolvedToggleState(
|
||||
style.colors.pressed,
|
||||
style.shape.pressed,
|
||||
style.shadow.pressed,
|
||||
style.border.pressed,
|
||||
)
|
||||
|
||||
isHovered -> ResolvedToggleState(
|
||||
style.colors.hovered,
|
||||
style.shape.hovered,
|
||||
style.shadow.hovered,
|
||||
style.border.hovered,
|
||||
)
|
||||
|
||||
else -> ResolvedToggleState(
|
||||
style.colors.normal,
|
||||
style.shape.normal,
|
||||
style.shadow.normal,
|
||||
style.border.normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Toggle(
|
||||
checked: Boolean,
|
||||
onCheckedChange: ((Boolean) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
theme: BaseUITheme.ToggleTheme? = null,
|
||||
) {
|
||||
val baseTheme = LocalBaseUITheme.current.toggle
|
||||
val toggleTheme = theme ?: baseTheme
|
||||
val source = interactionSource ?: remember { MutableInteractionSource() }
|
||||
val isPressed by source.collectIsPressedAsState()
|
||||
val isHovered by source.collectIsHoveredAsState()
|
||||
|
||||
val style = if (checked) toggleTheme.checked else toggleTheme.unchecked
|
||||
val resolved = resolveToggleState(style, isPressed, isHovered)
|
||||
|
||||
val thumbColor = when {
|
||||
isPressed -> toggleTheme.thumb.pressed
|
||||
isHovered -> toggleTheme.thumb.hovered
|
||||
else -> toggleTheme.thumb.normal
|
||||
}
|
||||
|
||||
val thumbShadowState = when {
|
||||
isPressed -> toggleTheme.thumbShadow.pressed
|
||||
isHovered -> toggleTheme.thumbShadow.hovered
|
||||
else -> toggleTheme.thumbShadow.normal
|
||||
}
|
||||
|
||||
val borderColor = when {
|
||||
!enabled -> resolved.colors.foreground.copy(alpha = 0.38f)
|
||||
else -> resolved.border.color
|
||||
}
|
||||
|
||||
val thumbProgress by animateFloatAsState(
|
||||
targetValue = if (checked) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "thumbProgress",
|
||||
)
|
||||
|
||||
val thumbScale by animateFloatAsState(
|
||||
targetValue = when {
|
||||
isPressed -> 0.92f
|
||||
isHovered -> 1.05f
|
||||
else -> 1f
|
||||
},
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "thumbScale",
|
||||
)
|
||||
|
||||
val lift = if (isHovered && !isPressed && onCheckedChange != null) (-1).dp else 0.dp
|
||||
val density = LocalDensity.current
|
||||
val thumbOffsetXPx by animateDpAsState(
|
||||
targetValue = with(density) {
|
||||
val trackWidth = ToggleWidth - ThumbSize
|
||||
max((thumbProgress * trackWidth.toPx()).toDp() - 4.dp, 2.dp)
|
||||
},
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "thumbOffsetX",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(ToggleWidth)
|
||||
.height(ToggleHeight)
|
||||
.graphicsLayer {
|
||||
translationY = lift.toPx()
|
||||
}
|
||||
.then(
|
||||
if (resolved.shadow.elevation > 0.dp) {
|
||||
Modifier.shadow(
|
||||
elevation = resolved.shadow.elevation,
|
||||
shape = resolved.shape,
|
||||
ambientColor = resolved.shadow.ambientColor,
|
||||
spotColor = resolved.shadow.spotColor,
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.clip(resolved.shape)
|
||||
.background(resolved.colors.background, resolved.shape)
|
||||
.border(BorderStroke(resolved.border.width, borderColor), resolved.shape)
|
||||
.highlight(resolved.colors.highlight)
|
||||
.then(
|
||||
if (onCheckedChange != null) {
|
||||
Modifier
|
||||
.hoverable(interactionSource = source, enabled = enabled)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = source,
|
||||
indication = null,
|
||||
onClick = { onCheckedChange(!checked) },
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = thumbOffsetXPx)
|
||||
.size(ThumbSize)
|
||||
.graphicsLayer {
|
||||
scaleX = thumbScale
|
||||
scaleY = thumbScale
|
||||
}
|
||||
.shadow(
|
||||
elevation = thumbShadowState.elevation,
|
||||
shape = CircleShape,
|
||||
ambientColor = thumbShadowState.ambientColor,
|
||||
spotColor = thumbShadowState.spotColor,
|
||||
)
|
||||
.background(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.6f),
|
||||
thumbColor.copy(alpha = 0.95f),
|
||||
thumbColor,
|
||||
),
|
||||
center = Offset(0f, -0.55f),
|
||||
radius = 1.1f,
|
||||
),
|
||||
shape = CircleShape,
|
||||
)
|
||||
.border(
|
||||
width = 0.8.dp,
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.55f),
|
||||
Color.White.copy(alpha = 0.08f),
|
||||
),
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun TogglePreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Toggle(checked = true, onCheckedChange = {})
|
||||
Toggle(checked = false, onCheckedChange = {})
|
||||
Toggle(checked = true, onCheckedChange = null)
|
||||
Toggle(checked = false, onCheckedChange = null, enabled = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
fun Modifier.highlight(color: Color): Modifier =
|
||||
drawWithCache {
|
||||
val highlightBrush = Brush.verticalGradient(
|
||||
colors = listOf(color, Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.5f,
|
||||
)
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
brush = highlightBrush,
|
||||
topLeft = androidx.compose.ui.geometry.Offset.Zero,
|
||||
size = size,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,13 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumDialog
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -25,75 +32,54 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import com.composeunstyled.SheetDetent
|
||||
import com.composeunstyled.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdaptiveDialogBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
breakpointDp: Float = 600f,
|
||||
actions: @Composable (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||
content: @Composable () -> Unit) {
|
||||
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
|
||||
|
||||
if (isLargeScreen) {
|
||||
ThemedDialog(
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = onDismiss,
|
||||
title = if (title != null) {
|
||||
{
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Modifier.weight(1f)) { title() }
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Iconsax.FluentDismiss,
|
||||
contentDescription = "Close dialog",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
actions = actions,
|
||||
modifier = modifier,
|
||||
) {
|
||||
title = "",
|
||||
modifier = modifier) {
|
||||
title?.let { it() }
|
||||
content()
|
||||
actions?.let {
|
||||
MillenniumSeparator()
|
||||
it()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
dragHandle = null,
|
||||
modifier = modifier,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||
LaunchedEffect(sheetState.currentDetent) {
|
||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
MillenniumModalBottomSheet(state = sheetState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 16.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
horizontalAlignment = Alignment.Start) {
|
||||
title?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@ -101,11 +87,12 @@ fun AdaptiveDialogBottomSheet(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Modifier.weight(1f)) { it() }
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
Iconsax.FluentDismiss,
|
||||
contentDescription = "Close dialog",
|
||||
)
|
||||
contentDescription = "Close dialog")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@ -113,7 +100,7 @@ fun AdaptiveDialogBottomSheet(
|
||||
content()
|
||||
actions?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
MillenniumSeparator()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
it()
|
||||
}
|
||||
|
||||
@ -17,13 +17,12 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -34,20 +33,12 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -55,25 +46,37 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composeunstyled.DropdownMenuPanel
|
||||
import com.composeunstyled.SheetDetent
|
||||
import com.composeunstyled.Text
|
||||
import com.composeunstyled.UnstyledDropdownMenu
|
||||
import com.composeunstyled.UnstyledDropdownMenuItem
|
||||
import com.composeunstyled.rememberModalBottomSheetState
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.Check
|
||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||
import dev.krtirtho.spotube.core.ui.base.DropdownMenu
|
||||
import dev.krtirtho.spotube.core.ui.base.DropdownMenuItem
|
||||
import dev.krtirtho.spotube.core.ui.base.DropdownMenuDivider
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.theme.gelBrush
|
||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||
import org.spotube.millennium.theme.millenniumSurface
|
||||
|
||||
data class AdaptiveMenuItem(
|
||||
val icon: ImageVector? = null,
|
||||
@ -82,7 +85,8 @@ data class AdaptiveMenuItem(
|
||||
val enabled: Boolean = true,
|
||||
val selected: Boolean = false,
|
||||
val dividerBefore: Boolean = false,
|
||||
)
|
||||
val subtitle: String? = null,
|
||||
val leadingContent: (@Composable () -> Unit)? = null)
|
||||
|
||||
enum class HeaderDisplayMode {
|
||||
Always,
|
||||
@ -95,71 +99,129 @@ fun AdaptiveDropdownBottomSheet(
|
||||
items: List<AdaptiveMenuItem>,
|
||||
trigger: @Composable (onClick: () -> Unit) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
breakpointDp: Float = 600f,
|
||||
menuMinWidth: Dp = 200.dp,
|
||||
header: @Composable (() -> Unit)? = null,
|
||||
headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always,
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
|
||||
) {
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
|
||||
|
||||
Box(modifier = modifier) {
|
||||
trigger { expanded = true }
|
||||
|
||||
if (isLargeScreen) {
|
||||
DropdownMenu(
|
||||
UnstyledDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
||||
header()
|
||||
}
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
if (filter != null) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
},
|
||||
placeholder = { Text("Search...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items.forEach { item ->
|
||||
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
||||
return@forEach
|
||||
onExpandedChange = { expanded = it },
|
||||
panel = {
|
||||
DropdownMenuPanel(
|
||||
Modifier
|
||||
.width(menuMinWidth)
|
||||
.millenniumSurface(
|
||||
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||
shape = AppTheme.shapes.medium,
|
||||
borderColor = AppTheme.colors.outlineVariant,
|
||||
borderWidth = 1.dp)
|
||||
.padding(4.dp)) {
|
||||
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
||||
header()
|
||||
}
|
||||
if (item.dividerBefore) {
|
||||
DropdownMenuDivider()
|
||||
|
||||
val dropdownQueryState = rememberTextFieldState()
|
||||
val query = dropdownQueryState.text.toString()
|
||||
if (filter != null) {
|
||||
MillenniumTextField(
|
||||
state = dropdownQueryState,
|
||||
label = "",
|
||||
placeholder = "Search...",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp))
|
||||
}
|
||||
|
||||
items.forEach { item ->
|
||||
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
||||
return@forEach
|
||||
}
|
||||
if (item.dividerBefore) {
|
||||
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
val itemInteraction = remember { MutableInteractionSource() }
|
||||
val itemHovered by itemInteraction.collectIsHoveredAsState()
|
||||
val itemPressed by itemInteraction.collectIsPressedAsState()
|
||||
val itemEnabled = item.enabled
|
||||
val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected)
|
||||
val itemForeground = when {
|
||||
!itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
itemHighlighted -> MillenniumTheme.colors.onAccent
|
||||
else -> AppTheme.colors.onSurface
|
||||
}
|
||||
UnstyledDropdownMenuItem(
|
||||
onClick = { item.onClick() },
|
||||
enabled = itemEnabled,
|
||||
indication = null,
|
||||
interactionSource = itemInteraction) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (itemHighlighted) {
|
||||
Modifier.millenniumSurface(
|
||||
background = gelBrush(
|
||||
MillenniumTheme.colors.accentTop,
|
||||
MillenniumTheme.colors.accentBottom),
|
||||
shape = RoundedCornerShape(8.dp))
|
||||
} else {
|
||||
Modifier
|
||||
})
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
val itemIcon = item.icon
|
||||
when {
|
||||
item.leadingContent != null -> item.leadingContent.invoke()
|
||||
itemIcon != null -> MillenniumIcon(
|
||||
imageVector = itemIcon,
|
||||
contentDescription = null,
|
||||
tint = itemForeground,
|
||||
modifier = Modifier.size(20.dp))
|
||||
|
||||
item.selected -> MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = itemForeground,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = itemForeground)
|
||||
item.subtitle?.let { subtitle ->
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = if (itemHighlighted) {
|
||||
itemForeground.copy(alpha = 0.85f)
|
||||
} else {
|
||||
AppTheme.colors.onSurfaceVariant
|
||||
})
|
||||
}
|
||||
}
|
||||
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = itemForeground,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = item.label,
|
||||
onClick = {
|
||||
item.onClick()
|
||||
expanded = false
|
||||
},
|
||||
enabled = item.enabled,
|
||||
selected = item.selected,
|
||||
leadingIcon = item.icon,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
anchor = { trigger { expanded = true } },
|
||||
)
|
||||
} else {
|
||||
trigger { expanded = true }
|
||||
if (expanded) {
|
||||
AdaptiveBottomSheetContent(
|
||||
onDismiss = { expanded = false },
|
||||
@ -169,143 +231,124 @@ fun AdaptiveDropdownBottomSheet(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
filter = filter,
|
||||
)
|
||||
filter = filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AdaptiveBottomSheetContent(
|
||||
onDismiss: () -> Unit,
|
||||
items: List<AdaptiveMenuItem>,
|
||||
header: @Composable (() -> Unit)?,
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
|
||||
) {
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
|
||||
val hasSelection = items.any { it.selected }
|
||||
var query by remember { mutableStateOf("") }
|
||||
val queryState = rememberTextFieldState()
|
||||
val query = queryState.text.toString()
|
||||
val filteredItems = if (filter != null && query.isNotBlank()) {
|
||||
items.filter { filter(it, query) }
|
||||
} else {
|
||||
items
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
dragHandle = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||
LaunchedEffect(sheetState.currentDetent) {
|
||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
MillenniumModalBottomSheet(state = sheetState) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
header?.invoke()
|
||||
|
||||
if (filter != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
},
|
||||
placeholder = { Text("Search...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
MillenniumTextField(
|
||||
state = queryState,
|
||||
label = "",
|
||||
placeholder = "Search...",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp))
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
items(
|
||||
count = filteredItems.size,
|
||||
) { index ->
|
||||
count = filteredItems.size) { index ->
|
||||
val item = filteredItems[index]
|
||||
|
||||
if (item.dividerBefore) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clip(AppTheme.shapes.medium)
|
||||
.clickable(
|
||||
enabled = item.enabled,
|
||||
onClick = {
|
||||
item.onClick()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
})
|
||||
.background(
|
||||
color = if (item.selected) {
|
||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f)
|
||||
AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
shape = AppTheme.shapes.medium)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (item.icon != null) {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
} else if (item.selected) {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else if (hasSelection) {
|
||||
Spacer(modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
when {
|
||||
item.leadingContent != null -> item.leadingContent.invoke()
|
||||
item.icon != null -> MillenniumIcon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.enabled) {
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
}
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
|
||||
if (item.selected && item.icon != null) {
|
||||
Icon(
|
||||
item.selected -> MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
|
||||
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = if (item.enabled) {
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
item.subtitle?.let { subtitle ->
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -317,44 +360,37 @@ private fun AdaptiveBottomSheetContent(
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AdaptiveDropdownBottomSheetPreview() {
|
||||
Scaffold {
|
||||
Box(modifier = Modifier.background(AppTheme.colors.background)) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = listOf(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxHeart,
|
||||
label = "Favorite",
|
||||
onClick = {},
|
||||
),
|
||||
onClick = {}),
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxShare,
|
||||
label = "Share",
|
||||
onClick = {},
|
||||
),
|
||||
onClick = {}),
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxTrash,
|
||||
label = "Delete",
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
),
|
||||
),
|
||||
enabled = false)),
|
||||
trigger = { onClick ->
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
shape = CircleShape,
|
||||
)
|
||||
color = AppTheme.colors.primary.copy(alpha = 0.1f),
|
||||
shape = CircleShape)
|
||||
.clip(CircleShape),
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
tint = AppTheme.colors.primary)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,8 +40,7 @@ fun AlbumCard(
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||
) {
|
||||
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
PlayableCard(
|
||||
@ -57,17 +56,14 @@ fun AlbumCard(
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.Album,
|
||||
album.id,
|
||||
album.title,
|
||||
)
|
||||
album.title)
|
||||
},
|
||||
onAddToQueue = {
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.Album,
|
||||
album.id,
|
||||
album.title,
|
||||
)
|
||||
album.title)
|
||||
},
|
||||
modifier = modifier.width(160.dp),
|
||||
sharedElementKey = "album_art_${album.id}",
|
||||
)
|
||||
sharedElementKey = "album_art_${album.id}")
|
||||
}
|
||||
|
||||
@ -17,9 +17,12 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
||||
@ -30,12 +33,12 @@ import org.koin.compose.koinInject
|
||||
fun ApplicationBackButton() {
|
||||
val navigationCommands: NavigationCommands = koinInject()
|
||||
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
navigationCommands.pop()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.ArrowLeft3,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
@ -47,5 +50,5 @@ expect fun ApplicationMainBar(
|
||||
title: @Composable () -> Unit = {},
|
||||
subtitle: @Composable () -> Unit = {},
|
||||
actions: @Composable (RowScope.() -> Unit) = {},
|
||||
backButton: Boolean = true
|
||||
)
|
||||
backButton: Boolean = true,
|
||||
transparent: Boolean = false)
|
||||
@ -29,9 +29,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -52,8 +52,7 @@ import org.koin.compose.koinInject
|
||||
fun ArtistCard(
|
||||
artist: MetadataArtist.Basic,
|
||||
modifier: Modifier = Modifier,
|
||||
isBlacklisted: Boolean = false,
|
||||
) {
|
||||
isBlacklisted: Boolean = false) {
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
|
||||
AvatarCard(
|
||||
@ -64,7 +63,6 @@ fun ArtistCard(
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
modifier = modifier,
|
||||
isBlacklisted = isBlacklisted,
|
||||
)
|
||||
isBlacklisted = isBlacklisted)
|
||||
}
|
||||
|
||||
|
||||
@ -17,13 +17,24 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.foundation.layout.height
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumCard
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@ -31,9 +42,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -42,12 +53,6 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
|
||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
|
||||
import dev.krtirtho.spotube.core.ui.base.Card
|
||||
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
@ -62,7 +67,6 @@ import dev.krtirtho.spotube.resources.iconsax.User
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun CollectionDetails(
|
||||
@ -85,10 +89,9 @@ fun CollectionDetails(
|
||||
onFollowClick: () -> Unit = { },
|
||||
showFollowButton: Boolean = true,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
sharedElementKey: String? = null,
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
val isCompact = maxWidth < 600.dp
|
||||
sharedElementKey: String? = null) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
||||
|
||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||
@ -98,95 +101,86 @@ fun CollectionDetails(
|
||||
if (onAddToJam != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
PrimaryButton(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
MillenniumButton(
|
||||
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
|
||||
onClick = onAddToJam!!,
|
||||
) {
|
||||
Icon(
|
||||
onClick = onAddToJam!!) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to Jam",
|
||||
)
|
||||
TextWithShimmer(
|
||||
text = "Add to Jam",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
contentDescription = "Add to Jam")
|
||||
TextWithShimmer(text = "Add to Jam")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val modifier = if (isCompact) {
|
||||
Modifier.weight(1f)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
PrimaryButton(
|
||||
MillenniumButton(
|
||||
modifier = modifier,
|
||||
onClick = onPlay,
|
||||
) {
|
||||
Icon(
|
||||
onClick = onPlay) {
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
)
|
||||
TextWithShimmer(
|
||||
text = if (isPlaying) "Pause" else "Play",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
contentDescription = if (isPlaying) "Pause" else "Play")
|
||||
TextWithShimmer(text = if (isPlaying) "Pause" else "Play")
|
||||
}
|
||||
OutlineButton(
|
||||
MillenniumButton(
|
||||
modifier = modifier,
|
||||
onClick = onShufflePlay
|
||||
onClick = onShufflePlay,
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||
TextWithShimmer(
|
||||
text = "Shuffle",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||
TextWithShimmer(text = "Shuffle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val actions = @Composable {
|
||||
ButtonGroup {
|
||||
GroupIconButton(onClick = onAddToQueue) {
|
||||
Icon(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumIconButton(
|
||||
onClick = onAddToQueue,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to queue"
|
||||
)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||
if (showFollowButton) {
|
||||
if (isFollowing) {
|
||||
GroupIconButton(onClick = onFollowClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onFollowClick,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxHeart2,
|
||||
contentDescription = "Unfollow",
|
||||
)
|
||||
contentDescription = "Unfollow")
|
||||
}
|
||||
} else {
|
||||
GroupIconButton(onClick = onFollowClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onFollowClick,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxHeart,
|
||||
contentDescription = "Follow",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onEdit != null) {
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = onEdit) {
|
||||
Icon(
|
||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||
MillenniumIconButton(
|
||||
onClick = onEdit,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxEdit,
|
||||
contentDescription = "Edit playlist",
|
||||
)
|
||||
contentDescription = "Edit playlist")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -195,8 +189,7 @@ fun CollectionDetails(
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onOwnerClick),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (ownerImageURL != null) {
|
||||
AsyncImage(
|
||||
model = ownerImageURL,
|
||||
@ -205,27 +198,24 @@ fun CollectionDetails(
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.shimmerApply(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = ownerName,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
AppTheme.colors.primaryContainer,
|
||||
CircleShape
|
||||
)
|
||||
.padding(6.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
tint = AppTheme.colors.onPrimaryContainer)
|
||||
}
|
||||
TextWithShimmer(
|
||||
text = "By $ownerName",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@ -237,79 +227,68 @@ fun CollectionDetails(
|
||||
.sharedElementOrNone(
|
||||
key = sharedElementKey,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope)
|
||||
.shimmerApply(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
if (imageURL.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else if (imageResource != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(imageResource),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Text(
|
||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
style = AppTheme.typography.displaySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val metadata = @Composable {
|
||||
Column {
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.headlineLarge,
|
||||
style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
|
||||
if (description.isNotBlank()) {
|
||||
TextWithShimmer(
|
||||
text = description,
|
||||
style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
if (isCompact) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
artwork()
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
metadata()
|
||||
ownerInfo()
|
||||
actions()
|
||||
@ -323,14 +302,12 @@ fun CollectionDetails(
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
verticalAlignment = Alignment.Top) {
|
||||
artwork()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
metadata()
|
||||
ownerInfo()
|
||||
actions()
|
||||
@ -354,6 +331,5 @@ fun CollectionDetailsPreview() {
|
||||
onOwnerClick = {},
|
||||
onPlay = {},
|
||||
onShufflePlay = {},
|
||||
onAddToQueue = {},
|
||||
)
|
||||
onAddToQueue = {})
|
||||
}
|
||||
|
||||
@ -17,18 +17,22 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -93,8 +97,7 @@ fun CollectionView(
|
||||
onAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||
footerContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: @Composable () -> Unit = {},
|
||||
) {
|
||||
trailingContent: @Composable () -> Unit = {}) {
|
||||
val listState = rememberLazyListState()
|
||||
val isCollapsed by remember {
|
||||
derivedStateOf {
|
||||
@ -103,45 +106,42 @@ fun CollectionView(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = if (isCollapsed) {
|
||||
{
|
||||
CollapsedCollectionTitle(
|
||||
title = title,
|
||||
imageURL = imageURL,
|
||||
imageResource = imageResource,
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = if (isCollapsed) {
|
||||
{
|
||||
CollapsedCollectionTitle(
|
||||
title = title,
|
||||
imageURL = imageURL,
|
||||
imageResource = imageResource)
|
||||
}
|
||||
} else {
|
||||
{}
|
||||
},
|
||||
actions = if (isCollapsed && !isJamGuest) {
|
||||
{
|
||||
MillenniumIconButton(
|
||||
onClick = onPlay,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play")
|
||||
}
|
||||
} else {
|
||||
{}
|
||||
},
|
||||
actions = if (isCollapsed && !isJamGuest) {
|
||||
{
|
||||
IconButton(onClick = onPlay) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
{}
|
||||
},
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
}
|
||||
} else {
|
||||
{}
|
||||
})
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
|
||||
if (error != null && tracks.isEmpty() && !isLoading) {
|
||||
ErrorDisplay(
|
||||
errorMessage = error,
|
||||
onRetry = onRetry ?: {},
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
TrackList(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
headerContent = {
|
||||
if (isLoading && tracks.isEmpty()) {
|
||||
@ -164,8 +164,7 @@ fun CollectionView(
|
||||
onEdit = onEdit,
|
||||
sharedElementKey = sharedElementKey,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = { onAddToJam(tracks) },
|
||||
)
|
||||
onAddToJam = { onAddToJam(tracks) })
|
||||
}
|
||||
} else {
|
||||
CollectionDetails(
|
||||
@ -186,8 +185,7 @@ fun CollectionView(
|
||||
onEdit = onEdit,
|
||||
sharedElementKey = sharedElementKey,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = { onAddToJam(tracks) },
|
||||
)
|
||||
onAddToJam = { onAddToJam(tracks) })
|
||||
}
|
||||
},
|
||||
footerContent = footerContent,
|
||||
@ -211,11 +209,11 @@ fun CollectionView(
|
||||
onBulkAddToJam = onBulkAddToJam,
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
trackOptionsState = trackOptionsState,
|
||||
)
|
||||
trackOptionsState = trackOptionsState)
|
||||
}
|
||||
|
||||
trailingContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,46 +221,39 @@ fun CollectionView(
|
||||
private fun CollapsedCollectionTitle(
|
||||
title: String,
|
||||
imageURL: String,
|
||||
imageResource: DrawableResource? = null,
|
||||
) {
|
||||
imageResource: DrawableResource? = null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.shimmerApply(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
if (imageURL.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(36.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else if (imageResource != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(imageResource),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(36.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Text(
|
||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
style = AppTheme.typography.labelMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
)
|
||||
modifier = Modifier.padding(start = 10.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,30 +17,33 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumCard
|
||||
import org.spotube.millennium.composables.MillenniumDialog
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
|
||||
import com.composeunstyled.Text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -67,8 +70,7 @@ import compose.icons.feathericons.RefreshCw
|
||||
fun ErrorDisplay(
|
||||
errorMessage: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
var showFullLog by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
@ -77,117 +79,99 @@ fun ErrorDisplay(
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Icon(
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.AlertCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
|
||||
)
|
||||
tint = AppTheme.colors.error.copy(alpha = 0.7f))
|
||||
|
||||
Text(
|
||||
text = "We're Truly Sorry",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
style = AppTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
color = AppTheme.colors.onSurface,
|
||||
textAlign = TextAlign.Center)
|
||||
|
||||
Text(
|
||||
text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2,
|
||||
)
|
||||
lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2)
|
||||
|
||||
Card(
|
||||
MillenniumCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f),
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Error Details",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
style = AppTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
IconButton(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Copy,
|
||||
contentDescription = "Copy to clipboard",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
modifier = Modifier.size(16.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = { showFullLog = true },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Maximize2,
|
||||
contentDescription = "View full log",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
MillenniumCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f),
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace),
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Button(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
MillenniumButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.height(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.RefreshCw,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp).size(18.dp),
|
||||
)
|
||||
Text("Retry")
|
||||
modifier = Modifier.height(40.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.RefreshCw,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp).size(18.dp))
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -195,93 +179,85 @@ fun ErrorDisplay(
|
||||
if (showFullLog) {
|
||||
ErrorLogDialog(
|
||||
errorMessage = errorMessage,
|
||||
onDismiss = { showFullLog = false },
|
||||
)
|
||||
onDismiss = { showFullLog = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorLogDialog(
|
||||
errorMessage: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
onDismiss: () -> Unit) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
AlertDialog(
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Full Error Log",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
title = "") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Full Error Log",
|
||||
style = AppTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.Copy,
|
||||
contentDescription = "Copy to clipboard",
|
||||
)
|
||||
}
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||
},
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Copy,
|
||||
contentDescription = "Copy to clipboard")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp)
|
||||
.clip(AppTheme.shapes.small)
|
||||
.background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = AppTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace),
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End) {
|
||||
MillenniumButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumButtonStyle.Quiet) {
|
||||
Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ErrorDisplayPreview() {
|
||||
Surface {
|
||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||
ErrorDisplay(
|
||||
errorMessage = "This is an error message",
|
||||
onRetry = {},
|
||||
)
|
||||
onRetry = {})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ErrorLogDialogPreview() {
|
||||
Scaffold {
|
||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||
ErrorLogDialog(
|
||||
errorMessage = "This is an error message",
|
||||
onDismiss = {},
|
||||
)
|
||||
onDismiss = {})
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
@ -34,11 +38,11 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@ -83,8 +87,7 @@ import org.koin.compose.koinInject
|
||||
@Composable
|
||||
fun FeaturedCarousel(
|
||||
items: List<MetadataBrowseItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
val filteredItems = remember(items) {
|
||||
items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track }
|
||||
}
|
||||
@ -92,20 +95,17 @@ fun FeaturedCarousel(
|
||||
if (filteredItems.isEmpty()) return
|
||||
|
||||
FeaturedCarouselContent(
|
||||
items = filteredItems,
|
||||
)
|
||||
items = filteredItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeaturedCarouselContent(
|
||||
items: List<MetadataBrowseItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
val centerIndex = items.size / 2
|
||||
var selectedIndex by remember { mutableIntStateOf(centerIndex) }
|
||||
val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index }
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isSmallScreen = adaptiveInfo.windowSizeClass.minWidthDp <= 600
|
||||
val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@ -113,8 +113,7 @@ private fun FeaturedCarouselContent(
|
||||
.height(
|
||||
if (isSmallScreen) 200.dp else 300.dp
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
Coverflow(
|
||||
state = state,
|
||||
params = CoverflowParams(
|
||||
@ -123,21 +122,17 @@ private fun FeaturedCarouselContent(
|
||||
angle = 12f,
|
||||
shift = 0.255f,
|
||||
zoom = 0.7864f,
|
||||
mirror = false,
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
mirror = false),
|
||||
modifier = modifier) {
|
||||
items(
|
||||
items = items,
|
||||
key = { index: Int -> items[index].hashCode() },
|
||||
) { item: MetadataBrowseItem ->
|
||||
key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem ->
|
||||
FeaturedCarouselCard(
|
||||
item = item,
|
||||
isCentered = selectedIndex == items.indexOf(item),
|
||||
onScrollToCenter = {
|
||||
state.scrollToItem(items.indexOf(item))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -148,8 +143,7 @@ private fun FeaturedCarouselCard(
|
||||
item: MetadataBrowseItem,
|
||||
isCentered: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onScrollToCenter: () -> Unit = { },
|
||||
) {
|
||||
onScrollToCenter: () -> Unit = { }) {
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
val audioPlayerQueue = koinInject<AudioPlayerQueue>()
|
||||
|
||||
@ -204,25 +198,21 @@ private fun FeaturedCarouselCard(
|
||||
}
|
||||
}
|
||||
.aspectRatio(1f)
|
||||
.hazeSource(hazeState),
|
||||
) {
|
||||
.hazeSource(hazeState)) {
|
||||
AsyncImage(
|
||||
model = thumbnailUrl,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = dimAlpha)),
|
||||
)
|
||||
.background(Color.Black.copy(alpha = dimAlpha)))
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
contentAlignment = Alignment.BottomCenter) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -231,43 +221,35 @@ private fun FeaturedCarouselCard(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.9f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Color.Black.copy(alpha = 0.9f)))))
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
contentAlignment = Alignment.BottomCenter) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
.padding(16.dp)) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = AppTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
@ -306,8 +288,7 @@ private fun FeaturedPlayButton(
|
||||
navigationCommands: NavigationCommands,
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
@ -320,7 +301,8 @@ private fun FeaturedPlayButton(
|
||||
modifier = modifier.size(44.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = {
|
||||
if (isPlaying) {
|
||||
navigationCommands.navigateTo(Routes.Album(item.data.id))
|
||||
@ -330,7 +312,7 @@ private fun FeaturedPlayButton(
|
||||
},
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
@ -347,7 +329,8 @@ private fun FeaturedPlayButton(
|
||||
modifier = modifier.size(44.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = {
|
||||
if (isPlaying) {
|
||||
navigationCommands.navigateTo(Routes.Playlist(item.data.id))
|
||||
@ -357,7 +340,7 @@ private fun FeaturedPlayButton(
|
||||
},
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
@ -377,20 +360,20 @@ private fun FeaturedPlayButton(
|
||||
modifier = modifier.size(44.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
audioPlayerQueue.load(
|
||||
entries = listOf(queueEntry),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
},
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.fadeIn
|
||||
@ -35,11 +39,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@ -76,8 +78,7 @@ fun LikedTracksCard(
|
||||
modifier: Modifier = Modifier,
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
) {
|
||||
navigationCommands: NavigationCommands = koinInject()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
@ -92,8 +93,7 @@ fun LikedTracksCard(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
|
||||
.width(160.dp)
|
||||
.hoverable(interactionSource = interactionSource),
|
||||
) {
|
||||
.hoverable(interactionSource = interactionSource)) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
androidx.compose.foundation.Image(
|
||||
@ -106,10 +106,8 @@ fun LikedTracksCard(
|
||||
.sharedElementOrNone(
|
||||
key = "saved_tracks_art",
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope),
|
||||
contentScale = ContentScale.Crop)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
|
||||
@ -120,22 +118,24 @@ fun LikedTracksCard(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
|
||||
modifier = Modifier.size(30.dp)
|
||||
modifier = Modifier.size(30.dp),
|
||||
style = MillenniumIconButtonStyle.Secondary
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to queue",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FilledIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = { scope.launch { playbackHelper.playSavedTracks() } },
|
||||
modifier = Modifier.size(30.dp)
|
||||
modifier = Modifier.size(30.dp),
|
||||
style = MillenniumIconButtonStyle.Primary
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) {
|
||||
Iconsax.IconsaxPauseCircle
|
||||
} else Iconsax.Play,
|
||||
@ -152,18 +152,16 @@ fun LikedTracksCard(
|
||||
) {
|
||||
Text(
|
||||
text = "Liked Tracks",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Your saved songs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,8 +40,7 @@ fun PlaylistCard(
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||
) {
|
||||
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
@ -58,17 +57,14 @@ fun PlaylistCard(
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.Playlist,
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
)
|
||||
playlist.title)
|
||||
},
|
||||
onAddToQueue = {
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.Playlist,
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
)
|
||||
playlist.title)
|
||||
},
|
||||
modifier = modifier,
|
||||
sharedElementKey = "playlist_art_${playlist.id}",
|
||||
)
|
||||
sharedElementKey = "playlist_art_${playlist.id}")
|
||||
}
|
||||
|
||||
@ -35,13 +35,11 @@ val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?>
|
||||
fun Modifier.sharedElementOrNone(
|
||||
key: String?,
|
||||
sharedTransitionScope: SharedTransitionScope?,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?,
|
||||
): Modifier {
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?): Modifier {
|
||||
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
|
||||
with(sharedTransitionScope) {
|
||||
return this@sharedElementOrNone.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(key = key),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope)
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,12 +26,9 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -54,8 +51,7 @@ import org.koin.compose.koinInject
|
||||
fun TrackCard(
|
||||
track: MetadataTrack,
|
||||
modifier: Modifier = Modifier,
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
) {
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val queueEntry = QueueEntry.StreamingTrack(track = track, url = "")
|
||||
|
||||
@ -69,8 +65,7 @@ fun TrackCard(
|
||||
entries = listOf(queueEntry),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
},
|
||||
onAddToQueue = {
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@ -36,6 +40,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@ -44,12 +49,20 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import org.spotube.millennium.composables.MillenniumCard
|
||||
import org.spotube.millennium.composables.MillenniumCheckbox
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumListTile
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import org.spotube.millennium.composables.MillenniumTriStateCheckbox
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@ -68,7 +81,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@ -83,16 +95,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumTy
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
|
||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
|
||||
import dev.krtirtho.spotube.core.ui.base.Card
|
||||
import dev.krtirtho.spotube.core.ui.base.CheckBox
|
||||
import dev.krtirtho.spotube.core.ui.base.CheckBoxState
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||
import dev.krtirtho.spotube.core.ui.base.highlight
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||
@ -122,7 +124,7 @@ enum class TrackSortOption(val label: String) {
|
||||
Duration("Duration"),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TrackList(
|
||||
tracks: List<MetadataTrack> = emptyList(),
|
||||
@ -153,9 +155,9 @@ fun TrackList(
|
||||
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
|
||||
simplified: Boolean = false,
|
||||
scrollable: Boolean = true,
|
||||
state: LazyListState? = null,
|
||||
) {
|
||||
var filterQuery by rememberSaveable { mutableStateOf("") }
|
||||
state: LazyListState? = null) {
|
||||
val filterState = rememberTextFieldState()
|
||||
val filterQuery = filterState.text.toString()
|
||||
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
||||
var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) }
|
||||
var isSelectionMode by rememberSaveable { mutableStateOf(false) }
|
||||
@ -207,20 +209,19 @@ fun TrackList(
|
||||
}
|
||||
}
|
||||
|
||||
val windowInfo = LocalWindowInfo.current
|
||||
val maxWidth = with(density) { windowInfo.containerSize.width.toDp() }
|
||||
val isCompact = maxWidth < CompactTrackListBreakpoint
|
||||
val widthBreakpoint = currentWindowWidthBreakpoint()
|
||||
val isCompact = widthBreakpoint isBelow WindowBreakpoints.Medium
|
||||
val isDesktop = remember { getPlatform().isDesktop() }
|
||||
val showIndex = maxWidth >= LargeTrackListBreakpoint
|
||||
val showIndex = widthBreakpoint isAtLeast WindowBreakpoints.Expanded
|
||||
val showAlbum = !isCompact
|
||||
val useDropdownForOptions = isDesktop && !isCompact
|
||||
|
||||
val trackCardContent: @Composable () -> Unit = {
|
||||
Card(
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -228,9 +229,7 @@ fun TrackList(
|
||||
) {
|
||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||
if (displayedIndex > 0) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator()
|
||||
}
|
||||
TrackListRow(
|
||||
index = displayedIndex + 1,
|
||||
@ -284,38 +283,31 @@ fun TrackList(
|
||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
|
||||
)
|
||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
|
||||
}
|
||||
|
||||
if (isLoading && tracks.isEmpty()) {
|
||||
repeat(ShimmerRowCount) { shimmerIndex ->
|
||||
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
ShimmerTrackListRow(
|
||||
index = shimmerIndex + 1,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions,
|
||||
)
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
TrackListFeedbackRow(
|
||||
message = error,
|
||||
isError = true,
|
||||
)
|
||||
isError = true)
|
||||
}
|
||||
|
||||
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
||||
@ -324,17 +316,14 @@ fun TrackList(
|
||||
|
||||
if (isLoadingNextPage) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
ShimmerTrackListRow(
|
||||
index = visibleTracks.size + 1,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions,
|
||||
)
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -345,19 +334,18 @@ fun TrackList(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||
.heightIn(max = 60.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) {
|
||||
if (showIndex || isSelectionMode) {
|
||||
val allSelected =
|
||||
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
|
||||
val anySelected = selectedTrackIds.isNotEmpty()
|
||||
val headerState = when {
|
||||
allSelected -> CheckBoxState.SELECTED
|
||||
anySelected -> CheckBoxState.INDETERMINATE
|
||||
else -> CheckBoxState.UNSELECTED
|
||||
allSelected -> ToggleableState.On
|
||||
anySelected -> ToggleableState.Indeterminate
|
||||
else -> ToggleableState.Off
|
||||
}
|
||||
CheckBox(
|
||||
state = headerState,
|
||||
MillenniumTriStateCheckbox(
|
||||
value = headerState,
|
||||
onClick = {
|
||||
if (allSelected) {
|
||||
isSelectionMode = false
|
||||
@ -367,58 +355,45 @@ fun TrackList(
|
||||
selectedTrackIds = visibleTracks.map { it.id }.toSet()
|
||||
}
|
||||
},
|
||||
)
|
||||
label = "")
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
TextField(
|
||||
value = filterQuery,
|
||||
onValueChange = { filterQuery = it },
|
||||
MillenniumTextField(
|
||||
state = filterState,
|
||||
label = "",
|
||||
placeholder = "Filter",
|
||||
modifier = Modifier
|
||||
.widthIn(max = 400.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
placeholder = { TextWithShimmer("Filter") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Filter",
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
.align(Alignment.CenterEnd))
|
||||
}
|
||||
|
||||
ButtonGroup {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = TrackSortOption.entries.map { option ->
|
||||
AdaptiveMenuItem(
|
||||
label = option.label,
|
||||
onClick = { sortBy = option },
|
||||
selected = sortBy == option,
|
||||
)
|
||||
selected = sortBy == option)
|
||||
},
|
||||
trigger = { onClick ->
|
||||
GroupIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxSort,
|
||||
contentDescription = "Sort",
|
||||
)
|
||||
contentDescription = "Sort")
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
|
||||
visibleTracks.filter { selectedTrackIds.contains(it.id) }
|
||||
} else {
|
||||
@ -433,54 +408,42 @@ fun TrackList(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxDirectboxReceive,
|
||||
label = if (isAll) "Download All" else "Download $trackCount",
|
||||
onClick = { onBulkDownload(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkDownload(targetTracks) }))
|
||||
if (!isJamGuest) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||
onClick = { onBulkAddToQueue(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkAddToQueue(targetTracks) }))
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||
onClick = { onBulkPlayNext(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkPlayNext(targetTracks) }))
|
||||
}
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicPlaylist,
|
||||
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||
onClick = { onBulkAddToPlaylist(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkAddToPlaylist(targetTracks) }))
|
||||
} + if (isInJam) {
|
||||
listOf(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
|
||||
onClick = { onBulkAddToJam(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkAddToJam(targetTracks) }))
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
trigger = { onClick ->
|
||||
GroupIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "Bulk actions",
|
||||
)
|
||||
contentDescription = "Bulk actions")
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -495,8 +458,7 @@ fun TrackList(
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (headerContent != null) {
|
||||
item {
|
||||
headerContent()
|
||||
@ -525,8 +487,7 @@ fun TrackList(
|
||||
},
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
)
|
||||
isJamGuest = isJamGuest)
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
||||
@ -536,8 +497,7 @@ fun TrackList(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (headerContent != null) {
|
||||
headerContent()
|
||||
}
|
||||
@ -560,8 +520,7 @@ fun TrackList(
|
||||
onTrackOptionsAction(track, action)
|
||||
selectedTrackForOptions = null
|
||||
},
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
)
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -590,235 +549,198 @@ private fun TrackListRow(
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val rowTheme = LocalBaseUITheme.current.listRowTile
|
||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
||||
modifier: Modifier = Modifier) {
|
||||
val artworkInteractionSource = remember { MutableInteractionSource() }
|
||||
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
|
||||
val rowInteractionSource = remember { MutableInteractionSource() }
|
||||
val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
|
||||
val rowBackgroundColor = when {
|
||||
isSelected -> rowTheme.background.selected
|
||||
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
|
||||
isCurrentTrack -> rowTheme.background.focused
|
||||
trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f))
|
||||
else -> rowTheme.background.normal
|
||||
isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||
isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer
|
||||
isCurrentTrack -> AppTheme.colors.surfaceVariant
|
||||
trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val highlightColor = Color.White.copy(
|
||||
alpha = if (isLight) 0.9f else 0.06f
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.hoverable(rowInteractionSource)
|
||||
.combinedClickable(
|
||||
onClick = onTrackClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.background(rowBackgroundColor)
|
||||
.then(
|
||||
if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) {
|
||||
Modifier.highlight(highlightColor)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.padding(end = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (isSelectionMode) {
|
||||
CheckBox(
|
||||
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
||||
onClick = { onSelectionToggle(!isSelected) },
|
||||
modifier = Modifier.shimmerApply()
|
||||
)
|
||||
} else if (showIndex) {
|
||||
Box(
|
||||
modifier = Modifier.width(30.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = isRowHovered,
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
label = "index-checkbox",
|
||||
) { hovered ->
|
||||
if (hovered) {
|
||||
CheckBox(
|
||||
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
||||
onClick = { onSelectionToggle(!isSelected) },
|
||||
modifier = Modifier.shimmerApply()
|
||||
)
|
||||
} else {
|
||||
TextWithShimmer(
|
||||
text = index.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.hoverable(interactionSource = artworkInteractionSource),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
||||
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
|
||||
val platformContext = LocalPlatformContext.current
|
||||
val imageRequest = remember(imageUrl) {
|
||||
ImageRequest.Builder(platformContext)
|
||||
.data(imageUrl)
|
||||
.size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size.
|
||||
.crossfade(false) // Crucial for scroll performance
|
||||
.build()
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = track.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().shimmerApply(),
|
||||
)
|
||||
|
||||
if (isArtworkHovered || isCurrentTrack) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.10f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp).shimmerApply(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1.2f)) {
|
||||
MillenniumListTile(
|
||||
modifier = modifier,
|
||||
selected = isSelected || isCurrentTrack,
|
||||
onClick = onTrackClick,
|
||||
title = {
|
||||
TextWithShimmer(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
subtitle = {
|
||||
ArtistChips(
|
||||
artists = track.artists,
|
||||
onArtistClick = onArtistClick,
|
||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||
)
|
||||
}
|
||||
|
||||
if (showAlbum && track.album != null) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
onArtistsOverflowClick = onArtistsOverflowClick)
|
||||
},
|
||||
leading = {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
TextWithShimmer(
|
||||
text = track.album!!.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.hoverable(interactionSource)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = interactionSource
|
||||
) { onAlbumClick(track.album!!) }
|
||||
// Hide ripple and show underline on hover/click
|
||||
.then(
|
||||
if (isHovered || isPressed) {
|
||||
Modifier.drawWithCache {
|
||||
val underlineHeight = 1.dp.toPx()
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawLine(
|
||||
color = colorScheme.primary,
|
||||
start = Offset(0f, size.height - underlineHeight),
|
||||
end = Offset(size.width, size.height - underlineHeight),
|
||||
strokeWidth = underlineHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (isSelectionMode) {
|
||||
MillenniumCheckbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onSelectionToggle(it) },
|
||||
label = "",
|
||||
modifier = Modifier.shimmerApply())
|
||||
} else if (showIndex) {
|
||||
Box(
|
||||
modifier = Modifier.width(30.dp),
|
||||
contentAlignment = Alignment.Center) {
|
||||
AnimatedContent(
|
||||
targetState = isRowHovered,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "index-checkbox") { hovered ->
|
||||
if (hovered) {
|
||||
MillenniumCheckbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onSelectionToggle(it) },
|
||||
label = "",
|
||||
modifier = Modifier.shimmerApply())
|
||||
} else {
|
||||
Modifier
|
||||
TextWithShimmer(
|
||||
text = index.toString(),
|
||||
style = AppTheme.typography.labelMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextWithShimmer(
|
||||
text = track.durationMs.toDurationString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.End,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(56.dp),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(AppTheme.shapes.small)
|
||||
.hoverable(interactionSource = artworkInteractionSource),
|
||||
contentAlignment = Alignment.Center) {
|
||||
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
|
||||
val platformContext = LocalPlatformContext.current
|
||||
val imageRequest = remember(imageUrl) {
|
||||
ImageRequest.Builder(platformContext)
|
||||
.data(imageUrl)
|
||||
.size(128)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
if (useDropdownForOptions) {
|
||||
TrackOptions(
|
||||
track = track,
|
||||
state = trackOptionsState,
|
||||
onAction = onTrackOptionsAction,
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
)
|
||||
} else {
|
||||
GhostIconButton(onClick = onShowOptionsClick) {
|
||||
Icon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "Track options",
|
||||
)
|
||||
AsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = track.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().shimmerApply())
|
||||
|
||||
if (isArtworkHovered || isCurrentTrack) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.10f)),
|
||||
contentAlignment = Alignment.Center) {
|
||||
MillenniumIcon(
|
||||
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp).shimmerApply())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
trailing = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (showAlbum && track.album != null) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val colorScheme = AppTheme.colors
|
||||
|
||||
TextWithShimmer(
|
||||
text = track.album!!.title,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 220.dp)
|
||||
.hoverable(interactionSource)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = interactionSource) { onAlbumClick(track.album!!) }
|
||||
.then(
|
||||
if (isHovered || isPressed) {
|
||||
Modifier.drawWithCache {
|
||||
val underlineHeight = 1.dp.toPx()
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawLine(
|
||||
color = colorScheme.primary,
|
||||
start = Offset(0f, size.height - underlineHeight),
|
||||
end = Offset(size.width, size.height - underlineHeight),
|
||||
strokeWidth = underlineHeight)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}))
|
||||
}
|
||||
|
||||
TextWithShimmer(
|
||||
text = track.durationMs.toDurationString(),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.End,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
modifier = Modifier.width(56.dp))
|
||||
|
||||
if (useDropdownForOptions) {
|
||||
TrackOptions(
|
||||
track = track,
|
||||
state = trackOptionsState,
|
||||
onAction = onTrackOptionsAction,
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest)
|
||||
} else {
|
||||
MillenniumIconButton(
|
||||
onClick = onShowOptionsClick,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "Track options")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistChips(
|
||||
artists: List<MetadataArtist.Basic>,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onArtistsOverflowClick: () -> Unit,
|
||||
) {
|
||||
onArtistsOverflowClick: () -> Unit) {
|
||||
val visibleArtists = artists.take(2)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
visibleArtists.forEachIndexed { index, artist ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
TextWithShimmer(
|
||||
text = artist.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null,
|
||||
@ -827,13 +749,12 @@ private fun ArtistChips(
|
||||
indication = null,
|
||||
interactionSource = interactionSource
|
||||
) { onArtistClick(artist) }
|
||||
,
|
||||
)
|
||||
)
|
||||
if (index < visibleArtists.size - 1)
|
||||
Text(
|
||||
",",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -842,12 +763,11 @@ private fun ArtistChips(
|
||||
if (remaining > 0) {
|
||||
TextWithShimmer(
|
||||
text = "+$remaining more",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtistsOverflowClick),
|
||||
)
|
||||
modifier = Modifier.clickable(onClick = onArtistsOverflowClick))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -857,8 +777,7 @@ private fun ShimmerTrackListRow(
|
||||
index: Int,
|
||||
showIndex: Boolean,
|
||||
showAlbum: Boolean,
|
||||
useDropdownForOptions: Boolean,
|
||||
) {
|
||||
useDropdownForOptions: Boolean) {
|
||||
val dummyTrack = remember(index) { dummyTrackForShimmer(index) }
|
||||
SkeletonTree(true) {
|
||||
TrackListRow(
|
||||
@ -881,8 +800,7 @@ private fun ShimmerTrackListRow(
|
||||
onShowOptionsClick = {},
|
||||
onArtistClick = {},
|
||||
onAlbumClick = {},
|
||||
onArtistsOverflowClick = {},
|
||||
)
|
||||
onArtistsOverflowClick = {})
|
||||
}
|
||||
}
|
||||
|
||||
@ -891,8 +809,7 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
|
||||
id = "shimmer_artist_$index",
|
||||
name = "Artist Name",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
)
|
||||
externalUri = null)
|
||||
return MetadataTrack(
|
||||
id = "shimmer_track_$index",
|
||||
title = "Track Title Here",
|
||||
@ -913,29 +830,25 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
|
||||
thumbnails = emptyList(),
|
||||
albumType = MetadataAlbumType.Album,
|
||||
artists = listOf(artist),
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
thumbnails = null,
|
||||
explicit = false,
|
||||
popularity = 0,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
)
|
||||
externalUri = null)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackListFeedbackRow(
|
||||
message: String,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
isError: Boolean = false) {
|
||||
TextWithShimmer(
|
||||
text = message,
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp))
|
||||
}
|
||||
|
||||
private fun Long.toDurationString(): String {
|
||||
@ -951,21 +864,17 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
id = "artist_1",
|
||||
name = "Aria Vale",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
MetadataArtist.Basic(
|
||||
id = "artist_2",
|
||||
name = "Neon Harbor",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
MetadataArtist.Basic(
|
||||
id = "artist_3",
|
||||
name = "Echo Drift",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
),
|
||||
)
|
||||
externalUri = null))
|
||||
|
||||
val album = MetadataAlbum.Detailed(
|
||||
releaseDate = null,
|
||||
@ -978,13 +887,11 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
Thumbnail(
|
||||
url = "https://picsum.photos/120",
|
||||
width = 120,
|
||||
height = 120,
|
||||
)
|
||||
height = 120)
|
||||
),
|
||||
albumType = MetadataAlbumType.Album,
|
||||
artists = artists.take(2),
|
||||
externalUri = null,
|
||||
)
|
||||
externalUri = null)
|
||||
|
||||
return listOf(
|
||||
MetadataTrack(
|
||||
@ -999,8 +906,7 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
explicit = false,
|
||||
popularity = 92,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
MetadataTrack(
|
||||
id = "track_2",
|
||||
title = "Static Horizon",
|
||||
@ -1013,8 +919,7 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
popularity = 80,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
thumbnails = null,
|
||||
),
|
||||
thumbnails = null),
|
||||
MetadataTrack(
|
||||
id = "track_3",
|
||||
title = "Last Train Echo",
|
||||
@ -1027,19 +932,16 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
popularity = 77,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
thumbnails = null,
|
||||
),
|
||||
)
|
||||
thumbnails = null))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
private fun TrackListPreview() {
|
||||
Scaffold {
|
||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||
TrackList(
|
||||
tracks = previewTracks(),
|
||||
hasMore = true,
|
||||
isLoadingNextPage = true,
|
||||
)
|
||||
isLoadingNextPage = true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,13 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||
import com.composeunstyled.SheetDetent
|
||||
import com.composeunstyled.rememberModalBottomSheetState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -24,12 +31,11 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -38,8 +44,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
@ -72,8 +76,7 @@ data class TrackOptionsState(
|
||||
val isInQueue: Boolean = false,
|
||||
val isCurrentlyPlaying: Boolean = false,
|
||||
val isFavorite: Boolean = false,
|
||||
val isBlacklisted: Boolean = false,
|
||||
)
|
||||
val isBlacklisted: Boolean = false)
|
||||
|
||||
data class TrackOptionsContext(
|
||||
val currentTrackId: String? = null,
|
||||
@ -81,15 +84,13 @@ data class TrackOptionsContext(
|
||||
val savedTrackIds: Set<String> = emptySet(),
|
||||
val blacklistedTrackIds: Set<String> = emptySet(),
|
||||
val blacklistedArtistIds: Set<String> = emptySet(),
|
||||
val forceFavorite: Boolean = false,
|
||||
) {
|
||||
val forceFavorite: Boolean = false) {
|
||||
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
|
||||
isInQueue = track.id in queueTrackIds,
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = forceFavorite || track.id in savedTrackIds,
|
||||
isBlacklisted = track.id in blacklistedTrackIds ||
|
||||
track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
track.artists.any { it.id in blacklistedArtistIds })
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -100,8 +101,7 @@ fun TrackOptions(
|
||||
onAlbumClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
) {
|
||||
isJamGuest: Boolean = false) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = buildTrackMenuItems(
|
||||
track = track,
|
||||
@ -109,11 +109,12 @@ fun TrackOptions(
|
||||
onAction = onAction,
|
||||
onAlbumClick = onAlbumClick,
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
),
|
||||
isJamGuest = isJamGuest),
|
||||
trigger = { onClick ->
|
||||
GhostIconButton(onClick = onClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "Track options",
|
||||
modifier = Modifier.shimmerApply()
|
||||
@ -122,11 +123,9 @@ fun TrackOptions(
|
||||
},
|
||||
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
|
||||
header = { TrackOptionsSheetHeader(track = track) },
|
||||
modifier = modifier,
|
||||
)
|
||||
modifier = modifier)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrackOptionsBottomSheet(
|
||||
track: MetadataTrack,
|
||||
@ -135,9 +134,14 @@ fun TrackOptionsBottomSheet(
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
isJamGuest: Boolean = false) {
|
||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||
LaunchedEffect(sheetState.currentDetent) {
|
||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
MillenniumModalBottomSheet(state = sheetState) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
TrackOptionsSheetHeader(track = track)
|
||||
|
||||
@ -145,8 +149,7 @@ fun TrackOptionsBottomSheet(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
buildTrackMenuItems(
|
||||
track = track,
|
||||
state = state,
|
||||
@ -159,40 +162,35 @@ fun TrackOptionsBottomSheet(
|
||||
onDismiss()
|
||||
},
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
).forEach { item ->
|
||||
isJamGuest = isJamGuest).forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
enabled = item.enabled,
|
||||
onClick = item.onClick,
|
||||
)
|
||||
onClick = item.onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item.icon?.let { icon ->
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
}
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -207,39 +205,34 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) {
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
AsyncImage(
|
||||
model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
)
|
||||
.clip(AppTheme.shapes.small))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = AppTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
text = track.artists.joinToString(", ") { it.name },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
track.album?.let { album ->
|
||||
Text(
|
||||
text = album.title,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -251,25 +244,20 @@ private fun buildTrackMenuItems(
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
): List<AdaptiveMenuItem> = buildList {
|
||||
isJamGuest: Boolean = false): List<AdaptiveMenuItem> = buildList {
|
||||
if (isInJam) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = "Add to Jam",
|
||||
onClick = { onAction(TrackOptionsAction.AddToJam) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.AddToJam) }))
|
||||
}
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicCircle,
|
||||
label = "Start radio",
|
||||
onClick = { onAction(TrackOptionsAction.StartRadio) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.StartRadio) }))
|
||||
|
||||
// A guest's queue is the shared jam queue — mutating it locally is not
|
||||
// allowed, so queue actions are replaced by "Add to Jam".
|
||||
@ -279,17 +267,13 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = "Play next",
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) }))
|
||||
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = "Move to next",
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) }))
|
||||
}
|
||||
|
||||
if (!state.isInQueue) {
|
||||
@ -297,17 +281,13 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = "Add to queue",
|
||||
onClick = { onAction(TrackOptionsAction.AddToQueue) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.AddToQueue) }))
|
||||
} else {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicSquareRemove,
|
||||
label = "Remove from queue",
|
||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -315,42 +295,32 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,
|
||||
label = if (state.isFavorite) "Remove from favorites" else "Save as favorite",
|
||||
onClick = { onAction(TrackOptionsAction.ToggleFavorite) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.ToggleFavorite) }))
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicPlaylist,
|
||||
label = "Add to playlist",
|
||||
onClick = { onAction(TrackOptionsAction.AddToPlaylist) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.AddToPlaylist) }))
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxDirectboxReceive,
|
||||
label = "Download",
|
||||
onClick = { onAction(TrackOptionsAction.Download) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.Download) }))
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicSquareRemoveFilled,
|
||||
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
|
||||
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }))
|
||||
|
||||
if (!track.externalUri.isNullOrBlank()) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxShare,
|
||||
label = "Share",
|
||||
onClick = { onAction(TrackOptionsAction.Share) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.Share) }))
|
||||
}
|
||||
|
||||
if (track.album != null) {
|
||||
@ -358,8 +328,6 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxCd,
|
||||
label = "Go to album",
|
||||
onClick = onAlbumClick,
|
||||
),
|
||||
)
|
||||
onClick = onAlbumClick))
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -44,8 +44,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
|
||||
@Composable
|
||||
fun UserCard(
|
||||
user: MetadataUser,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
AvatarCard(
|
||||
title = user.displayName ?: user.username,
|
||||
subtitle = "User",
|
||||
@ -53,7 +52,6 @@ fun UserCard(
|
||||
onClick = {
|
||||
// TODO: Route to user details screen.
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
modifier = modifier)
|
||||
}
|
||||
|
||||
|
||||
@ -28,8 +28,7 @@ import dev.krtirtho.spotube.isDesktop
|
||||
@Composable
|
||||
expect fun VerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
modifier: Modifier = Modifier)
|
||||
|
||||
fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
|
||||
if (getPlatform().isDesktop()) pointerInput(rowState) {
|
||||
|
||||
@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -58,10 +58,9 @@ fun AvatarCard(
|
||||
imageURL: String? = null,
|
||||
onClick: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
isBlacklisted: Boolean = false,
|
||||
) {
|
||||
isBlacklisted: Boolean = false) {
|
||||
val backgroundColor = if (isBlacklisted) {
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
|
||||
AppTheme.colors.error.copy(alpha = 0.1f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
@ -71,20 +70,17 @@ fun AvatarCard(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
||||
.width(160.dp),
|
||||
) {
|
||||
.width(160.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CircleShape)
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
@ -93,27 +89,24 @@ fun AvatarCard(
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.shimmerApply(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
||||
) {
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
subtitle?.let {
|
||||
TextWithShimmer(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component.cards
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.fadeIn
|
||||
@ -36,9 +40,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@ -50,10 +53,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
||||
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
|
||||
@ -75,8 +74,7 @@ fun PlayableCard(
|
||||
onPlay: (() -> Unit)? = null,
|
||||
onAddToQueue: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
sharedElementKey: String? = null,
|
||||
) {
|
||||
sharedElementKey: String? = null) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||
@ -86,8 +84,7 @@ fun PlayableCard(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = { onClick?.invoke() }, enabled = true)
|
||||
.width(160.dp)
|
||||
.hoverable(interactionSource = interactionSource),
|
||||
) {
|
||||
.hoverable(interactionSource = interactionSource)) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
|
||||
AsyncImage(
|
||||
@ -100,10 +97,8 @@ fun PlayableCard(
|
||||
.sharedElementOrNone(
|
||||
key = sharedElementKey,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope),
|
||||
contentScale = ContentScale.Crop)
|
||||
|
||||
if (onPlay != null || onAddToQueue != null)
|
||||
Box(
|
||||
@ -114,46 +109,35 @@ fun PlayableCard(
|
||||
this@Column.AnimatedVisibility(
|
||||
visible = isHovered,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
exit = fadeOut()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
onAddToQueue?.let {
|
||||
SecondaryIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onAddToQueue,
|
||||
modifier = Modifier.size(30.dp),
|
||||
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
||||
shape = BaseUITheme.InteractionState.fromSingleValue(
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
modifier = Modifier.size(34.dp),
|
||||
style = MillenniumIconButtonStyle.Secondary
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to queue",
|
||||
)
|
||||
contentDescription = "Add to queue")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
onPlay?.let {
|
||||
PrimaryIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onPlay,
|
||||
modifier = Modifier.size(30.dp),
|
||||
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
||||
shape = BaseUITheme.InteractionState.fromSingleValue(
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
modifier = Modifier.size(34.dp),
|
||||
style = MillenniumIconButtonStyle.Primary
|
||||
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) {
|
||||
Iconsax.IconsaxPause
|
||||
} else Iconsax.IconsaxPlay,
|
||||
contentDescription = "Play",
|
||||
)
|
||||
contentDescription = "Play")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -165,19 +149,17 @@ fun PlayableCard(
|
||||
) {
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
subtitle?.let {
|
||||
TextWithShimmer(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -185,11 +167,10 @@ fun PlayableCard(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun PlayableCardPreview() {
|
||||
Scaffold {
|
||||
Box {
|
||||
PlayableCard(
|
||||
title = "Sample Title",
|
||||
subtitle = "Sample Subtitle",
|
||||
|
||||
@ -45,8 +45,7 @@ fun Coverflow(
|
||||
modifier: Modifier = Modifier,
|
||||
state: CoverflowState = rememberCoverflowState(),
|
||||
params: CoverflowParams = CoverflowParams(),
|
||||
content: CoverflowScope.() -> Unit,
|
||||
) {
|
||||
content: CoverflowScope.() -> Unit) {
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
var geometry by remember { mutableStateOf<Geometry?>(null) }
|
||||
|
||||
@ -55,8 +54,7 @@ fun Coverflow(
|
||||
val g = geometry ?: return@LaunchedEffect
|
||||
state.lazyListState.scrollToItem(
|
||||
state.selectedIndex + 1,
|
||||
-g.spacerWidth,
|
||||
)
|
||||
-g.spacerWidth)
|
||||
state.initialScrollDone = true
|
||||
}
|
||||
|
||||
@ -69,8 +67,7 @@ fun Coverflow(
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
state = state.lazyListState,
|
||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState),
|
||||
) {
|
||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) {
|
||||
if (size != IntSize.Zero) {
|
||||
val g = Geometry(params, size)
|
||||
geometry = g
|
||||
@ -79,8 +76,7 @@ fun Coverflow(
|
||||
val coverflowScope = CoverflowScopeImpl(
|
||||
geometry = g,
|
||||
lazyListScope = this,
|
||||
coverflowState = state,
|
||||
)
|
||||
coverflowState = state)
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() }))
|
||||
@ -102,15 +98,13 @@ fun Coverflow(
|
||||
if (state.selectedIndex > 0) {
|
||||
state.scrollToItem(state.selectedIndex - 1)
|
||||
}
|
||||
},
|
||||
) {}
|
||||
}) {}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.CenterEnd)
|
||||
.clickable {
|
||||
state.scrollToItem(state.selectedIndex + 1)
|
||||
},
|
||||
) {}
|
||||
}) {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,8 +25,7 @@ import kotlin.math.min
|
||||
@Stable
|
||||
internal class Geometry(
|
||||
internal val params: CoverflowParams = CoverflowParams(),
|
||||
private val size: IntSize = IntSize.Zero,
|
||||
) {
|
||||
private val size: IntSize = IntSize.Zero) {
|
||||
@Stable
|
||||
private val shortEdge
|
||||
get() = min(size.width, size.height)
|
||||
|
||||
@ -41,8 +41,7 @@ internal fun CoverflowItem(
|
||||
geometry: Geometry,
|
||||
onClickHandler: () -> Unit = {},
|
||||
onSelectedHandler: (Boolean) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
content: @Composable () -> Unit) {
|
||||
var horizontalPosition by remember { mutableStateOf<Float?>(null) }
|
||||
val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f)
|
||||
|
||||
@ -53,8 +52,7 @@ internal fun CoverflowItem(
|
||||
.onGloballyPositioned { coordinates ->
|
||||
horizontalPosition = coordinates.positionInParent().x
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
if (horizontalPosition != null) {
|
||||
onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f))
|
||||
|
||||
@ -65,20 +63,16 @@ internal fun CoverflowItem(
|
||||
rotationY = geometry.rotation(distanceToCenter),
|
||||
translationX = geometry.translationX(distanceToCenter),
|
||||
scaleY = geometry.scale(distanceToCenter),
|
||||
scaleX = geometry.scale(distanceToCenter),
|
||||
),
|
||||
propagateMinConstraints = true,
|
||||
) {
|
||||
scaleX = geometry.scale(distanceToCenter)),
|
||||
propagateMinConstraints = true) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
if (geometry.params.mirror) {
|
||||
Mirror(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClickHandler,
|
||||
),
|
||||
coverSize = geometry.coverSize,
|
||||
) {
|
||||
onClick = onClickHandler),
|
||||
coverSize = geometry.coverSize) {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
@ -86,9 +80,7 @@ internal fun CoverflowItem(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClickHandler,
|
||||
),
|
||||
) {
|
||||
onClick = onClickHandler)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,5 +26,4 @@ data class CoverflowParams(
|
||||
val angle: Float = 45f,
|
||||
val shift: Float = 0.4f,
|
||||
val zoom: Float = 0.8f,
|
||||
val mirror: Boolean = false,
|
||||
)
|
||||
val mirror: Boolean = false)
|
||||
|
||||
@ -26,37 +26,32 @@ interface CoverflowScope {
|
||||
onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> },
|
||||
key: ((index: Int) -> Any)? = null,
|
||||
contentType: (index: Int) -> Any? = { null },
|
||||
itemContent: @Composable ((item: T) -> Unit),
|
||||
)
|
||||
itemContent: @Composable ((item: T) -> Unit))
|
||||
|
||||
fun items(
|
||||
count: Int,
|
||||
onSelectHandler: (index: Int) -> Unit = {},
|
||||
key: ((index: Int) -> Any)? = null,
|
||||
contentType: (index: Int) -> Any? = { null },
|
||||
itemContent: @Composable ((index: Int) -> Unit),
|
||||
)
|
||||
itemContent: @Composable ((index: Int) -> Unit))
|
||||
}
|
||||
|
||||
internal class CoverflowScopeImpl(
|
||||
private val geometry: Geometry,
|
||||
private val lazyListScope: LazyListScope,
|
||||
private val coverflowState: CoverflowState,
|
||||
) : CoverflowScope {
|
||||
private val coverflowState: CoverflowState) : CoverflowScope {
|
||||
override fun <T> items(
|
||||
items: List<T>,
|
||||
onSelectHandler: (item: T, index: Int) -> Unit,
|
||||
key: ((index: Int) -> Any)?,
|
||||
contentType: (index: Int) -> Any?,
|
||||
itemContent: @Composable (item: T) -> Unit,
|
||||
) = items(
|
||||
itemContent: @Composable (item: T) -> Unit) = items(
|
||||
count = items.size,
|
||||
onSelectHandler = {
|
||||
onSelectHandler(items[it], it)
|
||||
},
|
||||
key = key,
|
||||
contentType = contentType,
|
||||
) {
|
||||
contentType = contentType) {
|
||||
itemContent(items[it])
|
||||
}
|
||||
|
||||
@ -65,12 +60,10 @@ internal class CoverflowScopeImpl(
|
||||
onSelectHandler: (index: Int) -> Unit,
|
||||
key: ((index: Int) -> Any)?,
|
||||
contentType: (index: Int) -> Any?,
|
||||
itemContent: @Composable (index: Int) -> Unit,
|
||||
) = lazyListScope.items(
|
||||
itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items(
|
||||
count,
|
||||
key,
|
||||
contentType,
|
||||
) {
|
||||
contentType) {
|
||||
CoverflowItem(
|
||||
onClickHandler = { coverflowState.scrollToItem(it) },
|
||||
onSelectedHandler = { isSelected: Boolean ->
|
||||
@ -78,16 +71,14 @@ internal class CoverflowScopeImpl(
|
||||
onSelectHandler(it)
|
||||
}
|
||||
},
|
||||
geometry = geometry,
|
||||
) {
|
||||
geometry = geometry) {
|
||||
itemContent(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectHandler(
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
): Boolean {
|
||||
isSelected: Boolean): Boolean {
|
||||
if (!isSelected && index == coverflowState.selectedIndex) {
|
||||
coverflowState.selectedIndex = -1
|
||||
} else if (isSelected && index != coverflowState.selectedIndex) {
|
||||
|
||||
@ -28,19 +28,16 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun rememberCoverflowState(
|
||||
initialIndex: Int = 0,
|
||||
onSelectHandler: (Int) -> Unit = {},
|
||||
): CoverflowState {
|
||||
onSelectHandler: (Int) -> Unit = {}): CoverflowState {
|
||||
val lazyListState: LazyListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = initialIndex + 1,
|
||||
)
|
||||
initialFirstVisibleItemIndex = initialIndex + 1)
|
||||
val coroutineScope: CoroutineScope = rememberCoroutineScope()
|
||||
return remember {
|
||||
CoverflowState(
|
||||
lazyListState,
|
||||
coroutineScope,
|
||||
onSelectHandler,
|
||||
initialIndex,
|
||||
)
|
||||
initialIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,8 +45,7 @@ class CoverflowState internal constructor(
|
||||
internal val lazyListState: LazyListState,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val onSelectHandler: (Int) -> Unit = {},
|
||||
initialIndex: Int = 0,
|
||||
) {
|
||||
initialIndex: Int = 0) {
|
||||
internal var geometry: Geometry? = null
|
||||
internal var initialScrollDone: Boolean = false
|
||||
|
||||
@ -65,8 +61,7 @@ class CoverflowState internal constructor(
|
||||
coroutineScope.launch {
|
||||
lazyListState.animateScrollToItem(
|
||||
index + 1,
|
||||
-geometryCopy.spacerWidth,
|
||||
)
|
||||
-geometryCopy.spacerWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,23 +48,20 @@ import androidx.compose.ui.unit.times
|
||||
internal fun Mirror(
|
||||
modifier: Modifier = Modifier,
|
||||
coverSize: Float,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
content: @Composable () -> Unit) {
|
||||
val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(top = coverSizeDp)
|
||||
.fillMaxWidth()
|
||||
.requiredHeight(2 * coverSizeDp),
|
||||
) {
|
||||
.requiredHeight(2 * coverSizeDp)) {
|
||||
Box(
|
||||
propagateMinConstraints = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.height(coverSizeDp),
|
||||
) {
|
||||
.height(coverSizeDp)) {
|
||||
content()
|
||||
}
|
||||
Box(
|
||||
@ -75,34 +72,27 @@ internal fun Mirror(
|
||||
.height(coverSizeDp)
|
||||
.graphicsLayer(
|
||||
compositingStrategy = CompositingStrategy.Offscreen,
|
||||
rotationX = 180f,
|
||||
)
|
||||
rotationX = 180f)
|
||||
.clip(HalfSizeShape)
|
||||
.drawWithContent {
|
||||
val colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Transparent,
|
||||
Color.Black,
|
||||
)
|
||||
Color.Black)
|
||||
drawContent()
|
||||
drawRect(
|
||||
topLeft = Offset(
|
||||
0f,
|
||||
this.size.height / 2,
|
||||
),
|
||||
this.size.height / 2),
|
||||
size = Size(
|
||||
this.size.width,
|
||||
this.size.height / 2,
|
||||
),
|
||||
this.size.height / 2),
|
||||
brush = Brush.verticalGradient(colors),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
blendMode = BlendMode.DstIn)
|
||||
}
|
||||
.blur(
|
||||
radiusX = 1.dp,
|
||||
radiusY = 3.dp,
|
||||
),
|
||||
) {
|
||||
radiusY = 3.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@ -112,17 +102,12 @@ private object HalfSizeShape : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline = Outline.Rectangle(
|
||||
density: Density): Outline = Outline.Rectangle(
|
||||
Rect(
|
||||
Offset(
|
||||
0f,
|
||||
size.height / 2,
|
||||
),
|
||||
size.height / 2),
|
||||
Size(
|
||||
size.width,
|
||||
size.height,
|
||||
),
|
||||
),
|
||||
)
|
||||
size.height)))
|
||||
}
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.misc
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import com.composeunstyled.LocalTextStyle
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
@ -38,7 +39,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import com.eygraber.compose.placeholder.material3.placeholder
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
@ -84,11 +84,7 @@ fun Modifier.shimmerApply(
|
||||
): Modifier = composed {
|
||||
val isTreeLoading = LocalSkeletonLoading.current
|
||||
|
||||
this.placeholder(
|
||||
visible = isTreeLoading,
|
||||
color = color,
|
||||
shape = null,
|
||||
)
|
||||
if (isTreeLoading) this.background(color) else this
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -110,23 +106,20 @@ fun TextWithShimmer(
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
minLines: Int = 1,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
) {
|
||||
style: TextStyle = LocalTextStyle.current) {
|
||||
Text(
|
||||
text = text,
|
||||
color = color,
|
||||
modifier = Modifier.shimmerApply().then(modifier),
|
||||
autoSize = autoSize,
|
||||
fontSize = fontSize,
|
||||
fontStyle = fontStyle,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
letterSpacing = letterSpacing,
|
||||
textDecoration = textDecoration,
|
||||
textAlign = textAlign,
|
||||
textAlign = textAlign ?: TextAlign.Start,
|
||||
lineHeight = lineHeight,
|
||||
overflow = overflow,
|
||||
softWrap = softWrap,
|
||||
maxLines = maxLines,
|
||||
minLines = minLines,
|
||||
onTextLayout = onTextLayout,
|
||||
|
||||
@ -18,39 +18,202 @@
|
||||
package dev.krtirtho.spotube.core.ui.theming
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.materialkolor.rememberDynamicColorScheme
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
import dev.krtirtho.spotube.modules.settings.Theme
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
import org.spotube.millennium.composables.MillenniumTooltipHost
|
||||
import org.spotube.millennium.theme.MillenniumColors
|
||||
import org.spotube.millennium.theme.MillenniumPalette
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.theme.MillenniumThemeConfig
|
||||
|
||||
/**
|
||||
* Semantic color roles used across the app, resolved from the active Millennium palette.
|
||||
*
|
||||
* This is not a second palette: every role is derived from [MillenniumColors], so the
|
||||
* whole app follows the selected Millennium accent family and light/dark appearance.
|
||||
*/
|
||||
@Immutable
|
||||
data class AppColors(
|
||||
val primary: Color,
|
||||
val onPrimary: Color,
|
||||
val primaryContainer: Color,
|
||||
val onPrimaryContainer: Color,
|
||||
val secondary: Color,
|
||||
val onSecondary: Color,
|
||||
val secondaryContainer: Color,
|
||||
val onSecondaryContainer: Color,
|
||||
val tertiaryContainer: Color,
|
||||
val onTertiaryContainer: Color,
|
||||
val background: Color,
|
||||
val onBackground: Color,
|
||||
val surface: Color,
|
||||
val onSurface: Color,
|
||||
val surfaceVariant: Color,
|
||||
val onSurfaceVariant: Color,
|
||||
val surfaceContainer: Color,
|
||||
val surfaceContainerHigh: Color,
|
||||
val surfaceContainerHighest: Color,
|
||||
val inverseSurface: Color,
|
||||
val inverseOnSurface: Color,
|
||||
val outline: Color,
|
||||
val outlineVariant: Color,
|
||||
val error: Color,
|
||||
val onError: Color,
|
||||
val errorContainer: Color,
|
||||
val onErrorContainer: Color,
|
||||
val scrim: Color
|
||||
)
|
||||
|
||||
/** Maps Millennium's semantic palette onto the app's role names. */
|
||||
fun appColorsFrom(m: MillenniumColors): AppColors = AppColors(
|
||||
primary = m.accent,
|
||||
onPrimary = m.onAccent,
|
||||
primaryContainer = m.raised,
|
||||
onPrimaryContainer = m.text,
|
||||
secondary = m.accent,
|
||||
onSecondary = m.onAccent,
|
||||
secondaryContainer = m.raised,
|
||||
onSecondaryContainer = m.text,
|
||||
tertiaryContainer = m.raised,
|
||||
onTertiaryContainer = m.text,
|
||||
background = m.background,
|
||||
onBackground = m.text,
|
||||
surface = m.surface,
|
||||
onSurface = m.text,
|
||||
surfaceVariant = m.inset,
|
||||
onSurfaceVariant = m.muted,
|
||||
surfaceContainer = m.surface,
|
||||
surfaceContainerHigh = m.raised,
|
||||
surfaceContainerHighest = m.raised,
|
||||
inverseSurface = m.text,
|
||||
inverseOnSurface = m.background,
|
||||
outline = m.border,
|
||||
outlineVariant = m.border,
|
||||
error = m.danger,
|
||||
onError = m.background,
|
||||
errorContainer = m.danger.copy(alpha = 0.16f),
|
||||
onErrorContainer = m.danger,
|
||||
scrim = m.scrim
|
||||
)
|
||||
|
||||
/** Named text styles used by the app, independent of Material typography. */
|
||||
@Immutable
|
||||
data class AppTypography(
|
||||
val displaySmall: TextStyle,
|
||||
val headlineLarge: TextStyle,
|
||||
val headlineMedium: TextStyle,
|
||||
val headlineSmall: TextStyle,
|
||||
val titleLarge: TextStyle,
|
||||
val titleMedium: TextStyle,
|
||||
val titleSmall: TextStyle,
|
||||
val bodyLarge: TextStyle,
|
||||
val bodyMedium: TextStyle,
|
||||
val bodySmall: TextStyle,
|
||||
val labelLarge: TextStyle,
|
||||
val labelMedium: TextStyle,
|
||||
val labelSmall: TextStyle
|
||||
)
|
||||
|
||||
private fun style(
|
||||
size: Int,
|
||||
lineHeight: Int,
|
||||
weight: FontWeight = FontWeight.Normal
|
||||
): TextStyle = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontSize = size.sp,
|
||||
lineHeight = lineHeight.sp,
|
||||
fontWeight = weight
|
||||
)
|
||||
|
||||
val AppDefaultTypography = AppTypography(
|
||||
displaySmall = style(36, 44),
|
||||
headlineLarge = style(32, 40),
|
||||
headlineMedium = style(28, 36),
|
||||
headlineSmall = style(24, 32),
|
||||
titleLarge = style(22, 28, FontWeight.Medium),
|
||||
titleMedium = style(16, 24, FontWeight.Medium),
|
||||
titleSmall = style(14, 20, FontWeight.Medium),
|
||||
bodyLarge = style(16, 24),
|
||||
bodyMedium = style(14, 20),
|
||||
bodySmall = style(12, 16),
|
||||
labelLarge = style(14, 20, FontWeight.Medium),
|
||||
labelMedium = style(12, 16, FontWeight.Medium),
|
||||
labelSmall = style(11, 16, FontWeight.Medium)
|
||||
)
|
||||
|
||||
/** Corner shapes used by the app. */
|
||||
@Immutable
|
||||
data class AppShapes(
|
||||
val small: Shape,
|
||||
val medium: Shape,
|
||||
val large: Shape
|
||||
)
|
||||
|
||||
val AppDefaultShapes = AppShapes(
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp)
|
||||
)
|
||||
|
||||
val LocalAppColors =
|
||||
staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) }
|
||||
val LocalAppTypography = staticCompositionLocalOf { AppDefaultTypography }
|
||||
val LocalAppShapes = staticCompositionLocalOf { AppDefaultShapes }
|
||||
|
||||
/** Reads the active app theme tokens, all backed by Millennium. */
|
||||
object AppTheme {
|
||||
val colors: AppColors @Composable get() = LocalAppColors.current
|
||||
val typography: AppTypography @Composable get() = LocalAppTypography.current
|
||||
val shapes: AppShapes @Composable get() = LocalAppShapes.current
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's single source of truth for colour.
|
||||
*
|
||||
* Installs [MillenniumTheme] with the user's selected [UserSettings.palette] and light/dark
|
||||
* [UserSettings.theme], then derives the app's semantic [AppColors], [AppTypography] and
|
||||
* [AppShapes] tokens from the active Millennium theme.
|
||||
*/
|
||||
@Composable
|
||||
fun SpotubeTheme(
|
||||
settings: UserSettings,
|
||||
content: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val isDarkTheme = when (settings.theme) {
|
||||
Theme.LIGHT -> false
|
||||
Theme.DARK -> true
|
||||
Theme.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
val palette: MillenniumPalette = settings.palette
|
||||
|
||||
val accent = if (isDarkTheme) {
|
||||
settings.accentColor.toDarkColor()
|
||||
} else {
|
||||
settings.accentColor.toLightColor()
|
||||
}
|
||||
|
||||
val colorScheme = rememberDynamicColorScheme(
|
||||
seedColor = accent,
|
||||
isDark = isDarkTheme,
|
||||
)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
) {
|
||||
Surface(content = content)
|
||||
MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) {
|
||||
val colors = appColorsFrom(MillenniumTheme.colors)
|
||||
MillenniumTooltipHost {
|
||||
CompositionLocalProvider(
|
||||
LocalAppColors provides colors,
|
||||
LocalAppTypography provides AppDefaultTypography,
|
||||
LocalAppShapes provides AppDefaultShapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the active Millennium appearance is light. */
|
||||
@Composable
|
||||
fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f
|
||||
|
||||
@ -1,222 +1,230 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronLeft
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.X
|
||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
||||
import dev.nucleusframework.webview.jsbridge.JsMessage
|
||||
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
||||
import dev.nucleusframework.webview.web.WebView
|
||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
||||
|
||||
class PostMessageHandler(
|
||||
private val onMessageReceived: (String) -> Unit = {}
|
||||
) : IJsMessageHandler {
|
||||
override fun methodName(): String {
|
||||
return "sendMessage"
|
||||
}
|
||||
|
||||
override fun handle(
|
||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||
) {
|
||||
onMessageReceived(message.params)
|
||||
callback(message.params)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
if (webViewController.getContent() == null) {
|
||||
// This should never happen, but just in case
|
||||
Text("No URL to load")
|
||||
return
|
||||
}
|
||||
|
||||
val state = remember {
|
||||
WebViewState(
|
||||
webViewController.getWebContent(
|
||||
additionalHttpHeaders = mapOf(
|
||||
"User-Agent" to UserAgents.random()
|
||||
)
|
||||
)
|
||||
)
|
||||
}.apply {
|
||||
this.content = webViewController.getWebContent()
|
||||
platformWebviewConfig(this, webViewController.currentPluginId)
|
||||
}
|
||||
|
||||
val navigator = rememberWebViewNavigator()
|
||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||
|
||||
val bridgeBootstrapScript = remember {
|
||||
"""
|
||||
(function() {
|
||||
if (typeof window.sendMessage !== "function") {
|
||||
window.sendMessage = function(message) {
|
||||
if (typeof message !== "string") {
|
||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||
}
|
||||
window.kmpJsBridge.callNative("sendMessage", message);
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.bridgeReady) {
|
||||
const event = new CustomEvent("onBridgeReady");
|
||||
window.dispatchEvent(event);
|
||||
window.bridgeReady = true;
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
||||
if (url != null) {
|
||||
webViewController.emitUrlChange(url)
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
webViewController.emitWebViewCreated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.cookieManager, navigator) {
|
||||
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
||||
webViewController.webViewNavigator = navigator
|
||||
}
|
||||
|
||||
LaunchedEffect(webViewBridge) {
|
||||
webViewBridge.register(PostMessageHandler { message ->
|
||||
webViewController.emitPostMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webViewController.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier.height(56.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator.navigateBack()
|
||||
}, enabled = navigator.canGoBack
|
||||
) {
|
||||
Icon(
|
||||
FeatherIcons.ChevronLeft,
|
||||
contentDescription = "Go back to browser history"
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator.navigateForward()
|
||||
}, enabled = navigator.canGoForward
|
||||
) {
|
||||
Icon(
|
||||
FeatherIcons.ChevronRight,
|
||||
contentDescription = "Go forward to browser history"
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
||||
) {
|
||||
BasicTextField(
|
||||
value = state.lastLoadedUrl ?: "",
|
||||
onValueChange = {}, // Read-only
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
{ innerPadding ->
|
||||
WebView(
|
||||
state = state,
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||
navigator = navigator,
|
||||
webViewJsBridge = webViewBridge,
|
||||
onCreated = { webView ->
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
|
||||
},
|
||||
)
|
||||
}
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronLeft
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.X
|
||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
||||
import dev.nucleusframework.webview.jsbridge.JsMessage
|
||||
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
||||
import dev.nucleusframework.webview.web.WebView
|
||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
||||
|
||||
class PostMessageHandler(
|
||||
private val onMessageReceived: (String) -> Unit = {}
|
||||
) : IJsMessageHandler {
|
||||
override fun methodName(): String {
|
||||
return "sendMessage"
|
||||
}
|
||||
|
||||
override fun handle(
|
||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||
) {
|
||||
onMessageReceived(message.params)
|
||||
callback(message.params)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
if (webViewController.getContent() == null) {
|
||||
// This should never happen, but just in case
|
||||
Text("No URL to load")
|
||||
return
|
||||
}
|
||||
|
||||
val state = remember {
|
||||
WebViewState(
|
||||
webViewController.getWebContent(
|
||||
additionalHttpHeaders = mapOf(
|
||||
"User-Agent" to UserAgents.random()
|
||||
)
|
||||
)
|
||||
)
|
||||
}.apply {
|
||||
this.content = webViewController.getWebContent()
|
||||
platformWebviewConfig(this, webViewController.currentPluginId)
|
||||
}
|
||||
|
||||
val navigator = rememberWebViewNavigator()
|
||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||
|
||||
val bridgeBootstrapScript = remember {
|
||||
"""
|
||||
(function() {
|
||||
if (typeof window.sendMessage !== "function") {
|
||||
window.sendMessage = function(message) {
|
||||
if (typeof message !== "string") {
|
||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||
}
|
||||
window.kmpJsBridge.callNative("sendMessage", message);
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.bridgeReady) {
|
||||
const event = new CustomEvent("onBridgeReady");
|
||||
window.dispatchEvent(event);
|
||||
window.bridgeReady = true;
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
||||
if (url != null) {
|
||||
webViewController.emitUrlChange(url)
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
webViewController.emitWebViewCreated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.cookieManager, navigator) {
|
||||
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
||||
webViewController.webViewNavigator = navigator
|
||||
}
|
||||
|
||||
LaunchedEffect(webViewBridge) {
|
||||
webViewBridge.register(PostMessageHandler { message ->
|
||||
webViewController.emitPostMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webViewController.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier.height(56.dp)
|
||||
) {
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
navigator.navigateBack()
|
||||
},
|
||||
enabled = navigator.canGoBack,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
FeatherIcons.ChevronLeft,
|
||||
contentDescription = "Go back to browser history"
|
||||
)
|
||||
}
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
navigator.navigateForward()
|
||||
},
|
||||
enabled = navigator.canGoForward,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
FeatherIcons.ChevronRight,
|
||||
contentDescription = "Go forward to browser history"
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(36.dp)
|
||||
.padding(horizontal = 4.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f))
|
||||
.border(
|
||||
BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)),
|
||||
RoundedCornerShape(18.dp)),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
BasicTextField(
|
||||
value = state.lastLoadedUrl ?: "",
|
||||
onValueChange = {}, // Read-only
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
textStyle = AppTheme.typography.bodyMedium.copy(
|
||||
color = AppTheme.colors.onSurface,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
WebView(
|
||||
state = state,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
navigator = navigator,
|
||||
webViewJsBridge = webViewBridge,
|
||||
onCreated = { webView ->
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -38,8 +38,7 @@ import org.koin.core.component.KoinComponent
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class BuiltInPluginService(
|
||||
private val pluginInfo: PluginEntry,
|
||||
) : PluginService, KoinComponent {
|
||||
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
||||
|
||||
override val pluginId: String = pluginInfo.id
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
@ -58,7 +57,6 @@ class BuiltInPluginService(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override suspend fun start() {
|
||||
when (pluginInfo) {
|
||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {
|
||||
|
||||
@ -1,347 +1,344 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline
|
||||
|
||||
import app.cash.zipline.Zipline
|
||||
import app.cash.zipline.ZiplineService
|
||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||
import app.cash.zipline.loader.LoadResult
|
||||
import app.cash.zipline.loader.ManifestVerifier
|
||||
import app.cash.zipline.loader.ZiplineLoader
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.decodeURLQueryComponent
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of a Zipline plugin.
|
||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||
* Anything it provides, must be called within that dispatcher context.
|
||||
* The [use] function must be used.
|
||||
*
|
||||
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
||||
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
||||
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
||||
*
|
||||
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
||||
*/
|
||||
open class ZiplinePluginService(
|
||||
val applicationName: String,
|
||||
private val manifestUrl: String,
|
||||
private val pluginInfo: PluginEntry,
|
||||
) : PluginService, KoinComponent {
|
||||
|
||||
override val pluginId: String = pluginInfo.id
|
||||
|
||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
||||
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||
// instance is created, before any modules are loaded.
|
||||
private val ziplineDispatcher = createZiplineDispatcher()
|
||||
|
||||
private fun trace(event: String) {
|
||||
logger.d { "[$applicationName] $event" }
|
||||
}
|
||||
|
||||
private val logger by injectLogger<ZiplinePluginService>()
|
||||
private val webViewController: WebViewController by inject()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
logger.e(throwable) { "Zipline Engine Error" }
|
||||
}
|
||||
private val lifecycleMutex = Mutex()
|
||||
private var ziplineLoader: ZiplineLoader
|
||||
private var ziplineInstance: Zipline? = null
|
||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||
|
||||
init {
|
||||
val manifestPath = URLBuilder(manifestUrl)
|
||||
val baseDir =
|
||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||
ziplineLoader = ZiplineLoader(
|
||||
dispatcher = ziplineDispatcher.dispatcher,
|
||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||
httpClient = FileSystemHTTPClient(baseDir)
|
||||
)
|
||||
}
|
||||
|
||||
private val realHttpClientAPI = RealHttpClientAPI()
|
||||
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
|
||||
|
||||
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
||||
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
||||
private val systemInformationAPI = RealSystemInformationAPI()
|
||||
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||
|
||||
private fun bindHostServices(zipline: Zipline) {
|
||||
trace("initializer(): binding host APIs")
|
||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||
try {
|
||||
// Basic APIs
|
||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||
zipline.bind<SystemInformationAPI>(
|
||||
SystemInformationAPI_SERVICE_NAME,
|
||||
systemInformationAPI
|
||||
)
|
||||
|
||||
// Conditional APIs based on plugin capabilities
|
||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||
zipline.bind<HttpClientAPI>(
|
||||
HttpClientAPI_SERVICE_NAME,
|
||||
realHttpClientAPI
|
||||
)
|
||||
}
|
||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||
}
|
||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||
zipline.bind<PersistedStorageAPI>(
|
||||
PersistedStorageAPI_SERVICE_NAME,
|
||||
persistedStorageAPI
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumePluginServices(result: LoadResult.Success) {
|
||||
trace("start(): loadOnce success")
|
||||
val apiMap =
|
||||
buildMap<KClass<*>, ZiplineService> {
|
||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||
|
||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||
put(
|
||||
MetadataUserAPI::class,
|
||||
result.zipline.take<MetadataUserAPI>(
|
||||
MetadataUserAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataTrackAPI::class,
|
||||
result.zipline.take<MetadataTrackAPI>(
|
||||
MetadataTrackAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataAlbumAPI::class,
|
||||
result.zipline.take<MetadataAlbumAPI>(
|
||||
MetadataAlbumAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataArtistAPI::class,
|
||||
result.zipline.take<MetadataArtistAPI>(
|
||||
MetadataArtistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataPlaylistAPI::class,
|
||||
result.zipline.take<MetadataPlaylistAPI>(
|
||||
MetadataPlaylistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataBrowseAPI::class,
|
||||
result.zipline.take<MetadataBrowseAPI>(
|
||||
MetadataBrowseAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataSearchAPI::class,
|
||||
result.zipline.take<MetadataSearchAPI>(
|
||||
MetadataSearchAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||
put(
|
||||
AudioAPI::class,
|
||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||
put(
|
||||
LyricsAPI::class,
|
||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||
put(
|
||||
ScrobbleAPI::class,
|
||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serviceRegistry.putAll(apiMap)
|
||||
trace("start(): API ready")
|
||||
}
|
||||
|
||||
private fun runLogInFlowObservers() = scope.launch {
|
||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||
loggedInStateFlow.value = isLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("start(): entered")
|
||||
if (serviceRegistry.isNotEmpty()) {
|
||||
trace("start(): already started, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
trace("start(): inside zipline dispatcher before loadOnce")
|
||||
val result = ziplineLoader.loadOnce(
|
||||
applicationName = applicationName,
|
||||
manifestUrl = manifestUrl,
|
||||
freshnessChecker = DefaultFreshnessCheckerNotFresh,
|
||||
)
|
||||
when (result) {
|
||||
is LoadResult.Success -> {
|
||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||
ziplineInstance = result.zipline
|
||||
// Now we consume the initializer
|
||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||
// Bind host services before initialization, so plugins can use them in their initializer
|
||||
bindHostServices(result.zipline)
|
||||
trace("start(): calling initializer.initialize()")
|
||||
runCatching { initializer.initialize() }
|
||||
.onSuccess {
|
||||
consumePluginServices(result)
|
||||
runLogInFlowObservers()
|
||||
}
|
||||
.onFailure { e ->
|
||||
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
is LoadResult.Failure -> {
|
||||
trace("start(): loadOnce failure: ${result.exception}")
|
||||
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
||||
throw result.exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("stop(): entered")
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
ziplineInstance?.close()
|
||||
ziplineInstance = null
|
||||
for (service in serviceRegistry.values) {
|
||||
try {
|
||||
trace("stop(): closing service ${service::class.simpleName}")
|
||||
service.close()
|
||||
} catch (_: Exception) {
|
||||
trace("stop(): error closing service ${service::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
serviceRegistry.clear()
|
||||
scope.cancel()
|
||||
loggedInStateFlow.value = false
|
||||
ziplineDispatcher.close()
|
||||
trace("stop(): completed")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
||||
// Create the scope with the current registry
|
||||
val scope = PluginServiceScope(serviceRegistry)
|
||||
// Execute the block with 'scope' as 'this'
|
||||
scope.block()
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline
|
||||
|
||||
import app.cash.zipline.Zipline
|
||||
import app.cash.zipline.ZiplineService
|
||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||
import app.cash.zipline.loader.LoadResult
|
||||
import app.cash.zipline.loader.ManifestVerifier
|
||||
import app.cash.zipline.loader.ZiplineLoader
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.decodeURLQueryComponent
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of a Zipline plugin.
|
||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||
* Anything it provides, must be called within that dispatcher context.
|
||||
* The [use] function must be used.
|
||||
*
|
||||
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
||||
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
||||
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
||||
*
|
||||
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
||||
*/
|
||||
open class ZiplinePluginService(
|
||||
val applicationName: String,
|
||||
private val manifestUrl: String,
|
||||
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
||||
|
||||
override val pluginId: String = pluginInfo.id
|
||||
|
||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
||||
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||
// instance is created, before any modules are loaded.
|
||||
private val ziplineDispatcher = createZiplineDispatcher()
|
||||
|
||||
private fun trace(event: String) {
|
||||
logger.d { "[$applicationName] $event" }
|
||||
}
|
||||
|
||||
private val logger by injectLogger<ZiplinePluginService>()
|
||||
private val webViewController: WebViewController by inject()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
logger.e(throwable) { "Zipline Engine Error" }
|
||||
}
|
||||
private val lifecycleMutex = Mutex()
|
||||
private var ziplineLoader: ZiplineLoader
|
||||
private var ziplineInstance: Zipline? = null
|
||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||
|
||||
init {
|
||||
val manifestPath = URLBuilder(manifestUrl)
|
||||
val baseDir =
|
||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||
ziplineLoader = ZiplineLoader(
|
||||
dispatcher = ziplineDispatcher.dispatcher,
|
||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||
httpClient = FileSystemHTTPClient(baseDir)
|
||||
)
|
||||
}
|
||||
|
||||
private val realHttpClientAPI = RealHttpClientAPI()
|
||||
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
|
||||
|
||||
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
||||
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
||||
private val systemInformationAPI = RealSystemInformationAPI()
|
||||
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||
|
||||
private fun bindHostServices(zipline: Zipline) {
|
||||
trace("initializer(): binding host APIs")
|
||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||
try {
|
||||
// Basic APIs
|
||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||
zipline.bind<SystemInformationAPI>(
|
||||
SystemInformationAPI_SERVICE_NAME,
|
||||
systemInformationAPI
|
||||
)
|
||||
|
||||
// Conditional APIs based on plugin capabilities
|
||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||
zipline.bind<HttpClientAPI>(
|
||||
HttpClientAPI_SERVICE_NAME,
|
||||
realHttpClientAPI
|
||||
)
|
||||
}
|
||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||
}
|
||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||
zipline.bind<PersistedStorageAPI>(
|
||||
PersistedStorageAPI_SERVICE_NAME,
|
||||
persistedStorageAPI
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumePluginServices(result: LoadResult.Success) {
|
||||
trace("start(): loadOnce success")
|
||||
val apiMap =
|
||||
buildMap<KClass<*>, ZiplineService> {
|
||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||
|
||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||
put(
|
||||
MetadataUserAPI::class,
|
||||
result.zipline.take<MetadataUserAPI>(
|
||||
MetadataUserAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataTrackAPI::class,
|
||||
result.zipline.take<MetadataTrackAPI>(
|
||||
MetadataTrackAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataAlbumAPI::class,
|
||||
result.zipline.take<MetadataAlbumAPI>(
|
||||
MetadataAlbumAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataArtistAPI::class,
|
||||
result.zipline.take<MetadataArtistAPI>(
|
||||
MetadataArtistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataPlaylistAPI::class,
|
||||
result.zipline.take<MetadataPlaylistAPI>(
|
||||
MetadataPlaylistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataBrowseAPI::class,
|
||||
result.zipline.take<MetadataBrowseAPI>(
|
||||
MetadataBrowseAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataSearchAPI::class,
|
||||
result.zipline.take<MetadataSearchAPI>(
|
||||
MetadataSearchAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||
put(
|
||||
AudioAPI::class,
|
||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||
put(
|
||||
LyricsAPI::class,
|
||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||
put(
|
||||
ScrobbleAPI::class,
|
||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serviceRegistry.putAll(apiMap)
|
||||
trace("start(): API ready")
|
||||
}
|
||||
|
||||
private fun runLogInFlowObservers() = scope.launch {
|
||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||
loggedInStateFlow.value = isLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("start(): entered")
|
||||
if (serviceRegistry.isNotEmpty()) {
|
||||
trace("start(): already started, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
trace("start(): inside zipline dispatcher before loadOnce")
|
||||
val result = ziplineLoader.loadOnce(
|
||||
applicationName = applicationName,
|
||||
manifestUrl = manifestUrl,
|
||||
freshnessChecker = DefaultFreshnessCheckerNotFresh)
|
||||
when (result) {
|
||||
is LoadResult.Success -> {
|
||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||
ziplineInstance = result.zipline
|
||||
// Now we consume the initializer
|
||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||
// Bind host services before initialization, so plugins can use them in their initializer
|
||||
bindHostServices(result.zipline)
|
||||
trace("start(): calling initializer.initialize()")
|
||||
runCatching { initializer.initialize() }
|
||||
.onSuccess {
|
||||
consumePluginServices(result)
|
||||
runLogInFlowObservers()
|
||||
}
|
||||
.onFailure { e ->
|
||||
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
is LoadResult.Failure -> {
|
||||
trace("start(): loadOnce failure: ${result.exception}")
|
||||
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
||||
throw result.exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("stop(): entered")
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
ziplineInstance?.close()
|
||||
ziplineInstance = null
|
||||
for (service in serviceRegistry.values) {
|
||||
try {
|
||||
trace("stop(): closing service ${service::class.simpleName}")
|
||||
service.close()
|
||||
} catch (_: Exception) {
|
||||
trace("stop(): error closing service ${service::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
serviceRegistry.clear()
|
||||
scope.cancel()
|
||||
loggedInStateFlow.value = false
|
||||
ziplineDispatcher.close()
|
||||
trace("stop(): completed")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
||||
// Create the scope with the current registry
|
||||
val scope = PluginServiceScope(serviceRegistry)
|
||||
// Execute the block with 'scope' as 'this'
|
||||
scope.block()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -185,7 +185,6 @@ fun AESEncodingFormats.format(): AES.Key.Format {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
private val provider = CryptographyProvider.Default
|
||||
|
||||
@ -193,7 +192,6 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
return CryptographyRandom.nextBytes(size)
|
||||
}
|
||||
|
||||
|
||||
override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray {
|
||||
val hasher = provider.get(algorithm.algorithm()).hasher()
|
||||
return hasher.hash(data)
|
||||
@ -301,8 +299,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
val keyPair = provider.get(RSA.PSS).keyPairGenerator(
|
||||
algorithm.keySizeBits.bits,
|
||||
algorithm.hashAlgorithm.algorithm(),
|
||||
algorithm.publicExponent.toBigInt(),
|
||||
).generateKey()
|
||||
algorithm.publicExponent.toBigInt()).generateKey()
|
||||
|
||||
Pair(
|
||||
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
|
||||
@ -366,8 +363,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.ECDSA ->
|
||||
provider.get(ECDSA).privateKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.privateKeyFormat(),
|
||||
privateKey
|
||||
).signatureGenerator(
|
||||
@ -377,8 +373,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.EdDSA ->
|
||||
provider.get(EdDSA).privateKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.privateKeyFormat(),
|
||||
privateKey
|
||||
).signatureGenerator().generateSignature(data)
|
||||
@ -422,8 +417,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.ECDSA ->
|
||||
provider.get(ECDSA).publicKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.publicKeyFormat(),
|
||||
publicKey
|
||||
).signatureVerifier(
|
||||
@ -436,8 +430,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.EdDSA ->
|
||||
provider.get(EdDSA).publicKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.publicKeyFormat(),
|
||||
publicKey
|
||||
).signatureVerifier().verifySignature(
|
||||
|
||||
@ -28,7 +28,6 @@ import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.takeFrom
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
|
||||
class RealHttpClientAPI : HttpClientAPI, KoinComponent {
|
||||
val logger by injectLogger<RealHttpClientAPI>()
|
||||
val httpClient = HttpClient()
|
||||
|
||||
@ -1,74 +1,73 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.host_apis
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RealWebViewAPI(
|
||||
private val scope: CoroutineScope,
|
||||
private val webViewController: WebViewController,
|
||||
private val pluginId: String,
|
||||
) : WebViewAPI {
|
||||
override fun navigateTo(url: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.navigateTo(url, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToHTML(html: String) {
|
||||
scope.launch {
|
||||
webViewController.navigateToHTML(html, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCookies(url: String): List<Cookie> {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.getCookies(url)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun evaluateJavaScript(script: String): String? {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.evaluateJavascript(script)
|
||||
}
|
||||
}
|
||||
|
||||
override fun urlChangeFlow(): Flow<String> {
|
||||
return webViewController.urlChangedFlow
|
||||
}
|
||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||
return webViewController.webviewCreatedFlow
|
||||
}
|
||||
override fun postMessagesFlow(): Flow<String> {
|
||||
return webViewController.postMessagesFlow
|
||||
}
|
||||
|
||||
override fun exitWebView() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.closeWebview()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.host_apis
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RealWebViewAPI(
|
||||
private val scope: CoroutineScope,
|
||||
private val webViewController: WebViewController,
|
||||
private val pluginId: String) : WebViewAPI {
|
||||
override fun navigateTo(url: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.navigateTo(url, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToHTML(html: String) {
|
||||
scope.launch {
|
||||
webViewController.navigateToHTML(html, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCookies(url: String): List<Cookie> {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.getCookies(url)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun evaluateJavaScript(script: String): String? {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.evaluateJavascript(script)
|
||||
}
|
||||
}
|
||||
|
||||
override fun urlChangeFlow(): Flow<String> {
|
||||
return webViewController.urlChangedFlow
|
||||
}
|
||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||
return webViewController.webviewCreatedFlow
|
||||
}
|
||||
override fun postMessagesFlow(): Flow<String> {
|
||||
return webViewController.postMessagesFlow
|
||||
}
|
||||
|
||||
override fun exitWebView() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.closeWebview()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -45,8 +45,7 @@ data class LRCLibResponse(
|
||||
val artistName: String,
|
||||
val albumName: String,
|
||||
val plainLyrics: String?,
|
||||
val syncedLyrics: String?,
|
||||
)
|
||||
val syncedLyrics: String?)
|
||||
|
||||
class RealLRCLibLyricsAPI : LyricsAPI {
|
||||
private val httpClient = HttpClient {
|
||||
@ -95,7 +94,6 @@ class RealLRCLibLyricsAPI : LyricsAPI {
|
||||
val text = line.substring(match.range.last + 1).trim()
|
||||
LyricsLine(time = timeMs, text = text)
|
||||
},
|
||||
plainLyrics = response.plainLyrics,
|
||||
)
|
||||
plainLyrics = response.plainLyrics)
|
||||
}
|
||||
}
|
||||
@ -124,16 +124,14 @@ data class MusicGatewayResponse(
|
||||
val externalIds: ExternalIds,
|
||||
val id: String,
|
||||
val name: String,
|
||||
val popularity: Int,
|
||||
) {
|
||||
val popularity: Int) {
|
||||
@Serializable
|
||||
data class Album(
|
||||
@SerialName("album_type")
|
||||
val albumType: String,
|
||||
val artists: List<Artist>,
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
val name: String)
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
|
||||