mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-09 20:53:59 +00:00
Compare commits
1 Commits
5711721c6a
...
41001ee082
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41001ee082 |
@ -31,7 +31,6 @@
|
|||||||
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
||||||
|
|
||||||
## UI component patterns
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import gobley.gradle.cargo.dsl.jvm
|
|||||||
import dev.nucleusframework.desktop.application.dsl.TargetFormat
|
import dev.nucleusframework.desktop.application.dsl.TargetFormat
|
||||||
import org.jetbrains.compose.reload.gradle.ComposeHotRun
|
import org.jetbrains.compose.reload.gradle.ComposeHotRun
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
import org.gradle.api.tasks.Copy
|
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
@ -40,53 +39,11 @@ plugins {
|
|||||||
kotlin("plugin.atomicfu") version libs.versions.kotlin
|
kotlin("plugin.atomicfu") version libs.versions.kotlin
|
||||||
}
|
}
|
||||||
|
|
||||||
val bundleId = "dev.krtirtho.spotube"
|
|
||||||
val version = "6.0.0"
|
|
||||||
val buildNumber = 46
|
|
||||||
val targetName = "Spotube"
|
|
||||||
val homePageUrl = "https://spotube.cc"
|
|
||||||
val repositoryUrl = "https://github.com/team-spotube/spotube"
|
|
||||||
val appVersionName = version
|
|
||||||
val appBuildNumber = buildNumber
|
|
||||||
val appRepositoryUrl = repositoryUrl
|
|
||||||
val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/")
|
|
||||||
val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest"
|
|
||||||
val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin")
|
|
||||||
val generateAppBuildInfo by tasks.registering(Copy::class) {
|
|
||||||
description = "Generate AppBuildInfo.kt"
|
|
||||||
from(layout.projectDirectory.file("src/commonMain/templates/AppBuildInfo.kt.template"))
|
|
||||||
into(layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin/dev/krtirtho/spotube"))
|
|
||||||
rename("AppBuildInfo.kt.template", "AppBuildInfo.kt")
|
|
||||||
expand(
|
|
||||||
mapOf(
|
|
||||||
"appVersion" to appVersionName,
|
|
||||||
"appBuildNumber" to appBuildNumber,
|
|
||||||
"repositoryUrl" to appRepositoryUrl,
|
|
||||||
"latestReleaseApiUrl" to latestReleaseApiUrl,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
inputs.properties(
|
|
||||||
mapOf(
|
|
||||||
"appVersion" to appVersionName,
|
|
||||||
"appBuildNumber" to appBuildNumber,
|
|
||||||
"repositoryUrl" to appRepositoryUrl,
|
|
||||||
"latestReleaseApiUrl" to latestReleaseApiUrl,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.configureEach {
|
|
||||||
if (name.startsWith("compile") && name.contains("Kotlin")) {
|
|
||||||
dependsOn(generateAppBuildInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vlcjBundler {
|
vlcjBundler {
|
||||||
packageName = "$bundleId.core.generated" // choose a different package
|
packageName = "dev.krtirtho.spotube.core.generated" // choose a different package
|
||||||
objectName = "VLCBundleLoaderGenerated" // or rename the object
|
objectName = "VLCBundleLoaderGenerated" // or rename the object
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
// Note: For Android application modules, androidTarget() is still required as of AGP 8.x.
|
// Note: For Android application modules, androidTarget() is still required as of AGP 8.x.
|
||||||
// The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead.
|
// The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead.
|
||||||
@ -111,7 +68,6 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
val commonMain by getting {
|
val commonMain by getting {
|
||||||
kotlin.srcDir(generatedAppInfoDir)
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Project dependencies
|
// Project dependencies
|
||||||
implementation(project(":plugin_interfaces"))
|
implementation(project(":plugin_interfaces"))
|
||||||
@ -216,10 +172,6 @@ kotlin {
|
|||||||
implementation(libs.mqtt.x.models)
|
implementation(libs.mqtt.x.models)
|
||||||
implementation(libs.mqtt.buffer)
|
implementation(libs.mqtt.buffer)
|
||||||
implementation(libs.mqtt.buffer.codec)
|
implementation(libs.mqtt.buffer.codec)
|
||||||
|
|
||||||
// Markdown renderer
|
|
||||||
implementation(libs.multiplatform.markdown.renderer)
|
|
||||||
implementation(libs.multiplatform.markdown.renderer.m3)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
@ -297,11 +249,11 @@ android {
|
|||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = bundleId
|
applicationId = "dev.krtirtho.spotube"
|
||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = buildNumber
|
versionCode = 1
|
||||||
versionName = version
|
versionName = project.findProperty("versionName") as String? ?: "1.0"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@ -360,8 +312,8 @@ nucleus.application {
|
|||||||
)
|
)
|
||||||
|
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
packageName = bundleId
|
packageName = "dev.krtirtho.spotube"
|
||||||
packageVersion = version
|
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
|
||||||
licenseFile = project.file("../LICENSE")
|
licenseFile = project.file("../LICENSE")
|
||||||
|
|
||||||
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
|
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
|
||||||
@ -377,7 +329,7 @@ nucleus.application {
|
|||||||
|
|
||||||
modules("jdk.unsupported")
|
modules("jdk.unsupported")
|
||||||
|
|
||||||
homepage = homePageUrl
|
homepage = "https://spotube.cc"
|
||||||
|
|
||||||
linux {
|
linux {
|
||||||
modules("jdk.security.auth")
|
modules("jdk.security.auth")
|
||||||
@ -419,4 +371,4 @@ cargo {
|
|||||||
builds.jvm {
|
builds.jvm {
|
||||||
embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget)
|
embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -198,12 +198,4 @@
|
|||||||
<string name="player_go_to_album">Go to album</string>
|
<string name="player_go_to_album">Go to album</string>
|
||||||
<string name="player_sleep_timer">Sleep timer</string>
|
<string name="player_sleep_timer">Sleep timer</string>
|
||||||
<string name="player_track_details">Track details</string>
|
<string name="player_track_details">Track details</string>
|
||||||
|
|
||||||
<string name="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>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -24,8 +24,11 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@ -35,28 +38,28 @@ import dev.krtirtho.spotube.core.navigation.Routes
|
|||||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||||
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
|
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
|
||||||
import dev.krtirtho.spotube.core.navigation.toEntries
|
import dev.krtirtho.spotube.core.navigation.toEntries
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
||||||
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
import dev.krtirtho.spotube.modules.shell.AppShell
|
import dev.krtirtho.spotube.modules.shell.AppShell
|
||||||
import dev.krtirtho.spotube.modules.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.webview.WebViewScreen
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHome
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxHome
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirrorScreenRegular
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirrorScreenRegular
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibraryOutline
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSettingTwotone
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSound
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
|
||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@ -135,10 +138,6 @@ fun App(
|
|||||||
) {
|
) {
|
||||||
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
||||||
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
||||||
val appUpdateService: AppUpdateService = koinInject()
|
|
||||||
val pluginUpdateService: PluginUpdateService = koinInject()
|
|
||||||
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
|
||||||
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
val navigationState = rememberNavigationState(
|
val navigationState = rememberNavigationState(
|
||||||
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||||
@ -176,28 +175,6 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
content()
|
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()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,8 +73,6 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
|||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||||
import dev.krtirtho.spotube.modules.search.SearchRepository
|
import dev.krtirtho.spotube.modules.search.SearchRepository
|
||||||
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
|
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
|
||||||
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.SettingsProvider
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
|
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
|
||||||
@ -138,10 +136,6 @@ val sharedModules = module {
|
|||||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||||
viewModelOf(::JamSettingsViewModel)
|
viewModelOf(::JamSettingsViewModel)
|
||||||
|
|
||||||
// Updates
|
|
||||||
single { AppUpdateService(get()) } withOptions { createdAtStart() }
|
|
||||||
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
|
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
singleOf(::DownloadManager)
|
singleOf(::DownloadManager)
|
||||||
viewModelOf(::DownloadsViewModel)
|
viewModelOf(::DownloadsViewModel)
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import androidx.compose.foundation.hoverable
|
|||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -69,6 +70,7 @@ import androidx.compose.ui.focus.onFocusChanged
|
|||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEvent
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@ -171,9 +173,11 @@ fun <T> AutocompleteTextField(
|
|||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
|
|
||||||
var isMenuOpen by remember { mutableStateOf(false) }
|
var isMenuOpen by remember { mutableStateOf(false) }
|
||||||
|
var isMovingFocusToPopup by remember { mutableStateOf(false) }
|
||||||
var selectedIndex by remember { mutableIntStateOf(-1) }
|
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||||
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
|
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
val menuListState = rememberLazyListState()
|
val menuListState = rememberLazyListState()
|
||||||
|
val popupFocusRequester = remember { FocusRequester() }
|
||||||
val textFieldFocusRequester = remember { FocusRequester() }
|
val textFieldFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val menuExpanded = isMenuOpen && items.isNotEmpty()
|
val menuExpanded = isMenuOpen && items.isNotEmpty()
|
||||||
@ -181,6 +185,7 @@ fun <T> AutocompleteTextField(
|
|||||||
LaunchedEffect(isFocused) {
|
LaunchedEffect(isFocused) {
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
isMenuOpen = true
|
isMenuOpen = true
|
||||||
|
isMovingFocusToPopup = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,7 +291,7 @@ fun <T> AutocompleteTextField(
|
|||||||
)
|
)
|
||||||
.focusRequester(textFieldFocusRequester)
|
.focusRequester(textFieldFocusRequester)
|
||||||
.onFocusChanged { state ->
|
.onFocusChanged { state ->
|
||||||
if (!state.isFocused) {
|
if (!state.isFocused && !isMovingFocusToPopup) {
|
||||||
isMenuOpen = false
|
isMenuOpen = false
|
||||||
selectedIndex = -1
|
selectedIndex = -1
|
||||||
}
|
}
|
||||||
@ -298,6 +303,8 @@ fun <T> AutocompleteTextField(
|
|||||||
if (!menuExpanded) return@onPreviewKeyEvent false
|
if (!menuExpanded) return@onPreviewKeyEvent false
|
||||||
when (event.key) {
|
when (event.key) {
|
||||||
Key.DirectionDown -> {
|
Key.DirectionDown -> {
|
||||||
|
isMovingFocusToPopup = true
|
||||||
|
popupFocusRequester.requestFocus()
|
||||||
navigate(1)
|
navigate(1)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@ -367,7 +374,7 @@ fun <T> AutocompleteTextField(
|
|||||||
Popup(
|
Popup(
|
||||||
alignment = Alignment.TopStart,
|
alignment = Alignment.TopStart,
|
||||||
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
|
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
|
||||||
properties = PopupProperties(focusable = false),
|
properties = PopupProperties(focusable = true),
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
selectedIndex = -1
|
selectedIndex = -1
|
||||||
isMenuOpen = false
|
isMenuOpen = false
|
||||||
@ -379,7 +386,45 @@ fun <T> AutocompleteTextField(
|
|||||||
.heightIn(max = menuMaxHeight)
|
.heightIn(max = menuMaxHeight)
|
||||||
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
|
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
|
||||||
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
|
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
|
||||||
.clip(resolvedMenuTheme.shape),
|
.clip(resolvedMenuTheme.shape)
|
||||||
|
.focusRequester(popupFocusRequester)
|
||||||
|
.focusable()
|
||||||
|
.onFocusChanged { state ->
|
||||||
|
if (state.isFocused) {
|
||||||
|
isMovingFocusToPopup = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onKeyEvent { event ->
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionDown -> {
|
||||||
|
navigate(1); true
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.DirectionUp -> {
|
||||||
|
navigate(-1); true
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.Enter -> {
|
||||||
|
if (selectedIndex in items.indices) {
|
||||||
|
onItemSelected(items[selectedIndex])
|
||||||
|
selectedIndex = -1
|
||||||
|
isMenuOpen = false
|
||||||
|
textFieldFocusRequester.requestFocus()
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.Escape -> {
|
||||||
|
selectedIndex = -1
|
||||||
|
isMenuOpen = false
|
||||||
|
textFieldFocusRequester.requestFocus()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = menuListState,
|
state = menuListState,
|
||||||
|
|||||||
@ -87,8 +87,6 @@ data class UserSettings(
|
|||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
val autoCheckForUpdates: Boolean = true,
|
val autoCheckForUpdates: Boolean = true,
|
||||||
val ignoredAppUpdateVersion: String? = null,
|
|
||||||
val ignoredPluginUpdateVersions: Map<String, String> = emptyMap(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -59,10 +59,4 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
|
|||||||
prefs[SETTINGS_KEY] = Json.encodeToString(newSettings)
|
prefs[SETTINGS_KEY] = Json.encodeToString(newSettings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
suspend fun getCurrentSettings(): UserSettings {
|
|
||||||
val preferences = database.settingsDataStore.data.first()
|
|
||||||
val settingsJson = preferences[SETTINGS_KEY] ?: return UserSettings()
|
|
||||||
return Json.decodeFromString<UserSettings>(settingsJson)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,80 +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.modules.update
|
|
||||||
|
|
||||||
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.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.mikepenz.markdown.m3.Markdown
|
|
||||||
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 org.jetbrains.compose.resources.stringResource
|
|
||||||
import spotube.composeapp.generated.resources.Res
|
|
||||||
import spotube.composeapp.generated.resources.app_update_dialog_no_notes
|
|
||||||
import spotube.composeapp.generated.resources.app_update_dialog_title
|
|
||||||
import spotube.composeapp.generated.resources.update_action_ignore
|
|
||||||
import spotube.composeapp.generated.resources.update_action_update
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompts the user that a newer Spotube release is available on GitHub. [onUpdate]
|
|
||||||
* should take the user to the downloads page; [onIgnore] persistently silences this
|
|
||||||
* specific release tag so the check moves on to the next newer one.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AppUpdateDialog(
|
|
||||||
update: AvailableAppUpdate,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onIgnore: () -> Unit,
|
|
||||||
onUpdate: () -> Unit,
|
|
||||||
) {
|
|
||||||
ThemedDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.app_update_dialog_title, update.tag),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
OutlineButton(onClick = onIgnore) {
|
|
||||||
Text(stringResource(Res.string.update_action_ignore))
|
|
||||||
}
|
|
||||||
PrimaryButton(onClick = onUpdate) {
|
|
||||||
Text(stringResource(Res.string.update_action_update))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (update.releaseNotesMarkdown.isNotBlank()) {
|
|
||||||
SelectionContainer {
|
|
||||||
Markdown(
|
|
||||||
content = update.releaseNotesMarkdown,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(stringResource(Res.string.app_update_dialog_no_notes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,133 +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.modules.update
|
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
import dev.krtirtho.spotube.AppBuildInfo
|
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.engine.cio.CIO
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
import io.ktor.client.request.get
|
|
||||||
import io.ktor.client.request.header
|
|
||||||
import io.ktor.http.HttpHeaders
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import net.swiftzer.semver.SemVer
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
|
|
||||||
data class AvailableAppUpdate(
|
|
||||||
val tag: String,
|
|
||||||
val releaseNotesMarkdown: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks GitHub for a newer app release once on startup and exposes it for the UI to
|
|
||||||
* surface via [dev.krtirtho.spotube.modules.update.AppUpdateDialog]. This is an
|
|
||||||
* app-scoped singleton (not a ViewModel) since the check runs once for the whole
|
|
||||||
* process, independent of any screen's lifecycle.
|
|
||||||
*/
|
|
||||||
class AppUpdateService(
|
|
||||||
private val settingsRepository: SettingsRepository,
|
|
||||||
) : KoinComponent, AutoCloseable {
|
|
||||||
private val logger = Logger.withTag("AppUpdateService")
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
private val httpClient = HttpClient(CIO) {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json { ignoreUnknownKeys = true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null)
|
|
||||||
val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
checkForUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkForUpdate() {
|
|
||||||
try {
|
|
||||||
// Read directly from DataStore so the check honors persisted preferences,
|
|
||||||
// rather than the repository StateFlow's default value during startup.
|
|
||||||
val settings = settingsRepository.getCurrentSettings()
|
|
||||||
if (!settings.autoCheckForUpdates) return
|
|
||||||
|
|
||||||
val release = httpClient.get(AppBuildInfo.LATEST_RELEASE_API_URL) {
|
|
||||||
header(HttpHeaders.Accept, "application/vnd.github+json")
|
|
||||||
header(HttpHeaders.UserAgent, "Spotube/${AppBuildInfo.VERSION}")
|
|
||||||
}.body<GitHubRelease>()
|
|
||||||
|
|
||||||
val latestVersion = parseVersion(release.tagName.removePrefix("v"))
|
|
||||||
?: return logger.w { "Ignoring release with invalid SemVer tag: ${release.tagName}" }
|
|
||||||
val currentVersion = parseVersion(AppBuildInfo.VERSION)
|
|
||||||
?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" }
|
|
||||||
|
|
||||||
if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) {
|
|
||||||
_availableUpdate.value = AvailableAppUpdate(
|
|
||||||
tag = release.tagName,
|
|
||||||
releaseNotesMarkdown = release.body.orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.w(e) { "Failed to check for app updates" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ignoreUpdate() {
|
|
||||||
val update = _availableUpdate.value ?: return
|
|
||||||
_availableUpdate.value = null
|
|
||||||
scope.launch {
|
|
||||||
runCatching {
|
|
||||||
val settings = settingsRepository.getCurrentSettings()
|
|
||||||
settingsRepository.updateSettings(settings.copy(ignoredAppUpdateVersion = update.tag))
|
|
||||||
}.onFailure { e ->
|
|
||||||
logger.e(e) { "Failed to persist ignored app version" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissUpdate() {
|
|
||||||
_availableUpdate.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseVersion(version: String): SemVer? = runCatching {
|
|
||||||
SemVer.parse(version)
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
httpClient.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class GitHubRelease(
|
|
||||||
@SerialName("tag_name") val tagName: String,
|
|
||||||
val body: String? = null,
|
|
||||||
)
|
|
||||||
@ -1,89 +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.modules.update
|
|
||||||
|
|
||||||
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.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.mikepenz.markdown.m3.Markdown
|
|
||||||
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 org.jetbrains.compose.resources.stringResource
|
|
||||||
import spotube.composeapp.generated.resources.Res
|
|
||||||
import spotube.composeapp.generated.resources.plugin_update_dialog_current_version
|
|
||||||
import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes
|
|
||||||
import spotube.composeapp.generated.resources.plugin_update_dialog_title
|
|
||||||
import spotube.composeapp.generated.resources.update_action_ignore
|
|
||||||
import spotube.composeapp.generated.resources.update_action_update
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompts the user that a newer version of one of their installed plugins is
|
|
||||||
* available. [onUpdate] should open the plugin's direct download URL; [onIgnore]
|
|
||||||
* persistently silences this specific plugin version so the check moves on to the
|
|
||||||
* next newer one (or another plugin).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun PluginUpdateDialog(
|
|
||||||
update: AvailablePluginUpdate,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onIgnore: () -> Unit,
|
|
||||||
onUpdate: () -> Unit,
|
|
||||||
) {
|
|
||||||
ThemedDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
Res.string.plugin_update_dialog_title,
|
|
||||||
update.pluginName,
|
|
||||||
update.info.latestVersion,
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
OutlineButton(onClick = onIgnore) {
|
|
||||||
Text(stringResource(Res.string.update_action_ignore))
|
|
||||||
}
|
|
||||||
PrimaryButton(onClick = onUpdate) {
|
|
||||||
Text(stringResource(Res.string.update_action_update))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
)
|
|
||||||
if (update.info.releaseNotes.isNotBlank()) {
|
|
||||||
SelectionContainer {
|
|
||||||
Markdown(content = update.info.releaseNotes)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(stringResource(Res.string.plugin_update_dialog_no_notes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,137 +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.modules.update
|
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo
|
|
||||||
import dev.krtirtho.spotube.core.zipline.PluginService
|
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManagerStates
|
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import net.swiftzer.semver.SemVer
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
|
|
||||||
data class AvailablePluginUpdate(
|
|
||||||
val pluginId: String,
|
|
||||||
val pluginName: String,
|
|
||||||
val currentVersion: String,
|
|
||||||
val info: PluginUpdateInfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks the currently active plugins for updates through their own [PluginService.use]
|
|
||||||
* -> `coreAPI.checkPluginUpdates` API whenever the selected plugin set changes, and
|
|
||||||
* exposes at most one pending update at a time for the UI to surface via
|
|
||||||
* [dev.krtirtho.spotube.modules.update.PluginUpdateDialog]. This is an app-scoped
|
|
||||||
* singleton (not a ViewModel) since plugin runtimes are owned by [PluginManager]
|
|
||||||
* independent of any screen's lifecycle.
|
|
||||||
*/
|
|
||||||
class PluginUpdateService(
|
|
||||||
private val pluginManager: PluginManager,
|
|
||||||
private val settingsRepository: SettingsRepository,
|
|
||||||
) : KoinComponent {
|
|
||||||
private val logger = Logger.withTag("PluginUpdateService")
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
|
|
||||||
private val _availableUpdate = MutableStateFlow<AvailablePluginUpdate?>(null)
|
|
||||||
val availableUpdate: StateFlow<AvailablePluginUpdate?> = _availableUpdate.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
pluginManager.ziplineServices.filterNotNull().collect { services ->
|
|
||||||
checkForUpdates(services.values.filterNotNull())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkForUpdates(services: List<PluginService>) {
|
|
||||||
// Only surface one plugin update prompt at a time; re-checks happen the
|
|
||||||
// next time the selected plugin set changes.
|
|
||||||
if (_availableUpdate.value != null) return
|
|
||||||
|
|
||||||
val settings = settingsRepository.getCurrentSettings()
|
|
||||||
if (!settings.autoCheckForUpdates) return
|
|
||||||
|
|
||||||
for (service in services.distinctBy { it.pluginId }) {
|
|
||||||
val pluginEntry = findPluginEntry(service.pluginId) ?: continue
|
|
||||||
val currentVersion = parseVersion(pluginEntry.version) ?: continue
|
|
||||||
|
|
||||||
try {
|
|
||||||
val updateInfo = service.use { coreAPI.checkPluginUpdates(currentVersion) } ?: continue
|
|
||||||
val latestVersion = parseVersion(updateInfo.latestVersion)
|
|
||||||
if (latestVersion == null) {
|
|
||||||
logger.w { "Ignoring plugin update with invalid SemVer: ${updateInfo.latestVersion}" }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val ignoredVersion = settings.ignoredPluginUpdateVersions[pluginEntry.id]
|
|
||||||
if (latestVersion > currentVersion && updateInfo.latestVersion != ignoredVersion) {
|
|
||||||
_availableUpdate.value = AvailablePluginUpdate(
|
|
||||||
pluginId = pluginEntry.id,
|
|
||||||
pluginName = pluginEntry.name,
|
|
||||||
currentVersion = pluginEntry.version,
|
|
||||||
info = updateInfo,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.w(e) { "Failed to check updates for plugin ${pluginEntry.name}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findPluginEntry(pluginId: String): PluginEntry? {
|
|
||||||
val state = pluginManager.state.value as? PluginManagerStates.Data ?: return null
|
|
||||||
return state.plugins.find { it.id == pluginId }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ignoreUpdate() {
|
|
||||||
val update = _availableUpdate.value ?: return
|
|
||||||
_availableUpdate.value = null
|
|
||||||
scope.launch {
|
|
||||||
runCatching {
|
|
||||||
val settings = settingsRepository.getCurrentSettings()
|
|
||||||
settingsRepository.updateSettings(
|
|
||||||
settings.copy(
|
|
||||||
ignoredPluginUpdateVersions = settings.ignoredPluginUpdateVersions +
|
|
||||||
(update.pluginId to update.info.latestVersion)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}.onFailure { e ->
|
|
||||||
logger.e(e) { "Failed to persist ignored plugin update version" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissUpdate() {
|
|
||||||
_availableUpdate.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseVersion(version: String): SemVer? = runCatching {
|
|
||||||
SemVer.parse(version)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
@ -1,25 +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
|
|
||||||
|
|
||||||
internal object AppBuildInfo {
|
|
||||||
const val VERSION = "${appVersion}"
|
|
||||||
const val BUILD_NUMBER = ${appBuildNumber}
|
|
||||||
const val REPOSITORY_URL = "${repositoryUrl}"
|
|
||||||
const val LATEST_RELEASE_API_URL = "${latestReleaseApiUrl}"
|
|
||||||
}
|
|
||||||
@ -26,4 +26,5 @@ toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac6
|
|||||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
|
||||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect
|
||||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
|
||||||
|
toolchainVendor=JETBRAINS
|
||||||
toolchainVersion=21
|
toolchainVersion=21
|
||||||
@ -46,11 +46,10 @@ kotlinx-serialization-json = "1.11.0"
|
|||||||
materialKolor = "4.1.1"
|
materialKolor = "4.1.1"
|
||||||
mqttClient = "2.1.1"
|
mqttClient = "2.1.1"
|
||||||
mqttBuffer = "6.30.8"
|
mqttBuffer = "6.30.8"
|
||||||
multiplatformMarkdownRenderer = "0.45.0"
|
|
||||||
murmurhash = "0.4.2"
|
murmurhash = "0.4.2"
|
||||||
newpipeextractor = "v0.26.5"
|
newpipeextractor = "v0.26.5"
|
||||||
newpipeExtractorKmp = "1.3.0"
|
newpipeExtractorKmp = "1.3.0"
|
||||||
nucleusNucleusApplication = "2.5.18"
|
nucleusNucleusApplication = "2.4.6"
|
||||||
semver = "2.1.0"
|
semver = "2.1.0"
|
||||||
vlcj = "4.12.1"
|
vlcj = "4.12.1"
|
||||||
vlcjNative = "4.12.0"
|
vlcjNative = "4.12.0"
|
||||||
@ -61,8 +60,8 @@ kotlinStdlib = "2.4.10"
|
|||||||
runner = "1.7.0"
|
runner = "1.7.0"
|
||||||
core = "1.7.0"
|
core = "1.7.0"
|
||||||
filekit = "0.14.2"
|
filekit = "0.14.2"
|
||||||
compose-webview = "1.0.3"
|
compose-webview = "1.0.1"
|
||||||
composeNativeTray = "2.1.6"
|
composeNativeTray = "2.0.3"
|
||||||
koin = "4.2.2"
|
koin = "4.2.2"
|
||||||
multiplatform-nav3-ui = "1.1.1"
|
multiplatform-nav3-ui = "1.1.1"
|
||||||
compose-multiplatform-adaptive = "1.3.0-alpha05"
|
compose-multiplatform-adaptive = "1.3.0-alpha05"
|
||||||
@ -129,8 +128,6 @@ mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClie
|
|||||||
mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" }
|
mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" }
|
||||||
mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" }
|
mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" }
|
||||||
mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" }
|
mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" }
|
||||||
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
|
|
||||||
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
|
|
||||||
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
||||||
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
||||||
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user