├── proguard ├── oshi.pro ├── log4j.pro ├── slf4j.pro ├── kotlinlogging.pro ├── jna.pro ├── compose.pro ├── HackedSelectionContainer.kt.pro └── kotlinx-serialization.pro ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── .idea └── copyright │ ├── profiles_settings.xml │ └── Server_List_Explorer_GPT_3_0.xml ├── ui ├── src │ └── main │ │ ├── composeResources │ │ └── drawable │ │ │ ├── texture_unknown_server.png │ │ │ ├── flag_uk.svg │ │ │ └── flag_us.svg │ │ └── kotlin │ │ └── com │ │ └── spoiligaming │ │ └── explorer │ │ └── ui │ │ ├── extensions │ │ ├── IntExtensions.kt │ │ ├── LongExtensions.kt │ │ ├── InputStreamExtensions.kt │ │ ├── ColorExtensions.kt │ │ ├── StringExtensions.kt │ │ ├── ImageBitmapExtensions.kt │ │ └── ImageBitmapLuminance.kt │ │ ├── navigation │ │ ├── Destinations.kt │ │ └── NavGraph.kt │ │ ├── screens │ │ ├── multiplayer │ │ │ ├── ServerEntryMoveDirection.kt │ │ │ ├── querymethod │ │ │ │ ├── QueryMethodModels.kt │ │ │ │ ├── items │ │ │ │ │ ├── QueryMethodCheckboxItem.kt │ │ │ │ │ └── QueryMethodTimeoutItem.kt │ │ │ │ ├── QueryMethodConfigurationTooltip.kt │ │ │ │ └── QueryMethodChipPresets.kt │ │ │ ├── ShimmerServerEntry.kt │ │ │ └── ServerSelectionController.kt │ │ ├── setup │ │ │ ├── steps │ │ │ │ └── LanguageSelectionStep.kt │ │ │ └── SetupStepContainer.kt │ │ └── settings │ │ │ └── components │ │ │ └── SettingsSection.kt │ │ ├── theme │ │ └── Color.kt │ │ ├── widgets │ │ ├── ItemSelectableDropdownMenu.kt │ │ ├── SlickTextButton.kt │ │ ├── ItemSwitch.kt │ │ └── PocketInfoTooltip.kt │ │ ├── util │ │ ├── FilePickerUtil.kt │ │ └── AdaptiveDimension.kt │ │ ├── snackbar │ │ ├── SnackbarHostManager.kt │ │ └── SnackbarController.kt │ │ ├── Localization.kt │ │ ├── components │ │ ├── FPSCounterOverlay.kt │ │ └── MarkdownAutoLinkText.kt │ │ └── window │ │ ├── WindowState.kt │ │ └── WindowEffects.kt └── build.gradle.kts ├── gradle.properties ├── .editorconfig ├── .run ├── SLE (Production).run.xml ├── SLE (Debug).run.xml └── Generate Compose Resources.run.xml ├── .gitignore ├── nbt ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── com │ └── spoiligaming │ └── explorer │ └── multiplayer │ ├── MultiplayerServer.kt │ └── history │ └── ServerListHistoryService.kt ├── core ├── src │ ├── main │ │ └── kotlin │ │ │ └── com │ │ │ └── spoiligaming │ │ │ └── explorer │ │ │ ├── minecraft │ │ │ ├── multiplayer │ │ │ │ ├── online │ │ │ │ │ └── backend │ │ │ │ │ │ ├── common │ │ │ │ │ │ ├── IServerData.kt │ │ │ │ │ │ ├── OfflineServerData.kt │ │ │ │ │ │ ├── McSrvStatRateLimitedServerData.kt │ │ │ │ │ │ ├── IServerQueryHandler.kt │ │ │ │ │ │ ├── OnlineServerDataResourceResult.kt │ │ │ │ │ │ └── OnlineServerDataModels.kt │ │ │ │ │ │ ├── mcsrvstat │ │ │ │ │ │ ├── OnlineServerDataDeserializer.kt │ │ │ │ │ │ ├── McSrvStatServerQueryHandler.kt │ │ │ │ │ │ ├── RequestHandler.kt │ │ │ │ │ │ └── McSrvStatServerStatusResponse.kt │ │ │ │ │ │ └── mcutils │ │ │ │ │ │ └── retry.kt │ │ │ │ ├── ServerListModule.kt │ │ │ │ └── ServerListPaths.kt │ │ │ ├── common │ │ │ │ ├── IModuleKind.kt │ │ │ │ ├── IModeLoader.kt │ │ │ │ ├── PathResolver.kt │ │ │ │ └── UnifiedModeLoader.kt │ │ │ └── singleplayer │ │ │ │ ├── SingleplayerModule.kt │ │ │ │ └── WorldDirectoryPaths.kt │ │ │ └── util │ │ │ ├── StringExtensions.kt │ │ │ └── DurationFormatting.kt │ └── test │ │ └── kotlin │ │ └── com │ │ └── spoiligaming │ │ └── explorer │ │ └── minecraft │ │ └── multiplayer │ │ └── online │ │ └── backend │ │ └── mcsrvstat │ │ └── McSrvStatServerStatusResponseTest.kt └── build.gradle.kts ├── settings ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── com │ └── spoiligaming │ └── explorer │ └── settings │ ├── manager │ ├── SettingsManager.kt │ └── SettingsManagers.kt │ ├── model │ ├── SingleplayerSettings.kt │ ├── WindowState.kt │ ├── ThemeSettings.kt │ ├── MultiplayerSettings.kt │ ├── Preferences.kt │ ├── WindowAppearence.kt │ └── ServerQueryMethodConfigurations.kt │ ├── serializer │ ├── LocaleSerializer.kt │ └── PathSerializer.kt │ └── util │ └── SettingsFile.kt ├── app └── src │ ├── test │ └── resources │ │ └── log4j2-test.xml │ └── main │ ├── kotlin │ └── com │ │ └── spoiligaming │ │ └── explorer │ │ ├── Main.kt │ │ ├── ArgsParser.kt │ │ └── LogStorage.kt │ └── resources │ ├── log4j2-prod.xml │ └── log4j2-dev.xml ├── settings.gradle.kts ├── .github └── workflows │ └── verify-pr.yml ├── gradlew.bat └── add_gpl_header.bat /proguard/oshi.pro: -------------------------------------------------------------------------------- 1 | -keep class oshi.driver.windows.wmi.** { *; } 2 | -------------------------------------------------------------------------------- /proguard/log4j.pro: -------------------------------------------------------------------------------- 1 | -keep class org.apache.logging.log4j.** { *; } 2 | -------------------------------------------------------------------------------- /proguard/slf4j.pro: -------------------------------------------------------------------------------- 1 | -keep class org.slf4j.** { *; } 2 | -keep class org.apache.logging.slf4j.SLF4JServiceProvider { *; } 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpoilerRules/server-list-explorer/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /ui/src/main/composeResources/drawable/texture_unknown_server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpoilerRules/server-list-explorer/HEAD/ui/src/main/composeResources/drawable/texture_unknown_server.png -------------------------------------------------------------------------------- /proguard/kotlinlogging.pro: -------------------------------------------------------------------------------- 1 | -assumenosideeffects class io.github.oshai.kotlinlogging.logback.** { *; } 2 | -dontnote io.github.oshai.kotlinlogging.logback.** 3 | -dontwarn io.github.oshai.kotlinlogging.logback.** 4 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 2 | org.gradle.parallel=true 3 | org.gradle.configuration-cache=true 4 | org.gradle.caching=true 5 | org.gradle.java.installations.auto-download=true 6 | kotlin.code.style=official 7 | 8 | 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /proguard/jna.pro: -------------------------------------------------------------------------------- 1 | -keep class com.sun.jna.** { 2 | *; 3 | } 4 | -keepclassmembers class com.sun.jna.** { 5 | native ; 6 | } 7 | 8 | -keep class com.sun.jna.platform.** { 9 | *; 10 | } 11 | 12 | -keepclassmembers class * extends com.sun.jna.Structure { 13 | ; 14 | ; 15 | } 16 | 17 | -keep class * extends com.sun.jna.Library { *; } 18 | -keep class * extends com.sun.jna.Callback { *; } 19 | -------------------------------------------------------------------------------- /proguard/compose.pro: -------------------------------------------------------------------------------- 1 | -keep class androidx.compose.ui.input.pointer.** { *; } 2 | -keep class androidx.compose.ui.window.** { *; } 3 | 4 | # Required: Used via reflection in FloatingDialogBuilder.kt 5 | # - Class.forName("androidx.compose.material3.TooltipScopeImpl") and its constructor 6 | # are invoked explicitly, so the class name and members must remain unchanged. 7 | -keep class androidx.compose.material3.TooltipScopeImpl { 8 | (...); 9 | *; 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | 8 | [*.{kt,kts}] 9 | indent_style = space 10 | indent_size = 4 11 | insert_final_newline = true 12 | no-wildcard-imports = true 13 | ktlint_function_naming_ignore_when_annotated_with = Composable 14 | ktlint_standard_property-naming = disabled 15 | ktlint_standard_filename = disabled 16 | max_line_length = 120 17 | 18 | [**/generated/**] 19 | generated_code = true 20 | ij_formatter_enabled = false 21 | ktlint = disabled 22 | -------------------------------------------------------------------------------- /.run/SLE (Production).run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .kotlin/ 2 | workspace/ 3 | 4 | .gradle 5 | /.gradle-user-home/**/* 6 | build/ 7 | !gradle/wrapper/gradle-wrapper.jar 8 | !**/src/main/**/build/ 9 | !**/src/test/**/build/ 10 | 11 | ### IntelliJ IDEA ### 12 | .idea/* 13 | !.idea/ 14 | !.idea/copyright/ 15 | !.idea/copyright/*.xml 16 | *.iws 17 | *.iml 18 | *.ipr 19 | out/ 20 | !**/src/main/**/out/ 21 | !**/src/test/**/out/ 22 | 23 | ### Eclipse ### 24 | .apt_generated 25 | .classpath 26 | .factorypath 27 | .project 28 | .settings 29 | .springBeans 30 | .sts4-cache 31 | bin/ 32 | !**/src/main/**/bin/ 33 | !**/src/test/**/bin/ 34 | 35 | ### NetBeans ### 36 | /nbproject/private/ 37 | /nbbuild/ 38 | /dist/ 39 | /nbdist/ 40 | /.nb-gradle/ 41 | 42 | ### VS Code ### 43 | .vscode/ 44 | 45 | ### Mac OS ### 46 | .DS_Store 47 | -------------------------------------------------------------------------------- /proguard/HackedSelectionContainer.kt.pro: -------------------------------------------------------------------------------- 1 | -keepattributes *Annotation*,InnerClasses,EnclosingMethod,Signature,Exceptions 2 | 3 | -keep class androidx.compose.foundation.text.selection.** { *; } 4 | -keep class androidx.compose.ui.text.** { *; } 5 | -keep class androidx.compose.foundation.text.** { *; } 6 | -keep class androidx.compose.foundation.ContextMenu* { *; } 7 | 8 | -keepclassmembers class **$Companion { *; } 9 | 10 | -keep class androidx.compose.foundation.text.selection.SelectionRegistrarKt { *; } 11 | -keep class androidx.compose.foundation.text.selection.SelectionKt { *; } 12 | -keep class androidx.compose.foundation.text.selection.SelectionJvmKt { *; } 13 | -keep class androidx.compose.foundation.text.selection.Selection_desktopKt { *; } 14 | -keep class androidx.compose.foundation.text.selection.Selection { *; } -------------------------------------------------------------------------------- /.run/SLE (Debug).run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/copyright/Server_List_Explorer_GPT_3_0.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /nbt/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | dependencies { 20 | implementation(libs.kotlinx.coroutines.core.jvm) 21 | implementation(libs.nbt) 22 | compileOnly(libs.compose.runtime) 23 | } 24 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/common/IServerData.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.common 20 | 21 | interface IServerData 22 | -------------------------------------------------------------------------------- /settings/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | plugins { 20 | alias(libs.plugins.kotlin.serialization) 21 | } 22 | 23 | dependencies { 24 | implementation(libs.kotlinx.coroutines.core.jvm) 25 | implementation(libs.kotlinx.serialization.json) 26 | } 27 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/common/OfflineServerData.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.common 20 | 21 | data object OfflineServerData : IServerData 22 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/extensions/IntExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.extensions 20 | 21 | import java.util.Locale 22 | 23 | internal fun Int.toGroupedString(locale: Locale) = String.format(locale, "%,d", this) 24 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/common/McSrvStatRateLimitedServerData.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.common 20 | 21 | data object McSrvStatRateLimitedServerData : IServerData 22 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/common/IModuleKind.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.common 20 | 21 | sealed interface IModuleKind { 22 | data object Multiplayer : IModuleKind 23 | 24 | data object Singleplayer : IModuleKind 25 | } 26 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/util/StringExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.util 20 | 21 | val MinecraftColorCodeRegex = Regex("""§(?:[0-9a-fk-or]|#[0-9A-Fa-f]{6})""") 22 | 23 | fun String.stripMinecraftColorCodes() = replace(MinecraftColorCodeRegex, "") 24 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/common/IServerQueryHandler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.common 20 | 21 | internal interface IServerQueryHandler { 22 | suspend fun getServerData(): IServerData 23 | } 24 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/extensions/LongExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.extensions 20 | 21 | internal fun Long.formatMillis() = 22 | when { 23 | this < 1000 -> "${this}ms" 24 | else -> String.format("%.1fs", this / 1000.0) 25 | } 26 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/common/IModeLoader.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.common 20 | 21 | import java.nio.file.Path 22 | 23 | internal interface IModeLoader { 24 | suspend fun autoResolvePath(): Path? 25 | 26 | suspend fun initialize(target: Path?): Result 27 | } 28 | -------------------------------------------------------------------------------- /.run/Generate Compose Resources.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 17 | 19 | true 20 | true 21 | false 22 | false 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/test/resources/log4j2-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/navigation/Destinations.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.navigation 20 | 21 | import kotlinx.serialization.Serializable 22 | 23 | @Serializable 24 | internal object MultiplayerServerListScreen 25 | 26 | @Serializable 27 | internal object SingleplayerWorldListScreen 28 | 29 | @Serializable 30 | internal object SettingsScreen 31 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/manager/SettingsManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.manager 20 | 21 | internal interface SettingsManager { 22 | suspend fun loadSettings(): T 23 | 24 | fun saveSettings( 25 | settings: T, 26 | onComplete: (() -> Unit)? = null, 27 | ) 28 | 29 | fun getCachedSettings(): T 30 | } 31 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/extensions/InputStreamExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.extensions 20 | 21 | import org.jetbrains.compose.resources.decodeToImageBitmap 22 | import java.io.InputStream 23 | 24 | internal fun InputStream?.asImageBitmap() = this?.readAllBytes()?.decodeToImageBitmap() 25 | 26 | internal fun InputStream.safeAsImageBitmapOrNull() = runCatching { this.use { it.asImageBitmap() } }.getOrNull() 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/spoiligaming/explorer/Main.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer 20 | 21 | import com.spoiligaming.explorer.ui.launchInterface 22 | 23 | fun main(args: Array) { 24 | val env = if (System.getProperty("env") == "dev") "dev" else "prod" 25 | System.setProperty("log4j2.configurationFile", "log4j2-$env.xml") 26 | System.setProperty("app.logs.dir", LogStorage.logsDir.absolutePath) 27 | 28 | ArgsParser.parse(args) 29 | 30 | launchInterface() 31 | } 32 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | rootProject.name = "server-list-explorer" 20 | 21 | pluginManagement { 22 | repositories { 23 | mavenCentral() 24 | google() 25 | gradlePluginPortal() 26 | } 27 | } 28 | 29 | dependencyResolutionManagement { 30 | repositories { 31 | google() 32 | mavenCentral() 33 | maven("https://jitpack.io/") 34 | } 35 | } 36 | 37 | include(":app") 38 | include(":core") 39 | include(":settings") 40 | include(":nbt") 41 | include(":ui") 42 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/model/SingleplayerSettings.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.model 20 | 21 | import com.spoiligaming.explorer.settings.serializer.PathSerializer 22 | import kotlinx.serialization.SerialName 23 | import kotlinx.serialization.Serializable 24 | import java.nio.file.Path 25 | 26 | @Serializable 27 | data class SingleplayerSettings( 28 | @SerialName("saves_directory") 29 | @Serializable(with = PathSerializer::class) 30 | val savesDirectory: Path? = null, 31 | ) 32 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/multiplayer/ServerEntryMoveDirection.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | @file:OptIn(ExperimentalUuidApi::class) 20 | 21 | package com.spoiligaming.explorer.ui.screens.multiplayer 22 | 23 | import kotlin.uuid.ExperimentalUuidApi 24 | import kotlin.uuid.Uuid 25 | 26 | internal enum class ServerEntryMoveDirection { 27 | Up, 28 | Down, 29 | Left, 30 | Right, 31 | } 32 | 33 | internal data class BlockedMoveFeedback( 34 | val direction: ServerEntryMoveDirection, 35 | val targetIds: Set, 36 | val token: Long, 37 | ) 38 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/model/WindowState.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.model 20 | 21 | import kotlinx.serialization.SerialName 22 | import kotlinx.serialization.Serializable 23 | 24 | @Serializable 25 | data class WindowState( 26 | @SerialName("is_maximized") 27 | val isWindowMaximized: Boolean = false, 28 | @SerialName("width") 29 | val width: Int = 1024, 30 | @SerialName("height") 31 | val height: Int = 768, 32 | @SerialName("current_width") 33 | val currentWidth: Int = 1024, 34 | @SerialName("current_height") 35 | val currentHeight: Int = 768, 36 | ) 37 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/singleplayer/SingleplayerModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.singleplayer 20 | 21 | import com.spoiligaming.explorer.minecraft.common.IModeLoader 22 | import kotlinx.coroutines.Dispatchers 23 | import kotlinx.coroutines.withContext 24 | import java.nio.file.Path 25 | 26 | // TODO 27 | object SingleplayerModule : IModeLoader { 28 | override suspend fun autoResolvePath(): Path? = 29 | withContext(Dispatchers.Default) { 30 | WorldDirectoryPaths.findDefault() 31 | } 32 | 33 | override suspend fun initialize(target: Path?) = TODO() 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/spoiligaming/explorer/ArgsParser.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer 20 | 21 | import io.github.oshai.kotlinlogging.KotlinLogging 22 | import org.apache.logging.log4j.Level 23 | import org.apache.logging.log4j.core.config.Configurator 24 | 25 | internal object ArgsParser { 26 | fun parse(args: Array) { 27 | if ("--verbose" in args) { 28 | enableVerboseLogging() 29 | } 30 | } 31 | 32 | private fun enableVerboseLogging() { 33 | Configurator.setRootLevel(Level.ALL) 34 | logger.info { "Verbose logging enabled." } 35 | } 36 | } 37 | 38 | private val logger = KotlinLogging.logger {} 39 | -------------------------------------------------------------------------------- /core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | plugins { 20 | alias(libs.plugins.kotlin.serialization) 21 | } 22 | 23 | dependencies { 24 | implementation(project(":settings")) 25 | implementation(project(":nbt")) 26 | 27 | implementation(libs.kotlinx.coroutines.core.jvm) 28 | implementation(libs.kotlinx.serialization.json) 29 | 30 | implementation(libs.mcUtils) 31 | 32 | implementation(libs.ktor.client.core.jvm) 33 | implementation(libs.ktor.client.cio.jvm) 34 | testImplementation(libs.ktor.client.core.jvm) 35 | testImplementation(libs.ktor.client.cio.jvm) 36 | testImplementation(libs.ktor.client.mock.jvm) 37 | 38 | implementation(libs.oshi.core) 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/resources/log4j2-prod.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/common/OnlineServerDataResourceResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.common 20 | 21 | sealed class OnlineServerDataResourceResult { 22 | object Loading : OnlineServerDataResourceResult() 23 | 24 | data class Success( 25 | val data: T, 26 | ) : OnlineServerDataResourceResult() 27 | 28 | data class Error( 29 | val throwable: Throwable, 30 | ) : OnlineServerDataResourceResult() 31 | 32 | data class RateLimited( 33 | val data: McSrvStatRateLimitedServerData, 34 | ) : OnlineServerDataResourceResult() 35 | } 36 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.theme 20 | 21 | import androidx.compose.material3.ColorScheme 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.ui.graphics.Color 24 | 25 | internal val SecureChatLight = Color(0xFF1565C0) // blue 800 26 | internal val SecureChatDark = Color(0xFF64B5F6) // blue 300 27 | 28 | internal val LinkLight = Color(0xFF2F6FEB) 29 | internal val LinkDark = Color(0xFF6EA8FE) 30 | 31 | @Suppress("UnusedReceiverParameter") 32 | internal val ColorScheme.secureChatTint 33 | @Composable 34 | get() = if (LocalDarkTheme.current) SecureChatDark else SecureChatLight 35 | 36 | @Suppress("UnusedReceiverParameter") 37 | internal val ColorScheme.linkTint 38 | @Composable 39 | get() = if (LocalDarkTheme.current) LinkDark else LinkLight 40 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/extensions/ColorExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.extensions 20 | 21 | import androidx.compose.ui.graphics.toArgb 22 | import androidx.compose.ui.graphics.Color as ComposeColor 23 | import java.awt.Color as AwtColor 24 | 25 | internal fun ComposeColor.toAwtColor(): AwtColor { 26 | val argb = toArgb() 27 | return AwtColor( 28 | (argb shr 16) and 0xFF, 29 | (argb shr 8) and 0xFF, 30 | argb and 0xFF, 31 | (argb shr 24) and 0xFF, 32 | ) 33 | } 34 | 35 | internal fun AwtColor.toComposeColor() = 36 | ComposeColor( 37 | red = red / 255f, 38 | green = green / 255f, 39 | blue = blue / 255f, 40 | alpha = alpha / 255f, 41 | ) 42 | 43 | internal fun ComposeColor.toHex() = 44 | String.format("%06X", toArgb() and 0x00FFFFFF) + String.format("%02X", (alpha * 255).toInt()) 45 | -------------------------------------------------------------------------------- /ui/src/main/composeResources/drawable/flag_uk.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 21 | 22 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/serializer/LocaleSerializer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.serializer 20 | 21 | import kotlinx.serialization.KSerializer 22 | import kotlinx.serialization.descriptors.PrimitiveKind 23 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 24 | import kotlinx.serialization.descriptors.SerialDescriptor 25 | import kotlinx.serialization.encoding.Decoder 26 | import kotlinx.serialization.encoding.Encoder 27 | import java.util.Locale 28 | 29 | object LocaleSerializer : KSerializer { 30 | override val descriptor: SerialDescriptor = 31 | PrimitiveSerialDescriptor("Locale", PrimitiveKind.STRING) 32 | 33 | override fun serialize( 34 | encoder: Encoder, 35 | value: Locale, 36 | ) = encoder.encodeString(value.toLanguageTag()) 37 | 38 | override fun deserialize(decoder: Decoder): Locale = Locale.forLanguageTag(decoder.decodeString()) 39 | } 40 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/serializer/PathSerializer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.serializer 20 | 21 | import kotlinx.serialization.KSerializer 22 | import kotlinx.serialization.descriptors.PrimitiveKind 23 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 24 | import kotlinx.serialization.descriptors.SerialDescriptor 25 | import kotlinx.serialization.encoding.Decoder 26 | import kotlinx.serialization.encoding.Encoder 27 | import java.nio.file.Path 28 | import java.nio.file.Paths 29 | 30 | object PathSerializer : KSerializer { 31 | override val descriptor: SerialDescriptor = 32 | PrimitiveSerialDescriptor("Path", PrimitiveKind.STRING) 33 | 34 | override fun serialize( 35 | encoder: Encoder, 36 | value: Path, 37 | ) { 38 | encoder.encodeString(value.toString()) 39 | } 40 | 41 | override fun deserialize(decoder: Decoder): Path = Paths.get(decoder.decodeString()) 42 | } 43 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/widgets/ItemSelectableDropdownMenu.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.widgets 20 | 21 | import androidx.compose.foundation.layout.fillMaxWidth 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.ui.Modifier 24 | 25 | @Composable 26 | internal fun ItemSelectableDropdownMenu( 27 | title: String, 28 | description: String, 29 | note: String? = null, 30 | conflictReason: String? = null, 31 | selectedOption: DropdownOption, 32 | options: List, 33 | onOptionSelected: (DropdownOption) -> Unit, 34 | ) = SettingTile( 35 | title = title, 36 | description = description, 37 | note = note, 38 | conflictReason = conflictReason, 39 | trailingContent = { 40 | SelectableDropdown( 41 | selectedOption, 42 | options, 43 | onOptionSelected, 44 | modifier = Modifier.fillMaxWidth(DROPDOWN_WIDTH_RATIO), 45 | ) 46 | }, 47 | ) 48 | 49 | private const val DROPDOWN_WIDTH_RATIO = 0.25f 50 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/common/PathResolver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.common 20 | 21 | import java.nio.file.Files 22 | import java.nio.file.Path 23 | 24 | internal interface PathResolver { 25 | fun find(path: String): Path? 26 | 27 | fun findDefault(): Path? 28 | 29 | fun validate(path: Path?) 30 | } 31 | 32 | internal fun PathResolver.requireExistingDirectory(path: Path?) { 33 | requireNotNull(path) { "Path must not be null." } 34 | require(Files.exists(path)) { "Path does not exist: $path" } 35 | require(Files.isDirectory(path)) { "Path must be a directory: $path" } 36 | require(Files.isReadable(path)) { "Path is not readable: $path" } 37 | } 38 | 39 | internal fun PathResolver.requireExistingFile(path: Path?) { 40 | requireNotNull(path) { "File path must not be null." } 41 | require(Files.exists(path)) { "File path does not exist: $path" } 42 | require(Files.isRegularFile(path)) { "Path must be a file: $path" } 43 | require(Files.isReadable(path)) { "File is not readable: $path" } 44 | } 45 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/util/FilePickerUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.util 20 | 21 | import androidx.compose.runtime.Composable 22 | import io.github.vinceglb.filekit.dialogs.FileKitMode 23 | import io.github.vinceglb.filekit.dialogs.FileKitType 24 | import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher 25 | import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher 26 | import java.nio.file.Path 27 | 28 | @Composable 29 | internal fun rememberWorldSavesPickerLauncher( 30 | title: String, 31 | onDirectory: (Path) -> Unit, 32 | ) = rememberDirectoryPickerLauncher( 33 | title = title, 34 | ) { result -> 35 | result?.file?.toPath()?.let(onDirectory) 36 | } 37 | 38 | @Composable 39 | internal fun rememberServerListFilePickerLauncher( 40 | title: String, 41 | onFile: (Path) -> Unit, 42 | ) = rememberFilePickerLauncher( 43 | type = FileKitType.File(extensions = listOf("dat")), 44 | mode = FileKitMode.Single, 45 | title = title, 46 | ) { result -> 47 | result?.file?.toPath()?.let(onFile) 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/verify-pr.yml: -------------------------------------------------------------------------------- 1 | name: Build and Verify 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | types: [ opened, synchronize, reopened, ready_for_review ] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | verify-pr: 15 | name: Verify with Gradle build 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Install Node 24 20 | if: env.ACT == 'true' 21 | run: | 22 | curl -fsSL https://deb.nodesource.com/setup_24.x | bash - 23 | apt-get install -y nodejs 24 | node -v 25 | 26 | - name: Checkout 27 | uses: actions/checkout@v6 28 | 29 | - name: Set up JDK 25 30 | uses: actions/setup-java@v5 31 | with: 32 | distribution: graalvm 33 | java-version: 25 34 | 35 | - name: Setup Gradle 36 | uses: gradle/actions/setup-gradle@v5 37 | with: 38 | gradle-version: wrapper 39 | cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} 40 | cache-read-only: false 41 | 42 | - name: Normalize gradlew line endings 43 | if: env.ACT == 'true' 44 | run: | 45 | # remove CR at end-of-line 46 | sed -i 's/\r$//' ./gradlew 47 | shell: bash 48 | 49 | - name: Make gradlew executable 50 | run: chmod +x gradlew 51 | 52 | - name: Generate license report 53 | run: ./gradlew generateLicenseReport --no-parallel --no-configuration-cache 54 | 55 | - name: Gradle build 56 | run: ./gradlew app:buildWithProjectModules 57 | 58 | - name: Upload reports 59 | if: ${{ always() && !env.ACT }} 60 | uses: actions/upload-artifact@v6 61 | with: 62 | name: pr-verification-reports 63 | path: | 64 | **/build/reports/ktlint/** 65 | **/build/reports/**/ktlint*.html 66 | **/build/reports/tests/** 67 | **/build/test-results/** 68 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/multiplayer/querymethod/QueryMethodModels.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.screens.multiplayer.querymethod 20 | 21 | import com.spoiligaming.explorer.settings.model.ServerQueryMethod 22 | 23 | internal data class QueryMethodDefinition( 24 | val method: ServerQueryMethod, 25 | val title: String, 26 | val chips: List, 27 | val configuration: QueryMethodConfiguration? = null, 28 | ) 29 | 30 | internal data class QueryMethodConfiguration( 31 | val title: String, 32 | val items: List, 33 | ) 34 | 35 | internal sealed interface QueryMethodSettingItem 36 | 37 | internal data class TimeoutSliderSpec( 38 | val title: String, 39 | val description: String?, 40 | val valueSeconds: Int, 41 | val valueRangeSeconds: ClosedFloatingPointRange, 42 | val onValueChangeSeconds: (Int) -> Unit, 43 | ) : QueryMethodSettingItem 44 | 45 | internal data class QueryMethodCheckboxSpec( 46 | val title: String, 47 | val description: String?, 48 | val checked: Boolean, 49 | val onCheckedChange: (Boolean) -> Unit, 50 | ) : QueryMethodSettingItem 51 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/common/UnifiedModeLoader.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.common 20 | 21 | import com.spoiligaming.explorer.minecraft.multiplayer.ServerListModule 22 | import com.spoiligaming.explorer.minecraft.singleplayer.SingleplayerModule 23 | import kotlinx.coroutines.Dispatchers 24 | import kotlinx.coroutines.withContext 25 | import java.nio.file.Path 26 | 27 | object UnifiedModeInitializer { 28 | suspend fun autoDetect(mode: IModuleKind) = 29 | withContext(Dispatchers.Default) { 30 | when (mode) { 31 | IModuleKind.Multiplayer -> ServerListModule.autoResolvePath() 32 | IModuleKind.Singleplayer -> SingleplayerModule.autoResolvePath() 33 | } 34 | } 35 | 36 | @Suppress("UNCHECKED_CAST") 37 | suspend fun initialize( 38 | mode: IModuleKind, 39 | target: Path?, 40 | ) = withContext(Dispatchers.Default) { 41 | when (mode) { 42 | IModuleKind.Multiplayer -> ServerListModule.initialize(target) as Result 43 | IModuleKind.Singleplayer -> SingleplayerModule.initialize(target) as Result 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/resources/log4j2-dev.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/widgets/SlickTextButton.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.widgets 20 | 21 | import androidx.compose.material3.ButtonDefaults 22 | import androidx.compose.material3.ListItemDefaults 23 | import androidx.compose.material3.MaterialTheme 24 | import androidx.compose.material3.TextButton 25 | import androidx.compose.runtime.Composable 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.input.pointer.PointerIcon 29 | import androidx.compose.ui.input.pointer.pointerHoverIcon 30 | 31 | @Composable 32 | internal fun SlickTextButton( 33 | contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, 34 | onClick: () -> Unit, 35 | modifier: Modifier, 36 | enabled: Boolean = true, 37 | content: @Composable () -> Unit, 38 | ) = TextButton( 39 | onClick = onClick, 40 | modifier = modifier.pointerHoverIcon(PointerIcon.Hand), 41 | enabled = enabled, 42 | shape = MaterialTheme.shapes.small, 43 | colors = 44 | ButtonDefaults.textButtonColors( 45 | contentColor = contentColor, 46 | disabledContentColor = ListItemDefaults.colors().supportingTextColor, 47 | ), 48 | ) { 49 | content() 50 | } 51 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/common/OnlineServerDataModels.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.common 20 | 21 | sealed interface OnlineServerData : IServerData { 22 | val motd: String 23 | val onlinePlayers: Int 24 | val maxPlayers: Int 25 | val icon: String 26 | val versionInfo: String 27 | val protocolVersion: Int 28 | val ip: String 29 | val info: String? 30 | } 31 | 32 | data class McSrvStatOnlineServerData( 33 | override val motd: String, 34 | override val onlinePlayers: Int, 35 | override val maxPlayers: Int, 36 | override val icon: String, 37 | override val versionInfo: String, 38 | override val protocolVersion: Int, 39 | override val ip: String, 40 | override val info: String?, 41 | val versionName: String?, 42 | val eulaBlocked: Boolean, 43 | ) : OnlineServerData 44 | 45 | data class McUtilsOnlineServerData( 46 | override val motd: String, 47 | override val onlinePlayers: Int, 48 | override val maxPlayers: Int, 49 | override val icon: String, 50 | override val versionInfo: String, 51 | override val protocolVersion: Int, 52 | override val ip: String, 53 | override val info: String?, 54 | val ping: Long, 55 | val secureChatEnforced: Boolean, 56 | ) : OnlineServerData 57 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/widgets/ItemSwitch.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.widgets 20 | 21 | import androidx.compose.material3.Switch 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.input.pointer.PointerIcon 25 | import androidx.compose.ui.input.pointer.pointerHoverIcon 26 | 27 | @Composable 28 | internal fun ItemSwitch( 29 | isChecked: Boolean, 30 | onCheckedChange: (Boolean) -> Unit, 31 | enabled: Boolean, 32 | ) = Switch( 33 | modifier = 34 | Modifier.pointerHoverIcon( 35 | icon = if (enabled) PointerIcon.Hand else PointerIcon.Default, 36 | ), 37 | checked = isChecked, 38 | onCheckedChange = onCheckedChange, 39 | enabled = enabled, 40 | ) 41 | 42 | @Composable 43 | internal fun ItemSwitch( 44 | title: String, 45 | description: String, 46 | note: String? = null, 47 | conflictReason: String? = null, 48 | isChecked: Boolean, 49 | onCheckedChange: (Boolean) -> Unit, 50 | ) = SettingTile( 51 | title = title, 52 | description = description, 53 | note = note, 54 | conflictReason = conflictReason, 55 | trailingContent = { 56 | ItemSwitch( 57 | isChecked = isChecked, 58 | onCheckedChange = onCheckedChange, 59 | enabled = conflictReason == null, 60 | ) 61 | }, 62 | ) 63 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/singleplayer/WorldDirectoryPaths.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.singleplayer 20 | 21 | import com.spoiligaming.explorer.minecraft.common.PathResolver 22 | import com.spoiligaming.explorer.minecraft.common.requireExistingDirectory 23 | import com.spoiligaming.explorer.util.OSUtils 24 | import java.nio.file.Files 25 | import java.nio.file.Path 26 | import java.nio.file.Paths 27 | 28 | object WorldDirectoryPaths : PathResolver { 29 | override fun find(path: String) = Paths.get(path).takeIf { Files.exists(it) } 30 | 31 | override fun findDefault(): Path? { 32 | val home = System.getProperty("user.home") ?: return null 33 | val p = 34 | when { 35 | OSUtils.isWindows -> Paths.get(home, "AppData", "Roaming", ".minecraft", "saves") 36 | OSUtils.isMacOS -> 37 | Paths.get( 38 | home, 39 | "Library", 40 | "Application Support", 41 | "minecraft", 42 | "saves", 43 | ) 44 | 45 | OSUtils.isLinux -> Paths.get(home, ".minecraft", "saves") 46 | else -> return null 47 | } 48 | return p.takeIf(Files::exists) 49 | } 50 | 51 | override fun validate(path: Path?) = requireExistingDirectory(path) 52 | } 53 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/ServerListModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer 20 | 21 | import com.spoiligaming.explorer.minecraft.common.IModeLoader 22 | import com.spoiligaming.explorer.multiplayer.repository.ServerListRepository 23 | import io.github.oshai.kotlinlogging.KotlinLogging 24 | import kotlinx.coroutines.Dispatchers 25 | import kotlinx.coroutines.withContext 26 | import java.nio.file.Path 27 | 28 | object ServerListModule : IModeLoader { 29 | var repository: ServerListRepository? = null 30 | private set 31 | 32 | override suspend fun autoResolvePath() = 33 | withContext(Dispatchers.Default) { 34 | ServerListPaths.findDefault() 35 | } 36 | 37 | override suspend fun initialize(target: Path?) = 38 | withContext(Dispatchers.Default) { 39 | runCatching { 40 | ServerListPaths.validate(target) 41 | val p = requireNotNull(target) { "target must not be null after validation" } 42 | 43 | ServerListRepository(p).apply { 44 | load() 45 | repository = this 46 | } 47 | }.onFailure { e -> 48 | logger.error(e) { "Validation failed for server list path: $target" } 49 | } 50 | } 51 | } 52 | 53 | private val logger = KotlinLogging.logger {} 54 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/model/ThemeSettings.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.model 20 | 21 | import kotlinx.serialization.SerialName 22 | import kotlinx.serialization.Serializable 23 | 24 | @Serializable 25 | enum class ThemeMode { 26 | @SerialName("light") 27 | Light, 28 | 29 | @SerialName("dark") 30 | Dark, 31 | 32 | @SerialName("system") 33 | System, 34 | } 35 | 36 | @Serializable 37 | enum class ThemePaletteStyle { 38 | TonalSpot, 39 | Neutral, 40 | Vibrant, 41 | Expressive, 42 | Rainbow, 43 | FruitSalad, 44 | Monochrome, 45 | Fidelity, 46 | Content, 47 | } 48 | 49 | @Serializable 50 | data class ThemeSettings( 51 | @SerialName("theme_mode") 52 | val themeMode: ThemeMode = ThemeMode.System, 53 | @SerialName("seed_color") 54 | val seedColor: String = DEFAULT_SEED_COLOR, 55 | @SerialName("palette_style") 56 | val paletteStyle: ThemePaletteStyle = ThemePaletteStyle.TonalSpot, 57 | @SerialName("contrast_level") 58 | val contrastLevel: Double = DEFAULT_CONTRAST_LEVEL, 59 | @SerialName("use_system_accent_color") 60 | val useSystemAccentColor: Boolean = false, 61 | @SerialName("amoled_mode") 62 | val amoledMode: Boolean = false, 63 | ) { 64 | companion object { 65 | const val DEFAULT_SEED_COLOR = "#8D15FFED" 66 | const val DEFAULT_CONTRAST_LEVEL = 0.0 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/extensions/StringExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.extensions 20 | 21 | import androidx.compose.ui.graphics.Color 22 | 23 | internal fun String.toComposeColor(): Color { 24 | require(startsWith("#")) { "Color must start with '#'" } 25 | require(length == 7 || length == 9) { 26 | "Invalid color length: must be 7 (#RRGGBB) or 9 (#AARRGGBB) characters" 27 | } 28 | 29 | val hex = substring(1) 30 | val (alphaHex, colorHex) = 31 | when (hex.length) { 32 | 6 -> null to hex 33 | 8 -> hex.take(2) to hex.drop(2) 34 | else -> throw IllegalArgumentException("Invalid hex length: ${hex.length} after '#'") 35 | } 36 | 37 | fun parseHexComponent( 38 | hex: String, 39 | component: String, 40 | ): Int { 41 | require(hex.length == 2) { "Invalid $component hex length" } 42 | return hex.toIntOrNull(16)?.takeIf { it in 0..255 } 43 | ?: throw IllegalArgumentException("Invalid $component hex value: '$hex'") 44 | } 45 | 46 | val alpha = alphaHex?.let { parseHexComponent(it, "alpha") } ?: 255 47 | val red = parseHexComponent(colorHex.substring(0, 2), "red") 48 | val green = parseHexComponent(colorHex.substring(2, 4), "green") 49 | val blue = parseHexComponent(colorHex.substring(4, 6), "blue") 50 | 51 | return Color(alpha, red, green, blue) 52 | } 53 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/mcsrvstat/OnlineServerDataDeserializer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.mcsrvstat 20 | 21 | import com.spoiligaming.explorer.minecraft.multiplayer.online.backend.common.McSrvStatOnlineServerData 22 | import kotlinx.serialization.json.Json 23 | 24 | internal object OnlineServerDataSerializer { 25 | private val json by lazy { 26 | Json { 27 | ignoreUnknownKeys = true 28 | } 29 | } 30 | 31 | fun decode(rawJson: String) = json.decodeFromString(McSrvStatServerStatusResponse.serializer(), rawJson) 32 | 33 | fun reduce(response: McSrvStatServerStatusResponse) = 34 | McSrvStatOnlineServerData( 35 | motd = response.motd.raw.joinToString(separator = "\n"), 36 | onlinePlayers = response.players.online.toInt(), 37 | maxPlayers = response.players.max.toInt(), 38 | versionInfo = response.versionInfo, 39 | icon = response.icon, 40 | versionName = response.protocol.name, 41 | protocolVersion = response.protocol.version.toInt(), 42 | ip = response.ip, 43 | info = response.info?.raw?.joinToString(separator = "\n"), 44 | eulaBlocked = response.eulaBlocked, 45 | ) 46 | 47 | // just in case 48 | fun fromJson(rawJson: String) = reduce(decode(rawJson)) 49 | } 50 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/util/DurationFormatting.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.util 20 | 21 | import kotlin.time.Duration.Companion.days 22 | import kotlin.time.Duration.Companion.hours 23 | import kotlin.time.Duration.Companion.milliseconds 24 | import kotlin.time.Duration.Companion.minutes 25 | import kotlin.time.Duration.Companion.seconds 26 | import kotlin.time.DurationUnit 27 | 28 | fun Long.toHumanReadableDuration(): String { 29 | val d = this.milliseconds 30 | 31 | return when { 32 | d < 1.seconds -> 33 | "${d.inWholeMilliseconds} ms" 34 | 35 | d < 1.minutes -> { 36 | val sec = d.toDouble(DurationUnit.SECONDS) 37 | String.format("%.1f seconds", sec) 38 | } 39 | 40 | d < 1.hours -> { 41 | val m = d.inWholeMinutes 42 | "$m ${plural(m, "minute")}" 43 | } 44 | 45 | d < 1.days -> { 46 | val h = d.inWholeHours 47 | "$h ${plural(h, "hour")}" 48 | } 49 | 50 | d < 365.days -> { 51 | val dayCount = d.inWholeDays 52 | "$dayCount ${plural(dayCount, "day")}" 53 | } 54 | 55 | else -> { 56 | val years = d.inWholeDays / 365 57 | "$years ${plural(years, "year")}" 58 | } 59 | } 60 | } 61 | 62 | private fun plural( 63 | value: Long, 64 | unit: String, 65 | ) = if (value == 1L) unit else "${unit}s" 66 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/model/MultiplayerSettings.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.model 20 | 21 | import com.spoiligaming.explorer.settings.serializer.PathSerializer 22 | import kotlinx.serialization.SerialName 23 | import kotlinx.serialization.Serializable 24 | import java.nio.file.Path 25 | 26 | @Serializable 27 | enum class ActionBarOrientation { 28 | @SerialName("right") 29 | Right, 30 | 31 | @SerialName("top") 32 | Top, 33 | 34 | @SerialName("left") 35 | Left, 36 | 37 | @SerialName("bottom") 38 | Bottom, 39 | } 40 | 41 | @Serializable 42 | enum class ServerQueryMethod { 43 | @SerialName("mcsrvstat") 44 | McSrvStat, 45 | 46 | @SerialName("mcutils") 47 | McUtils, 48 | } 49 | 50 | @Serializable 51 | data class MultiplayerSettings( 52 | @SerialName("server_list_file") 53 | @Serializable(with = PathSerializer::class) 54 | val serverListFile: Path? = null, 55 | @SerialName("server_query_method") 56 | val serverQueryMethod: ServerQueryMethod = ServerQueryMethod.McUtils, 57 | @SerialName("entry_size_scale") 58 | val serverEntryScale: Float = 1.0f, 59 | @SerialName("drag_shake_intensity_deg") 60 | val dragShakeIntensityDegrees: Int = 1, 61 | @SerialName("action_bar_orientation") 62 | val actionBarOrientation: ActionBarOrientation = ActionBarOrientation.Right, 63 | // TODO: auto refresh icons on load/reload 64 | ) 65 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/extensions/ImageBitmapExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.extensions 20 | 21 | import androidx.compose.ui.graphics.ImageBitmap 22 | import androidx.compose.ui.graphics.toAwtImage 23 | import io.github.oshai.kotlinlogging.KotlinLogging 24 | import java.io.ByteArrayInputStream 25 | import java.io.ByteArrayOutputStream 26 | import javax.imageio.ImageIO 27 | import kotlin.io.encoding.Base64 28 | 29 | private fun ImageBitmap.toPngBytes(): ByteArray { 30 | require(width > 0 && height > 0) { 31 | "ImageBitmap must have positive dimensions, but was ${width}x$height" 32 | } 33 | return runCatching { 34 | ByteArrayOutputStream().use { baos -> 35 | val success = ImageIO.write(toAwtImage(), "png", baos) 36 | check(success) { "ImageIO.write returned false while encoding PNG" } 37 | baos.toByteArray() 38 | } 39 | }.getOrElse { e -> 40 | logger.error(e) { "Failed to encode ImageBitmap to PNG" } 41 | throw e 42 | } 43 | } 44 | 45 | fun ImageBitmap.toPngInputStream() = 46 | runCatching { 47 | ByteArrayInputStream(toPngBytes()) 48 | }.getOrElse { e -> 49 | logger.error(e) { "Failed to create InputStream from PNG bytes" } 50 | throw e 51 | } 52 | 53 | fun ImageBitmap.toPngBase64() = 54 | runCatching { 55 | Base64.encode(toPngBytes()) 56 | }.getOrElse { e -> 57 | logger.error(e) { "Failed to Base64-encode PNG bytes" } 58 | throw e 59 | } 60 | 61 | private val logger = KotlinLogging.logger {} 62 | -------------------------------------------------------------------------------- /proguard/kotlinx-serialization.pro: -------------------------------------------------------------------------------- 1 | # --- Source: https://github.com/Kotlin/kotlinx.serialization/blob/master/rules/common.pro 2 | 3 | # Keep `Companion` object fields of serializable classes. 4 | # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. 5 | -if @kotlinx.serialization.Serializable class ** 6 | -keepclassmembers class <1> { 7 | static <1>$* Companion; 8 | } 9 | 10 | # Keep names for named companion object from obfuscation 11 | # Names of a class and of a field are important in lookup of named companion in runtime 12 | -keepnames @kotlinx.serialization.internal.NamedCompanion class * 13 | -if @kotlinx.serialization.internal.NamedCompanion class * 14 | -keepclassmembernames class * { 15 | static <1> *; 16 | } 17 | 18 | # Keep `serializer()` on companion objects (both default and named) of serializable classes. 19 | -if @kotlinx.serialization.Serializable class ** { 20 | static **$* *; 21 | } 22 | -keepclassmembers class <2>$<3> { 23 | kotlinx.serialization.KSerializer serializer(...); 24 | } 25 | 26 | # Keep `INSTANCE.serializer()` of serializable objects. 27 | -if @kotlinx.serialization.Serializable class ** { 28 | public static ** INSTANCE; 29 | } 30 | -keepclassmembers class <1> { 31 | public static <1> INSTANCE; 32 | kotlinx.serialization.KSerializer serializer(...); 33 | } 34 | 35 | # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. 36 | -keepattributes RuntimeVisibleAnnotations,AnnotationDefault 37 | 38 | # Don't print notes about potential mistakes or omissions in the configuration for kotlinx-serialization classes 39 | # See also https://github.com/Kotlin/kotlinx.serialization/issues/1900 40 | -dontnote kotlinx.serialization.** 41 | 42 | # Serialization core uses `java.lang.ClassValue` for caching inside these specified classes. 43 | # If there is no `java.lang.ClassValue` (for example, in Android), then R8/ProGuard will print a warning. 44 | # However, since in this case they will not be used, we can disable these warnings 45 | -dontwarn kotlinx.serialization.internal.ClassValueReferences 46 | 47 | # disable optimisation for descriptor field because in some versions of ProGuard, optimization generates incorrect bytecode that causes a verification error 48 | # see https://github.com/Kotlin/kotlinx.serialization/issues/2719 49 | -keepclassmembers public class **$$serializer { 50 | private ** descriptor; 51 | } 52 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/ServerListPaths.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer 20 | 21 | import com.spoiligaming.explorer.minecraft.common.PathResolver 22 | import com.spoiligaming.explorer.minecraft.common.requireExistingFile 23 | import com.spoiligaming.explorer.util.OSUtils 24 | import java.nio.file.Files 25 | import java.nio.file.Path 26 | import java.nio.file.Paths 27 | 28 | object ServerListPaths : PathResolver { 29 | override fun find(path: String) = Paths.get(path).takeIf { Files.exists(it) } 30 | 31 | override fun findDefault(): Path? { 32 | val home = System.getProperty("user.home") ?: return null 33 | val p = 34 | when { 35 | OSUtils.isWindows -> 36 | Paths.get( 37 | home, 38 | "AppData", 39 | "Roaming", 40 | ".minecraft", 41 | "servers.dat", 42 | ) 43 | OSUtils.isMacOS -> 44 | Paths.get( 45 | home, 46 | "Library", 47 | "Application Support", 48 | "minecraft", 49 | "servers.dat", 50 | ) 51 | 52 | OSUtils.isLinux -> Paths.get(home, ".minecraft", "servers.dat") 53 | else -> return null 54 | } 55 | return p.takeIf(Files::exists) 56 | } 57 | 58 | override fun validate(path: Path?) = requireExistingFile(path) 59 | } 60 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/snackbar/SnackbarHostManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.snackbar 20 | 21 | import androidx.compose.material3.SnackbarHost 22 | import androidx.compose.material3.SnackbarHostState 23 | import androidx.compose.material3.SnackbarResult 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.LaunchedEffect 26 | import androidx.compose.runtime.remember 27 | import androidx.compose.runtime.rememberCoroutineScope 28 | import androidx.compose.ui.Modifier 29 | import kotlinx.coroutines.launch 30 | 31 | @Composable 32 | internal fun SnackbarHostManager(modifier: Modifier = Modifier) { 33 | val snackbarHostState = remember { SnackbarHostState() } 34 | val scope = rememberCoroutineScope() 35 | val eventsFlow = SnackbarController.events 36 | 37 | LaunchedEffect(eventsFlow, snackbarHostState) { 38 | scope.launch { 39 | eventsFlow.collect { event -> 40 | snackbarHostState.currentSnackbarData?.dismiss() 41 | 42 | val result = 43 | snackbarHostState.showSnackbar( 44 | message = event.message, 45 | actionLabel = event.action?.name, 46 | duration = event.duration, 47 | ) 48 | 49 | if (result == SnackbarResult.ActionPerformed) { 50 | event.action?.action?.invoke() 51 | } 52 | } 53 | } 54 | } 55 | 56 | SnackbarHost( 57 | hostState = snackbarHostState, 58 | modifier = modifier, 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/spoiligaming/explorer/LogStorage.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer 20 | 21 | import com.spoiligaming.explorer.build.BuildConfig 22 | import java.io.File 23 | 24 | internal object LogStorage { 25 | private const val APP_DIR = "ServerListExplorer" 26 | 27 | private val isPortableWindows 28 | get() = isWindows && BuildConfig.DISTRIBUTION.contains("portable", ignoreCase = true) 29 | 30 | private val osName 31 | get() = System.getProperty("os.name")?.lowercase().orEmpty() 32 | 33 | private val isWindows 34 | get() = osName.contains("win") 35 | 36 | private val isMac 37 | get() = osName.contains("mac") 38 | 39 | val logsDir 40 | get() = 41 | if (isPortableWindows) { 42 | File("logs") 43 | } else { 44 | when { 45 | isWindows -> windowsLogs() 46 | isMac -> macLogs() 47 | else -> linuxLogs() 48 | } 49 | } 50 | 51 | private fun windowsLogs(): File { 52 | val local = System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() } 53 | val base = 54 | local?.let { File(it, APP_DIR) } 55 | ?: File(System.getProperty("user.home"), "AppData/Local/$APP_DIR") 56 | return base.resolve("logs") 57 | } 58 | 59 | private fun macLogs() = File(System.getProperty("user.home"), "Library/Logs/$APP_DIR") 60 | 61 | private fun linuxLogs(): File { 62 | val state = System.getenv("XDG_STATE_HOME")?.takeIf { it.isNotBlank() } 63 | val base = 64 | state?.let { File(it, APP_DIR) } 65 | ?: File(System.getProperty("user.home"), ".local/state/$APP_DIR") 66 | return base.resolve("logs") 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/multiplayer/ShimmerServerEntry.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | @file:OptIn(ExperimentalUuidApi::class) 20 | 21 | package com.spoiligaming.explorer.ui.screens.multiplayer 22 | 23 | import androidx.compose.foundation.BorderStroke 24 | import androidx.compose.foundation.border 25 | import androidx.compose.material3.CardDefaults 26 | import androidx.compose.material3.ElevatedCard 27 | import androidx.compose.runtime.Composable 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.graphics.Color 30 | import androidx.compose.ui.unit.dp 31 | import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalAmoledActive 32 | import com.valentinilk.shimmer.Shimmer 33 | import com.valentinilk.shimmer.shimmer 34 | import kotlin.uuid.ExperimentalUuidApi 35 | 36 | // TODO 37 | @Composable 38 | internal fun ShimmerServerEntry( 39 | modifier: Modifier = Modifier, 40 | shimmer: Shimmer, 41 | ) { 42 | val amoledOn = LocalAmoledActive.current 43 | 44 | ElevatedCard( 45 | modifier = 46 | modifier 47 | .shimmer(shimmer) 48 | .border( 49 | border = 50 | if (amoledOn) { 51 | CardDefaults.outlinedCardBorder() 52 | } else { 53 | BorderStroke( 54 | 0.dp, 55 | Color.Transparent, 56 | ) 57 | }, 58 | shape = CardDefaults.shape, 59 | ), 60 | colors = 61 | CardDefaults 62 | .cardColors() 63 | .copy( 64 | containerColor = if (amoledOn) Color.Black else CardDefaults.cardColors().containerColor, 65 | ), 66 | ) {} 67 | } 68 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/Localization.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui 20 | 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.CompositionLocalProvider 23 | import androidx.compose.runtime.ProvidedValue 24 | import androidx.compose.runtime.staticCompositionLocalOf 25 | import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalPrefs 26 | import org.jetbrains.compose.resources.StringResource 27 | import org.jetbrains.compose.resources.stringResource 28 | import java.util.Locale 29 | 30 | private object AppLocale { 31 | private var initialDefaultLocale: Locale? = null 32 | private val LocalLocaleTag = staticCompositionLocalOf { Locale.getDefault().toLanguageTag() } 33 | 34 | val currentTag: String 35 | @Composable get() = LocalLocaleTag.current 36 | 37 | @Composable 38 | infix fun provides(localeTag: String?): ProvidedValue<*> { 39 | if (initialDefaultLocale == null) { 40 | initialDefaultLocale = Locale.getDefault() 41 | } 42 | 43 | val resolvedLocale = 44 | when (localeTag) { 45 | null -> initialDefaultLocale!! 46 | else -> Locale.forLanguageTag(localeTag) 47 | } 48 | 49 | Locale.setDefault(resolvedLocale) 50 | 51 | return LocalLocaleTag.provides(resolvedLocale.toLanguageTag()) 52 | } 53 | } 54 | 55 | @Composable 56 | internal fun AppLocaleProvider(content: @Composable () -> Unit) { 57 | val locale = LocalPrefs.current.locale.toLanguageTag() 58 | 59 | CompositionLocalProvider( 60 | AppLocale provides locale, 61 | content = content, 62 | ) 63 | } 64 | 65 | @Composable 66 | internal fun t(stringRes: StringResource) = stringResource(stringRes) 67 | 68 | @Composable 69 | internal fun t( 70 | stringRes: StringResource, 71 | vararg args: Any, 72 | ) = stringResource(stringRes, *args) 73 | -------------------------------------------------------------------------------- /nbt/src/main/kotlin/com/spoiligaming/explorer/multiplayer/MultiplayerServer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | @file:OptIn(ExperimentalUuidApi::class) 20 | 21 | package com.spoiligaming.explorer.multiplayer 22 | 23 | import androidx.compose.runtime.Immutable 24 | import io.github.oshai.kotlinlogging.KotlinLogging 25 | import kotlin.io.encoding.Base64 26 | import kotlin.uuid.ExperimentalUuidApi 27 | import kotlin.uuid.Uuid 28 | 29 | @Immutable 30 | data class MultiplayerServer( 31 | val id: Uuid = Uuid.random(), 32 | val name: String, 33 | val ip: String, 34 | val iconBase64: String? = null, 35 | val hidden: HiddenState = HiddenState.NotHidden, 36 | val acceptTextures: AcceptTexturesState = AcceptTexturesState.Prompt, 37 | ) { 38 | val iconBytes 39 | get() = 40 | iconBase64 41 | ?.takeIf { it.isNotBlank() } 42 | ?.let { 43 | runCatching { Base64.decode(it) } 44 | .onFailure { e -> 45 | logger.error(e) { "Failed to decode icon for '$name': ${e.message}" } 46 | }.getOrNull() 47 | }?.takeIf { it.size <= MAX_ICON_SIZE_BYTES } 48 | ?: run { 49 | iconBase64?.let { 50 | logger.error { 51 | "Icon for '$name' too large (${it.length} bytes), " + 52 | "max is $MAX_ICON_SIZE_BYTES" 53 | } 54 | } 55 | null 56 | } 57 | 58 | val iconStream get() = iconBytes?.inputStream() 59 | 60 | companion object { 61 | const val MAX_ICON_SIZE_BYTES = 96 * 1024 62 | val logger = KotlinLogging.logger {} 63 | } 64 | } 65 | 66 | enum class AcceptTexturesState { 67 | Enabled, 68 | Disabled, 69 | Prompt, 70 | } 71 | 72 | enum class HiddenState { 73 | Hidden, 74 | NotHidden, 75 | } 76 | -------------------------------------------------------------------------------- /core/src/test/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/mcsrvstat/McSrvStatServerStatusResponseTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.mcsrvstat 20 | 21 | import kotlin.test.Test 22 | import kotlin.test.assertEquals 23 | 24 | internal class McSrvStatServerStatusResponseTest { 25 | private fun responseWithIcon(rawIcon: String) = 26 | McSrvStatServerStatusResponse( 27 | ip = "1.6.9", 28 | port = 25565, 29 | debug = 30 | DebugInfo( 31 | ping = false, 32 | query = false, 33 | bedrock = false, 34 | srv = false, 35 | querymismatch = false, 36 | ipinsrv = false, 37 | cnameinsrv = false, 38 | animatedmotd = false, 39 | cachehit = false, 40 | cachetime = 4, 41 | cacheexpire = 2, 42 | apiversion = 0, 43 | dns = DnsInfo(), 44 | error = null, 45 | ), 46 | motd = Motd(), 47 | players = Players(online = 0, max = 20), 48 | versionInfo = "4.20", 49 | online = true, 50 | protocol = Protocol(version = 764), 51 | hostname = "noballs727", 52 | rawIcon = rawIcon, 53 | info = null, 54 | eulaBlocked = false, 55 | ) 56 | 57 | @Test 58 | fun `icon strips data URL base64 prefix`() { 59 | val raw = "data:image/png;base64,QUJDREVGRw==" 60 | val response = responseWithIcon(raw) 61 | assertEquals("QUJDREVGRw==", response.icon) 62 | } 63 | 64 | @Test 65 | fun `icon returns raw icon unchanged when no prefix`() { 66 | val raw = "QUJDREVGRw==" 67 | val response = responseWithIcon(raw) 68 | assertEquals(raw, response.icon) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /ui/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | plugins { 20 | alias(libs.plugins.compose) 21 | alias(libs.plugins.kotlin.compose) 22 | alias(libs.plugins.kotlin.serialization) 23 | } 24 | 25 | dependencies { 26 | implementation(project(":core")) 27 | implementation(project(":settings")) 28 | implementation(project(":nbt")) 29 | 30 | // Compose Desktop 31 | implementation(compose.desktop.currentOs) { 32 | exclude(group = "org.jetbrains.compose.material", module = "material") 33 | exclude(group = "org.jetbrains.compose.material", module = "material-desktop") 34 | exclude(group = "org.jetbrains.compose.material", module = "material-ripple") 35 | } 36 | implementation(compose.components.resources) 37 | implementation(compose.material3) 38 | implementation(compose.materialIconsExtended) 39 | implementation(libs.kotlinx.serialization.json) 40 | 41 | // Navigation Compose 42 | implementation(libs.navigation.compose) 43 | 44 | // UI Components 45 | implementation(libs.materialKolor) 46 | implementation(libs.color.picker) 47 | implementation(libs.file.kit) 48 | implementation(libs.composeShimmer) 49 | implementation(libs.reorderable.jvm) 50 | implementation(libs.autolinktext) 51 | 52 | // JNA for DWM 53 | implementation(libs.jna) 54 | implementation(libs.jna.platform) 55 | 56 | // System theme detection 57 | implementation(libs.theme.detector) { 58 | exclude(group = "net.java.dev.jna", module = "jna") 59 | exclude(group = "net.java.dev.jna", module = "jna-platform") 60 | } 61 | 62 | // Resources 63 | implementation(compose.components.resources) 64 | } 65 | 66 | compose { 67 | resources { 68 | generateResClass = always 69 | 70 | customDirectory( 71 | sourceSetName = "main", 72 | directoryProvider = 73 | provider { 74 | layout.projectDirectory.dir("src/main/composeResources") 75 | }, 76 | ) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/mcutils/retry.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.mcutils 20 | 21 | internal class RetryPolicy( 22 | val exceptionClass: Class, 23 | val maxAttempts: Int, 24 | val onRetry: (attempt: Int, maxAttempts: Int, cause: Throwable) -> Unit, 25 | ) 26 | 27 | internal inline fun retryPolicy( 28 | maxAttempts: Int, 29 | noinline onRetry: (attempt: Int, maxAttempts: Int, cause: E) -> Unit, 30 | ): RetryPolicy { 31 | require(maxAttempts >= 1) { 32 | "maxAttempts must be >= 1 (was $maxAttempts for ${E::class.simpleName})" 33 | } 34 | 35 | return RetryPolicy( 36 | exceptionClass = E::class.java, 37 | maxAttempts = maxAttempts, 38 | onRetry = { attempt, max, cause -> 39 | @Suppress("UNCHECKED_CAST") 40 | onRetry(attempt, max, cause as E) 41 | }, 42 | ) 43 | } 44 | 45 | internal inline fun retry( 46 | vararg policies: RetryPolicy, 47 | block: () -> T, 48 | ): T { 49 | if (policies.isEmpty()) return block() 50 | 51 | val size = policies.size 52 | val attemptsUsed = IntArray(size) 53 | 54 | while (true) { 55 | try { 56 | return block() 57 | } catch (t: Throwable) { 58 | var index = -1 59 | for (i in 0 until size) { 60 | if (policies[i].exceptionClass.isInstance(t)) { 61 | index = i 62 | break 63 | } 64 | } 65 | 66 | if (index == -1) { 67 | throw t 68 | } 69 | 70 | val policy = policies[index] 71 | val used = attemptsUsed[index] 72 | 73 | if (used + 1 >= policy.maxAttempts) { 74 | throw t 75 | } 76 | 77 | attemptsUsed[index] = used + 1 78 | policy.onRetry(used, policy.maxAttempts, t) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/mcsrvstat/McSrvStatServerQueryHandler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.mcsrvstat 20 | 21 | import com.spoiligaming.explorer.minecraft.multiplayer.online.backend.common.IServerQueryHandler 22 | import com.spoiligaming.explorer.minecraft.multiplayer.online.backend.common.McSrvStatRateLimitedServerData 23 | import com.spoiligaming.explorer.minecraft.multiplayer.online.backend.common.OfflineServerData 24 | import io.github.oshai.kotlinlogging.KotlinLogging 25 | import io.ktor.client.HttpClient 26 | import kotlinx.coroutines.Dispatchers 27 | import kotlinx.coroutines.withContext 28 | 29 | /** 30 | * `IServerQueryHandler` implementation powered by the public [https://api.mcsrvstat.us](https://api.mcsrvstat.us) service. 31 | */ 32 | internal class McSrvStatServerQueryHandler( 33 | private val serverAddress: String, 34 | private val client: HttpClient, 35 | ) : IServerQueryHandler { 36 | override suspend fun getServerData() = 37 | withContext(Dispatchers.IO) { 38 | try { 39 | val rawJson = 40 | RequestHandler(serverAddress, client) 41 | .fetchResponseBody() 42 | .getOrThrow() 43 | 44 | if (!rawJson.contains("\"online\":true")) { 45 | logger.warn { "Server is offline: $serverAddress" } 46 | return@withContext OfflineServerData 47 | } 48 | 49 | val decoded = OnlineServerDataSerializer.decode(rawJson) 50 | OnlineServerDataSerializer.reduce(decoded) 51 | } catch (_: RequestHandler.RateLimitException) { 52 | McSrvStatRateLimitedServerData 53 | } catch (e: Exception) { 54 | logger.error(e) { "Failed to get server data for $serverAddress" } 55 | OfflineServerData 56 | } 57 | } 58 | } 59 | 60 | private val logger = KotlinLogging.logger {} 61 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/LanguageSelectionStep.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | @file:OptIn(ExperimentalMaterial3Api::class) 20 | 21 | package com.spoiligaming.explorer.ui.screens.setup.steps 22 | 23 | import androidx.compose.foundation.layout.Arrangement 24 | import androidx.compose.foundation.layout.Column 25 | import androidx.compose.material3.ExperimentalMaterial3Api 26 | import androidx.compose.material3.MaterialTheme 27 | import androidx.compose.material3.Text 28 | import androidx.compose.runtime.Composable 29 | import androidx.compose.ui.unit.dp 30 | import com.spoiligaming.explorer.settings.manager.preferenceSettingsManager 31 | import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalPrefs 32 | import com.spoiligaming.explorer.ui.screens.setup.SetupStepContainer 33 | import com.spoiligaming.explorer.ui.t 34 | import com.spoiligaming.explorer.ui.widgets.LanguagePickerDropdownMenu 35 | import server_list_explorer.ui.generated.resources.Res 36 | import server_list_explorer.ui.generated.resources.preferred_language_label 37 | import server_list_explorer.ui.generated.resources.setup_step_title_localization 38 | 39 | @Composable 40 | internal fun LanguageSelectionStep() { 41 | val currentLocale = LocalPrefs.current.locale 42 | 43 | SetupStepContainer(title = t(Res.string.setup_step_title_localization)) { 44 | Column( 45 | verticalArrangement = Arrangement.spacedBy(LanguageStepItemSpacing), 46 | ) { 47 | Text( 48 | text = t(Res.string.preferred_language_label), 49 | style = MaterialTheme.typography.titleMedium, 50 | color = MaterialTheme.colorScheme.onSurfaceVariant, 51 | ) 52 | LanguagePickerDropdownMenu( 53 | selectedLocale = currentLocale, 54 | onLocaleSelected = { locale -> 55 | preferenceSettingsManager.updateSettings { 56 | it.copy(locale = locale) 57 | } 58 | }, 59 | ) 60 | } 61 | } 62 | } 63 | 64 | private val LanguageStepItemSpacing = 4.dp 65 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/snackbar/SnackbarController.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | @file:OptIn(ExperimentalAtomicApi::class) 20 | 21 | package com.spoiligaming.explorer.ui.snackbar 22 | 23 | import androidx.compose.material3.SnackbarDuration 24 | import com.spoiligaming.explorer.ui.snackbar.SnackbarController.FIRST_SNACKBAR_DELAY_MS 25 | import io.github.oshai.kotlinlogging.KotlinLogging 26 | import kotlinx.coroutines.channels.Channel 27 | import kotlinx.coroutines.delay 28 | import kotlinx.coroutines.flow.receiveAsFlow 29 | import kotlin.concurrent.atomics.AtomicBoolean 30 | import kotlin.concurrent.atomics.ExperimentalAtomicApi 31 | 32 | internal data class SnackbarEvent( 33 | val message: String, 34 | val duration: SnackbarDuration, 35 | val action: SnackbarAction? = null, 36 | ) 37 | 38 | internal data class SnackbarAction( 39 | val name: String, 40 | val action: suspend () -> Unit, 41 | ) 42 | 43 | internal object SnackbarController { 44 | private val logging = KotlinLogging.logger { } 45 | 46 | private const val FIRST_SNACKBAR_DELAY_MS = 600L 47 | 48 | private val _events = Channel() 49 | val events = _events.receiveAsFlow() 50 | 51 | private val firstSend = AtomicBoolean(true) 52 | 53 | /** 54 | * Sends a [SnackbarEvent] to the UI snackbar host. 55 | * 56 | * On the very first invocation after app startup, this function intentionally applies a short delay 57 | * (see [FIRST_SNACKBAR_DELAY_MS]) before sending the event. This gives the Compose UI on desktop 58 | * enough time to fully initialize, preventing a noticeable UI hitch or lag when showing the very first snackbar. 59 | * 60 | * All subsequent snackbar events are sent immediately without delay. 61 | */ 62 | suspend fun sendEvent(event: SnackbarEvent) { 63 | if (firstSend.compareAndExchange(expectedValue = true, newValue = false)) { 64 | delay(FIRST_SNACKBAR_DELAY_MS) 65 | logging.debug { 66 | "Applied one-time delay of $FIRST_SNACKBAR_DELAY_MS ms before first snackbar event." 67 | } 68 | } 69 | _events.send(event) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/navigation/NavGraph.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.navigation 20 | 21 | import androidx.compose.animation.EnterTransition 22 | import androidx.compose.animation.ExitTransition 23 | import androidx.compose.foundation.layout.fillMaxSize 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.ui.Modifier 26 | import androidx.navigation.NavHostController 27 | import androidx.navigation.compose.NavHost 28 | import androidx.navigation.compose.composable 29 | import com.spoiligaming.explorer.settings.manager.UniversalSettingsManager 30 | import com.spoiligaming.explorer.ui.AppLocaleProvider 31 | import com.spoiligaming.explorer.ui.components.LoadingScreen 32 | import com.spoiligaming.explorer.ui.screens.multiplayer.MultiplayerScreenContainer 33 | import com.spoiligaming.explorer.ui.screens.settings.SettingsScreen 34 | import com.spoiligaming.explorer.ui.t 35 | import server_list_explorer.ui.generated.resources.Res 36 | import server_list_explorer.ui.generated.resources.loading_user_settings 37 | 38 | @Composable 39 | internal fun NavGraph(navController: NavHostController) { 40 | NavHost( 41 | navController = navController, 42 | startDestination = MultiplayerServerListScreen, 43 | enterTransition = { EnterTransition.None }, 44 | exitTransition = { ExitTransition.None }, 45 | popEnterTransition = { EnterTransition.None }, 46 | popExitTransition = { ExitTransition.None }, 47 | ) { 48 | composable { 49 | MultiplayerScreenContainer(navController) 50 | } 51 | 52 | composable { 53 | AppLocaleProvider { 54 | LoadingScreen( 55 | displayAfterThreshold = true, 56 | steps = 57 | listOf( 58 | t(Res.string.loading_user_settings) to { UniversalSettingsManager.loadAll() }, 59 | ), 60 | modifier = Modifier.fillMaxSize(), 61 | ) { 62 | SettingsScreen() 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/mcsrvstat/RequestHandler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.mcsrvstat 20 | 21 | import io.github.oshai.kotlinlogging.KotlinLogging 22 | import io.ktor.client.HttpClient 23 | import io.ktor.client.request.get 24 | import io.ktor.client.statement.bodyAsText 25 | import io.ktor.http.HttpStatusCode 26 | import io.ktor.http.headers 27 | 28 | private val logger = KotlinLogging.logger {} 29 | 30 | internal class RequestHandler( 31 | serverAddress: String, 32 | private val client: HttpClient, 33 | ) { 34 | companion object { 35 | private const val BASE_URL = "https://api.mcsrvstat.us/3" 36 | } 37 | 38 | init { 39 | require(serverAddress.isNotBlank()) { "Server address must not be blank" } 40 | } 41 | 42 | private val serverAddress = serverAddress.trim() 43 | 44 | internal class RateLimitException : IllegalStateException("Rate limit exceeded for MCSrvStatus") 45 | 46 | suspend fun fetchResponseBody(): Result { 47 | val url = "$BASE_URL/$serverAddress" 48 | logger.debug { "Requesting server status at $url" } 49 | 50 | return runCatching { 51 | val response = 52 | client.get(url) { 53 | headers { 54 | append( 55 | "User-Agent", 56 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", 57 | ) 58 | append("Accept", "application/json") 59 | } 60 | } 61 | 62 | when (response.status) { 63 | HttpStatusCode.TooManyRequests -> throw RateLimitException() 64 | HttpStatusCode.OK -> { 65 | val body = response.bodyAsText() 66 | check(body.isNotBlank()) { "Response body is empty" } 67 | body 68 | } 69 | 70 | else -> error("Unexpected HTTP status: ${response.status.value}") 71 | } 72 | }.onFailure { e -> 73 | logger.error(e) { "Failed to fetch status for $serverAddress" } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/extensions/ImageBitmapLuminance.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.extensions 20 | 21 | import androidx.compose.ui.geometry.Rect 22 | import androidx.compose.ui.graphics.ImageBitmap 23 | import androidx.compose.ui.graphics.toPixelMap 24 | 25 | // luminance coefficients per ITU-R BT.709 26 | private const val LUMINANCE_RED = 0.2126f 27 | private const val LUMINANCE_GREEN = 0.7152f 28 | private const val LUMINANCE_BLUE = 0.0722f 29 | 30 | private const val DEFAULT_THRESHOLD = 0.5f 31 | private const val DEFAULT_STRIDE = 4 32 | 33 | /** 34 | * Computes the average luminance of a region in the bitmap. 35 | * 36 | * @param region The rectangular area to sample (in pixels). Defaults to the entire bitmap. 37 | * @param stride Number of pixels to skip per sample (higher = faster, lower = more accurate). 38 | * @return Average luminance in [0,1], or 1f if no samples were taken. 39 | */ 40 | private fun ImageBitmap.averageLuminance( 41 | region: Rect = Rect(0f, 0f, width.toFloat(), height.toFloat()), 42 | stride: Int = DEFAULT_STRIDE, 43 | ): Float { 44 | val pixelMap = toPixelMap() 45 | // clamp region to bitmap bounds 46 | val left = region.left.coerceIn(0f, width.toFloat()).toInt() 47 | val top = region.top.coerceIn(0f, height.toFloat()).toInt() 48 | val right = region.right.coerceIn(0f, width.toFloat()).toInt() 49 | val bottom = region.bottom.coerceIn(0f, height.toFloat()).toInt() 50 | 51 | var sum = 0f 52 | var count = 0 53 | 54 | for (y in top until bottom step stride) { 55 | for (x in left until right step stride) { 56 | pixelMap[x, y].let { color -> 57 | sum += color.red * LUMINANCE_RED + 58 | color.green * LUMINANCE_GREEN + 59 | color.blue * LUMINANCE_BLUE 60 | } 61 | count++ 62 | } 63 | } 64 | return if (count > 0) sum / count else 1f 65 | } 66 | 67 | /** 68 | * Determines if the entire bitmap is considered dark. 69 | * 70 | * @param threshold Luminance cutoff (0 = pure black, 1 = pure white). 71 | * @param stride Sampling stride. 72 | */ 73 | internal fun ImageBitmap.isDark( 74 | threshold: Float = DEFAULT_THRESHOLD, 75 | stride: Int = DEFAULT_STRIDE, 76 | ) = averageLuminance(stride = stride) < threshold 77 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/components/FPSCounterOverlay.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.components 20 | 21 | import androidx.compose.foundation.layout.Box 22 | import androidx.compose.foundation.layout.BoxScope 23 | import androidx.compose.foundation.layout.padding 24 | import androidx.compose.material3.CardDefaults 25 | import androidx.compose.material3.ElevatedCard 26 | import androidx.compose.material3.MaterialTheme 27 | import androidx.compose.material3.Text 28 | import androidx.compose.runtime.Composable 29 | import androidx.compose.runtime.LaunchedEffect 30 | import androidx.compose.runtime.getValue 31 | import androidx.compose.runtime.mutableIntStateOf 32 | import androidx.compose.runtime.remember 33 | import androidx.compose.runtime.setValue 34 | import androidx.compose.runtime.withFrameNanos 35 | import androidx.compose.ui.Alignment 36 | import androidx.compose.ui.Modifier 37 | import androidx.compose.ui.unit.dp 38 | import androidx.compose.ui.unit.sp 39 | import org.jetbrains.skiko.FPSCounter 40 | 41 | @Composable 42 | internal fun FPSOverlay(onFpsUpdate: (Int) -> Unit) = 43 | LaunchedEffect(Unit) { 44 | val counter = FPSCounter(logOnTick = false) 45 | while (true) { 46 | withFrameNanos { 47 | counter.tick() 48 | val fps = counter.average 49 | onFpsUpdate(fps) 50 | } 51 | } 52 | } 53 | 54 | @Composable 55 | internal fun BoxScope.FPSDisplay() { 56 | var fps by remember { mutableIntStateOf(0) } 57 | FPSOverlay { fps = it } 58 | Box( 59 | Modifier.align(Alignment.BottomEnd), 60 | ) { 61 | ElevatedCard( 62 | shape = MaterialTheme.shapes.extraSmall, 63 | colors = 64 | CardDefaults.cardColors( 65 | containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, 66 | ), 67 | ) { 68 | Text( 69 | text = "$fps FPS", 70 | modifier = Modifier.padding(FpsCardPadding), 71 | style = 72 | MaterialTheme.typography.bodyLarge.copy( 73 | fontSize = FpsTextSize, 74 | ), 75 | ) 76 | } 77 | } 78 | } 79 | 80 | private val FpsCardPadding = 4.dp 81 | private val FpsTextSize = 16.sp 82 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | 74 | 75 | @rem Execute Gradle 76 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 | 78 | :end 79 | @rem End local scope for the variables with windows NT shell 80 | if %ERRORLEVEL% equ 0 goto mainEnd 81 | 82 | :fail 83 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 | rem the _cmd.exe /c_ return code! 85 | set EXIT_CODE=%ERRORLEVEL% 86 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 | exit /b %EXIT_CODE% 89 | 90 | :mainEnd 91 | if "%OS%"=="Windows_NT" endlocal 92 | 93 | :omega 94 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/manager/SettingsManagers.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.manager 20 | 21 | import com.spoiligaming.explorer.settings.model.MultiplayerSettings 22 | import com.spoiligaming.explorer.settings.model.Preferences 23 | import com.spoiligaming.explorer.settings.model.ServerQueryMethodConfigurations 24 | import com.spoiligaming.explorer.settings.model.SingleplayerSettings 25 | import com.spoiligaming.explorer.settings.model.ThemeSettings 26 | import com.spoiligaming.explorer.settings.model.WindowAppearance 27 | import com.spoiligaming.explorer.settings.model.WindowState 28 | 29 | /* 30 | * CONTRIBUTOR NOTICE: 31 | * 32 | * If you add, remove, or rename a UniversalSettingsManager or any settings model in this file, 33 | * you must also update com.spoiligaming.explorer.ui.SettingsCompositionLocals.kt 34 | * to keep the CompositionLocals in sync with these managers. 35 | * 36 | * Failing to do so will break global settings propagation in the UI. 37 | */ 38 | 39 | val windowStateSettingsManager by UniversalSettingsManager( 40 | fileName = "window_state.json", 41 | defaultValueProvider = { WindowState() }, 42 | ) 43 | 44 | val windowAppearanceSettingsManager by UniversalSettingsManager( 45 | fileName = "window_appearance.json", 46 | defaultValueProvider = { WindowAppearance() }, 47 | ) 48 | 49 | val preferenceSettingsManager by UniversalSettingsManager( 50 | fileName = "preferences.json", 51 | defaultValueProvider = { Preferences() }, 52 | ) 53 | 54 | val themeSettingsManager by UniversalSettingsManager( 55 | fileName = "theme.json", 56 | defaultValueProvider = { ThemeSettings() }, 57 | ) 58 | 59 | val multiplayerSettingsManager by UniversalSettingsManager( 60 | fileName = "multiplayer.json", 61 | defaultValueProvider = { MultiplayerSettings() }, 62 | ) 63 | 64 | val serverQueryMethodConfigurationsManager by UniversalSettingsManager( 65 | fileName = "server_query_method_configs.json", 66 | defaultValueProvider = { ServerQueryMethodConfigurations() }, 67 | ) 68 | 69 | val singleplayerSettingsManager by UniversalSettingsManager( 70 | fileName = "singleplayer.json", 71 | defaultValueProvider = { SingleplayerSettings() }, 72 | ) 73 | -------------------------------------------------------------------------------- /add_gpl_header.bat: -------------------------------------------------------------------------------- 1 | rem This script was generated with assistance from ChatGPT-4o by OpenAI. 2 | 3 | @echo off 4 | setlocal EnableDelayedExpansion 5 | 6 | set "header_kt_0=/*" 7 | set "header_kt_1= * This file is part of Server List Explorer." 8 | set "header_kt_2= * Copyright (C) 2025 SpoilerRules" 9 | set "header_kt_3= *" 10 | set "header_kt_4= * Server List Explorer is free software: you can redistribute it and/or modify" 11 | set "header_kt_5= * it under the terms of the GNU General Public License as published by" 12 | set "header_kt_6= * the Free Software Foundation, either version 3 of the License, or" 13 | set "header_kt_7= * (at your option) any later version." 14 | set "header_kt_8= *" 15 | set "header_kt_9= * Server List Explorer is distributed in the hope that it will be useful," 16 | set "header_kt_10= * but WITHOUT ANY WARRANTY; without even the implied warranty of" 17 | set "header_kt_11= * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the" 18 | set "header_kt_12= * GNU General Public License for more details." 19 | set "header_kt_13= *" 20 | set "header_kt_14= * You should have received a copy of the GNU General Public License" 21 | set "header_kt_15= * along with Server List Explorer. If not, see ." 22 | set "header_kt_16=*/" 23 | 24 | set "header_prop_0=# This file is part of Server List Explorer." 25 | set "header_prop_1=# Copyright (C) 2025 SpoilerRules" 26 | set "header_prop_2=#" 27 | set "header_prop_3=# Server List Explorer is free software: you can redistribute it and/or modify" 28 | set "header_prop_4=# it under the terms of the GNU General Public License as published by" 29 | set "header_prop_5=# the Free Software Foundation, either version 3 of the License, or" 30 | set "header_prop_6=# (at your option) any later version." 31 | set "header_prop_7=#" 32 | set "header_prop_8=# Server List Explorer is distributed in the hope that it will be useful," 33 | set "header_prop_9=# but WITHOUT ANY WARRANTY; without even the implied warranty of" 34 | set "header_prop_10=# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the" 35 | set "header_prop_11=# GNU General Public License for more details." 36 | set "header_prop_12=#" 37 | set "header_prop_13=# You should have received a copy of the GNU General Public License" 38 | set "header_prop_14=# along with Server List Explorer. If not, see ." 39 | 40 | for /R %%F in (*.kt *.kts) do ( 41 | findstr /C:"* This file is part of Server List Explorer." "%%F" >nul 42 | if errorlevel 1 ( 43 | echo Adding header to %%F 44 | >"%%F.temp" ( 45 | for /L %%i in (0,1,16) do echo !header_kt_%%i! 46 | echo( 47 | type "%%F" 48 | ) 49 | move /Y "%%F.temp" "%%F" >nul 50 | ) 51 | ) 52 | 53 | for /R %%F in (*.properties) do ( 54 | findstr /C:"# This file is part of Server List Explorer." "%%F" >nul 55 | if errorlevel 1 ( 56 | echo Adding header to %%F 57 | >"%%F.temp" ( 58 | for /L %%i in (0,1,14) do echo !header_prop_%%i! 59 | echo( 60 | type "%%F" 61 | ) 62 | move /Y "%%F.temp" "%%F" >nul 63 | ) 64 | ) 65 | 66 | echo. 67 | echo Done. 68 | pause 69 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/SetupStepContainer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.screens.setup 20 | 21 | import androidx.compose.foundation.layout.Arrangement 22 | import androidx.compose.foundation.layout.Box 23 | import androidx.compose.foundation.layout.BoxScope 24 | import androidx.compose.foundation.layout.Column 25 | import androidx.compose.foundation.layout.fillMaxSize 26 | import androidx.compose.foundation.layout.padding 27 | import androidx.compose.foundation.layout.widthIn 28 | import androidx.compose.foundation.layout.wrapContentHeight 29 | import androidx.compose.material3.Card 30 | import androidx.compose.material3.MaterialTheme 31 | import androidx.compose.material3.Text 32 | import androidx.compose.runtime.Composable 33 | import androidx.compose.ui.Alignment 34 | import androidx.compose.ui.Modifier 35 | import androidx.compose.ui.unit.dp 36 | 37 | @Composable 38 | internal fun SetupStepContainer( 39 | title: String, 40 | subtitle: String? = null, 41 | content: @Composable BoxScope.() -> Unit, 42 | ) = Box( 43 | modifier = Modifier.fillMaxSize(), 44 | contentAlignment = Alignment.Center, 45 | ) { 46 | Card( 47 | modifier = 48 | Modifier 49 | .widthIn(min = ContainerMinWidth, max = ContainerMaxWidth) 50 | .wrapContentHeight(), 51 | ) { 52 | Column( 53 | modifier = Modifier.padding(ContainerPadding), 54 | verticalArrangement = Arrangement.spacedBy(ColumnArrangement), 55 | ) { 56 | Column { 57 | Text( 58 | text = title, 59 | style = MaterialTheme.typography.titleLarge, 60 | color = MaterialTheme.colorScheme.onSurface, 61 | ) 62 | subtitle?.let { 63 | Text( 64 | text = it, 65 | style = MaterialTheme.typography.bodyMedium, 66 | color = MaterialTheme.colorScheme.onSurfaceVariant, 67 | ) 68 | } 69 | } 70 | Box { 71 | content() 72 | } 73 | } 74 | } 75 | } 76 | 77 | private val ContainerPadding = 32.dp 78 | private val ColumnArrangement = 16.dp 79 | private val ContainerMinWidth = 400.dp 80 | private val ContainerMaxWidth = 800.dp 81 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/window/WindowState.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.window 20 | 21 | import com.spoiligaming.explorer.settings.manager.windowStateSettingsManager 22 | import java.awt.Frame 23 | import java.awt.event.ComponentAdapter 24 | import java.awt.event.ComponentEvent 25 | import java.awt.event.WindowEvent 26 | import javax.swing.JFrame 27 | 28 | internal object WindowSettingsBinder { 29 | @Suppress("NOTHING_TO_INLINE") 30 | inline fun JFrame.listenResizes() { 31 | addComponentListener( 32 | object : ComponentAdapter() { 33 | override fun componentResized(event: ComponentEvent) { 34 | val frame = event.component as JFrame 35 | // same surgical procedure as in listenMaximization 36 | val isMaximized = frame.extendedState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH 37 | 38 | frame.size.let { newSize -> 39 | windowStateSettingsManager.updateSettings { current -> 40 | if (isMaximized) { 41 | current.copy( 42 | currentWidth = newSize.width, 43 | currentHeight = newSize.height, 44 | ) 45 | } else { 46 | current.copy( 47 | width = newSize.width, 48 | height = newSize.height, 49 | currentWidth = newSize.width, 50 | currentHeight = newSize.height, 51 | ) 52 | } 53 | } 54 | } 55 | } 56 | }, 57 | ) 58 | } 59 | 60 | @Suppress("NOTHING_TO_INLINE") 61 | inline fun JFrame.listenMaximization() { 62 | addWindowStateListener { event: WindowEvent -> 63 | // check if both bits of MAXIMIZED_BOTH are set 64 | val isMaximized = event.newState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH 65 | 66 | windowStateSettingsManager.updateSettings { 67 | it.copy( 68 | isWindowMaximized = isMaximized, 69 | ) 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/multiplayer/querymethod/items/QueryMethodCheckboxItem.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.screens.multiplayer.querymethod.items 20 | 21 | import androidx.compose.foundation.layout.Arrangement 22 | import androidx.compose.foundation.layout.Column 23 | import androidx.compose.foundation.layout.Row 24 | import androidx.compose.foundation.layout.fillMaxWidth 25 | import androidx.compose.material3.Checkbox 26 | import androidx.compose.material3.MaterialTheme 27 | import androidx.compose.material3.Text 28 | import androidx.compose.runtime.Composable 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.input.pointer.PointerIcon 32 | import androidx.compose.ui.input.pointer.pointerHoverIcon 33 | import androidx.compose.ui.unit.dp 34 | import com.spoiligaming.explorer.ui.screens.multiplayer.querymethod.QueryMethodCheckboxSpec 35 | import com.spoiligaming.explorer.ui.widgets.PocketInfoTooltip 36 | 37 | @Composable 38 | internal fun QueryMethodCheckboxItem( 39 | spec: QueryMethodCheckboxSpec, 40 | modifier: Modifier = Modifier, 41 | ) = Column( 42 | modifier = modifier, 43 | ) { 44 | Row( 45 | modifier = Modifier.fillMaxWidth(), 46 | verticalAlignment = Alignment.CenterVertically, 47 | horizontalArrangement = Arrangement.SpaceBetween, 48 | ) { 49 | Row( 50 | modifier = Modifier.weight(TITLE_WEIGHT), 51 | verticalAlignment = Alignment.CenterVertically, 52 | horizontalArrangement = Arrangement.spacedBy(TitleSpacing), 53 | ) { 54 | Text( 55 | text = spec.title, 56 | style = MaterialTheme.typography.labelLarge, 57 | color = MaterialTheme.colorScheme.onSurface, 58 | ) 59 | if (!spec.description.isNullOrBlank()) { 60 | PocketInfoTooltip( 61 | text = spec.description, 62 | minWidth = TooltipMinWidth, 63 | ) 64 | } 65 | } 66 | Checkbox( 67 | checked = spec.checked, 68 | onCheckedChange = spec.onCheckedChange, 69 | modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), 70 | ) 71 | } 72 | } 73 | 74 | private val TitleSpacing = 4.dp 75 | private val TooltipMinWidth = 296.dp 76 | private const val TITLE_WEIGHT = 1f 77 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/model/Preferences.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.model 20 | 21 | import com.spoiligaming.explorer.settings.serializer.LocaleSerializer 22 | import kotlinx.serialization.SerialName 23 | import kotlinx.serialization.Serializable 24 | import java.util.Locale 25 | 26 | @Serializable 27 | data class Preferences( 28 | @SerialName("locale") 29 | @Serializable(with = LocaleSerializer::class) 30 | val locale: Locale = determineDefaultLocale(), 31 | @SerialName("snackbar_at_top") 32 | val snackbarAtTop: Boolean = false, 33 | @SerialName("max_undo_history_size") 34 | val maxUndoHistorySize: Int = 25, 35 | @SerialName("undo_redo_repeat_initial_delay_ms") 36 | val undoRedoRepeatInitialDelayMillis: Long = 400, 37 | @SerialName("undo_redo_repeat_interval_ms") 38 | val undoRedoRepeatIntervalMillis: Long = 50, 39 | @SerialName("scroll_after_add") 40 | val scrollAfterAdd: Boolean = true, 41 | @SerialName("highlight_after_scroll") 42 | val highlightAfterScroll: Boolean = true, 43 | @SerialName("highlight_after_scroll_delay_ms") 44 | val highlightAfterScrollDelayMillis: Long = 300, 45 | @SerialName("vsync") 46 | val vsync: Boolean = true, 47 | @SerialName("show_fps_overlay") 48 | val showFpsOverlay: Boolean = false, 49 | @SerialName("window_title_show_build_info") 50 | val windowTitleShowBuildInfo: Boolean = false, 51 | @SerialName("scrollbar_always_visible") 52 | val settingsScrollbarAlwaysVisible: Boolean = false, 53 | @SerialName("nav_rail_items_centered") 54 | val navRailItemsCentered: Boolean = true, 55 | ) { 56 | companion object { 57 | /** 58 | * Returns the app's default locale, applying overrides for English regional variants 59 | * that typically follow British spelling. 60 | */ 61 | fun determineDefaultLocale(): Locale { 62 | val systemLocale = Locale.getDefault() 63 | 64 | return when (systemLocale.language to systemLocale.country) { 65 | "en" to "AU", 66 | "en" to "NZ", 67 | "en" to "CA", 68 | "en" to "IE", 69 | "en" to "ZA", 70 | "en" to "HK", 71 | "en" to "IN", 72 | -> Locale.UK 73 | 74 | else -> systemLocale 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/multiplayer/ServerSelectionController.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | @file:OptIn(ExperimentalUuidApi::class) 20 | 21 | package com.spoiligaming.explorer.ui.screens.multiplayer 22 | 23 | import kotlinx.coroutines.flow.MutableStateFlow 24 | import kotlinx.coroutines.flow.asStateFlow 25 | import kotlin.uuid.ExperimentalUuidApi 26 | import kotlin.uuid.Uuid 27 | 28 | internal class ServerSelectionController( 29 | private val onSelectionChanged: (() -> Unit)?, 30 | ) { 31 | private val _selectedIds = MutableStateFlow>(emptySet()) 32 | val selectedIds = _selectedIds.asStateFlow() 33 | 34 | private var anchorKey: Uuid? = null 35 | 36 | fun toggle(id: Uuid) { 37 | _selectedIds.value = _selectedIds.value.toggle(id) 38 | anchorKey = id 39 | onSelectionChanged?.invoke() 40 | } 41 | 42 | fun clear() { 43 | _selectedIds.value = emptySet() 44 | anchorKey = null 45 | onSelectionChanged?.invoke() 46 | } 47 | 48 | fun selectAll(allIds: Collection) { 49 | _selectedIds.value = allIds.toSet() 50 | anchorKey = allIds.firstOrNull() 51 | onSelectionChanged?.invoke() 52 | } 53 | 54 | fun handlePointerClick( 55 | id: Uuid, 56 | entries: List, 57 | ctrlMeta: Boolean, 58 | shift: Boolean, 59 | ) { 60 | val idx = entries.indexOf(id) 61 | val anchorIdx = anchorKey?.let { entries.indexOf(it) }?.takeIf { it >= 0 } 62 | 63 | when { 64 | // multi-select 65 | ctrlMeta -> toggle(id) 66 | 67 | // range-select 68 | shift && anchorIdx != null -> { 69 | val range = if (idx >= anchorIdx) anchorIdx..idx else idx..anchorIdx 70 | _selectedIds.value = range.map { entries[it] }.toSet() 71 | onSelectionChanged?.invoke() 72 | } 73 | 74 | // single-select 75 | else -> { 76 | _selectedIds.value = setOf(id) 77 | anchorKey = id 78 | onSelectionChanged?.invoke() 79 | } 80 | } 81 | } 82 | 83 | fun indicesOf(entries: List) = 84 | entries.mapIndexedNotNull { index, uuid -> 85 | if (uuid in _selectedIds.value) index else null 86 | } 87 | 88 | private fun Set.toggle(key: Uuid) = if (key in this) this - key else this + key 89 | } 90 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/model/WindowAppearence.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.model 20 | 21 | import com.spoiligaming.explorer.settings.model.WindowCornerPreferenceSetting.ELEVATED_SQUARE 22 | import com.spoiligaming.explorer.settings.model.WindowCornerPreferenceSetting.FLAT_SQUARE 23 | import com.spoiligaming.explorer.settings.model.WindowCornerPreferenceSetting.ROUNDED 24 | import com.spoiligaming.explorer.settings.model.WindowCornerPreferenceSetting.SYSTEM_DEFAULT 25 | import kotlinx.serialization.SerialName 26 | import kotlinx.serialization.Serializable 27 | 28 | /** 29 | * Represents the window corner styling preference, mapped to the underlying Windows DWM setting. 30 | * 31 | * These values correspond to how corners are rendered for top-level windows: 32 | * - [SYSTEM_DEFAULT] – Uses the system's default corner style (typically rounded on Windows 11). 33 | * - [ROUNDED] – Forces rounded corners. 34 | * - [ELEVATED_SQUARE] – Forces square corners with visual elevation (shadow). 35 | * - [FLAT_SQUARE] – Forces square corners without elevation, producing a flat appearance. 36 | * 37 | * Note: Rounded corners require Windows 11 (build 22000 or later). 38 | * 39 | * @see 40 | * DWM_WINDOW_CORNER_PREFERENCE – Windows API Documentation 41 | * 42 | */ 43 | @Serializable 44 | enum class WindowCornerPreferenceSetting( 45 | val dwmValue: Int, 46 | ) { 47 | @SerialName("system_default") 48 | SYSTEM_DEFAULT(0), // DWMWCP_DEFAULT 49 | 50 | @SerialName("rounded") 51 | ROUNDED(2), // DWMWCP_ROUND, 52 | 53 | @SerialName("square_elevated") 54 | ELEVATED_SQUARE(1), // DWMWCP_DONOTROUND 55 | 56 | @SerialName("square_flat") 57 | FLAT_SQUARE(3), // DWMWCP_ROUNDSMALL 58 | } 59 | 60 | @Serializable 61 | enum class TitleBarColorMode { 62 | @SerialName("auto") 63 | AUTO, 64 | 65 | @SerialName("manual") 66 | MANUAL, 67 | } 68 | 69 | @Serializable 70 | data class WindowAppearance( 71 | @SerialName("corner_preference") 72 | val windowCornerPreference: WindowCornerPreferenceSetting = SYSTEM_DEFAULT, 73 | @SerialName("titlebar_color_mode") 74 | val titleBarColorMode: TitleBarColorMode = TitleBarColorMode.AUTO, 75 | @SerialName("custom_title_bar_color") 76 | val customTitleBarColor: String = "#FFFFFF", 77 | @SerialName("use_custom_border_color") 78 | val useCustomBorderColor: Boolean = false, 79 | @SerialName("custom_border_color") 80 | val customBorderColor: String = "#FFFFFF", 81 | ) 82 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/util/AdaptiveDimension.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.util 20 | 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.derivedStateOf 23 | import androidx.compose.runtime.getValue 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.ui.platform.LocalDensity 26 | import androidx.compose.ui.unit.Dp 27 | import androidx.compose.ui.unit.dp 28 | import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalWindowState 29 | 30 | /** 31 | * Computes an adaptive width based on the current window dimensions, clamped between [min] and [max]. 32 | * 33 | * @param min The minimum width (default: 0.dp) 34 | * @param max The maximum width (default: Dp.Infinity) 35 | * @param fraction The fraction of the window width to use (default: 0.25f) 36 | * @return The clamped adaptive width in Dp. 37 | */ 38 | @Composable 39 | internal fun rememberAdaptiveWidth( 40 | min: Dp = DefaultMinDp, 41 | max: Dp = Dp.Infinity, 42 | fraction: Float = DEFAULT_ADAPTIVE_FRACTION, 43 | ): Dp { 44 | val windowState = LocalWindowState.current 45 | val density = LocalDensity.current 46 | 47 | val adaptive by remember(windowState, min, max, fraction) { 48 | derivedStateOf { 49 | val baseDp = with(density) { windowState.currentWidth.toDp() } 50 | val calculated = baseDp * fraction 51 | 52 | when { 53 | calculated < min -> min 54 | calculated > max -> max 55 | else -> calculated 56 | } 57 | } 58 | } 59 | 60 | return adaptive 61 | } 62 | 63 | /** 64 | * Computes an adaptive height based on the current window dimensions, clamped between [min] and [max]. 65 | * 66 | * @param min The minimum height (default: 0.dp) 67 | * @param max The maximum height (default: Dp.Infinity) 68 | * @param fraction The fraction of the window height to use (default: 0.25f) 69 | * @return The clamped adaptive height in Dp. 70 | */ 71 | @Composable 72 | internal fun rememberAdaptiveHeight( 73 | min: Dp = DefaultMinDp, 74 | max: Dp = Dp.Infinity, 75 | fraction: Float = DEFAULT_ADAPTIVE_FRACTION, 76 | ): Dp { 77 | val windowState = LocalWindowState.current 78 | val density = LocalDensity.current 79 | 80 | val adaptive by remember(windowState, min, max, fraction) { 81 | derivedStateOf { 82 | val baseDp = with(density) { windowState.currentHeight.toDp() } 83 | val calculated = baseDp * fraction 84 | 85 | calculated.coerceIn(min, max) 86 | } 87 | } 88 | 89 | return adaptive 90 | } 91 | 92 | private const val DEFAULT_ADAPTIVE_FRACTION = 0.25f 93 | private val DefaultMinDp = 0.dp 94 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/model/ServerQueryMethodConfigurations.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.model 20 | 21 | import kotlinx.serialization.SerialName 22 | import kotlinx.serialization.Serializable 23 | 24 | @Serializable 25 | data class ServerQueryMethodConfigurations( 26 | @SerialName("mc_srv_status") 27 | val mcSrvStat: McSrvStatusQueryConfiguration = McSrvStatusQueryConfiguration(), 28 | @SerialName("mc_utils") 29 | val mcUtils: McUtilsQueryConfiguration = McUtilsQueryConfiguration(), 30 | ) 31 | 32 | sealed interface QueryMethodRequestKey 33 | 34 | data class McSrvStatRequestKey( 35 | val connectTimeoutMillis: Long, 36 | val socketTimeoutMillis: Long, 37 | ) : QueryMethodRequestKey 38 | 39 | data class McUtilsRequestKey( 40 | val timeoutMillis: Long, 41 | val enableSrvLookups: Boolean, 42 | ) : QueryMethodRequestKey 43 | 44 | interface ServerQueryMethodConfig { 45 | val requestKey: QueryMethodRequestKey 46 | } 47 | 48 | @Serializable 49 | data class McSrvStatusQueryConfiguration( 50 | @SerialName("timeouts") 51 | val timeouts: McSrvStatusTimeouts = McSrvStatusTimeouts(), 52 | ) : ServerQueryMethodConfig { 53 | override val requestKey 54 | get() = 55 | McSrvStatRequestKey( 56 | connectTimeoutMillis = timeouts.connectionTimeoutMillis, 57 | socketTimeoutMillis = timeouts.responseTimeoutMillis, 58 | ) 59 | } 60 | 61 | @Serializable 62 | data class McUtilsQueryConfiguration( 63 | @SerialName("timeouts") 64 | val timeouts: McUtilsTimeouts = McUtilsTimeouts(), 65 | @SerialName("options") 66 | val options: McUtilsQueryOptions = McUtilsQueryOptions(), 67 | ) : ServerQueryMethodConfig { 68 | override val requestKey 69 | get() = 70 | McUtilsRequestKey( 71 | timeoutMillis = timeouts.timeoutMillis, 72 | enableSrvLookups = options.enableSrvLookups, 73 | ) 74 | } 75 | 76 | @Serializable 77 | data class McSrvStatusTimeouts( 78 | @SerialName("connection_timeout_ms") 79 | val connectionTimeoutMillis: Long = 120_000L, 80 | @SerialName("response_timeout_ms") 81 | val responseTimeoutMillis: Long = 15_000L, 82 | ) 83 | 84 | @Serializable 85 | data class McUtilsTimeouts( 86 | @SerialName("timeout_ms") 87 | val timeoutMillis: Long = 45_000L, 88 | ) 89 | 90 | @Serializable 91 | data class McUtilsQueryOptions( 92 | @SerialName("enable_srv") 93 | val enableSrvLookups: Boolean = true, 94 | ) 95 | 96 | fun ServerQueryMethodConfigurations.requestKeyFor(method: ServerQueryMethod): QueryMethodRequestKey = 97 | when (method) { 98 | ServerQueryMethod.McSrvStat -> mcSrvStat.requestKey 99 | ServerQueryMethod.McUtils -> mcUtils.requestKey 100 | } 101 | -------------------------------------------------------------------------------- /nbt/src/main/kotlin/com/spoiligaming/explorer/multiplayer/history/ServerListHistoryService.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.multiplayer.history 20 | 21 | import io.github.oshai.kotlinlogging.KotlinLogging 22 | import kotlinx.coroutines.flow.MutableStateFlow 23 | import kotlinx.coroutines.flow.asStateFlow 24 | import kotlinx.coroutines.sync.Mutex 25 | import kotlinx.coroutines.sync.withLock 26 | 27 | class ServerListHistoryService( 28 | private val maxUndoEntries: Int, 29 | ) { 30 | private val mutex = Mutex() 31 | private val undoStack = ArrayDeque() 32 | private val redoStack = ArrayDeque() 33 | 34 | private val _canUndo = MutableStateFlow(false) 35 | val canUndo = _canUndo.asStateFlow() 36 | 37 | private val _canRedo = MutableStateFlow(false) 38 | val canRedo = _canRedo.asStateFlow() 39 | 40 | private fun refreshFlags() { 41 | _canUndo.value = undoStack.isNotEmpty() 42 | _canRedo.value = redoStack.isNotEmpty() 43 | } 44 | 45 | suspend fun recordChange(change: ServerListChange) = 46 | mutex.withLock { 47 | undoStack.pushWithLimit(change, maxUndoEntries) 48 | redoStack.clear() 49 | refreshFlags() 50 | logger.debug { 51 | "Recorded change: ${change.description()} " + 52 | "(undo=${undoStack.size}, redo=${redoStack.size})" 53 | } 54 | } 55 | 56 | suspend fun undo(): ServerListChange? = 57 | mutex.withLock { 58 | val lastChange = undoStack.removeLastOrNull() ?: return null 59 | redoStack.pushWithLimit(lastChange, maxUndoEntries) 60 | logger.info { 61 | "Undo: ${lastChange.description()} " + 62 | "(undo=${undoStack.size}, redo=${redoStack.size})" 63 | } 64 | refreshFlags() 65 | lastChange 66 | } 67 | 68 | suspend fun redo(): ServerListChange? = 69 | mutex.withLock { 70 | val redoChange = redoStack.removeLastOrNull() ?: return null 71 | undoStack.pushWithLimit(redoChange, maxUndoEntries) 72 | logger.info { 73 | "Redo: ${redoChange.description()} " + 74 | "(undo=${undoStack.size}, redo=${redoStack.size})" 75 | } 76 | refreshFlags() 77 | redoChange 78 | } 79 | 80 | suspend fun clear() = 81 | mutex.withLock { 82 | undoStack.clear() 83 | redoStack.clear() 84 | refreshFlags() 85 | logger.debug { "Cleared all history" } 86 | } 87 | 88 | private fun ArrayDeque.pushWithLimit( 89 | element: T, 90 | limit: Int, 91 | ) { 92 | if (limit <= 0) return 93 | while (size >= limit) removeFirst() 94 | addLast(element) 95 | } 96 | } 97 | 98 | private val logger = KotlinLogging.logger {} 99 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/widgets/PocketInfoTooltip.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | @file:OptIn(ExperimentalMaterial3Api::class) 20 | 21 | package com.spoiligaming.explorer.ui.widgets 22 | 23 | import androidx.compose.foundation.layout.size 24 | import androidx.compose.foundation.layout.widthIn 25 | import androidx.compose.material.icons.Icons 26 | import androidx.compose.material.icons.outlined.Info 27 | import androidx.compose.material3.ExperimentalMaterial3Api 28 | import androidx.compose.material3.Icon 29 | import androidx.compose.material3.MaterialTheme 30 | import androidx.compose.material3.PlainTooltip 31 | import androidx.compose.material3.Text 32 | import androidx.compose.material3.TooltipAnchorPosition 33 | import androidx.compose.material3.TooltipBox 34 | import androidx.compose.material3.TooltipDefaults.rememberTooltipPositionProvider 35 | import androidx.compose.material3.rememberTooltipState 36 | import androidx.compose.runtime.Composable 37 | import androidx.compose.runtime.LaunchedEffect 38 | import androidx.compose.runtime.getValue 39 | import androidx.compose.runtime.mutableStateOf 40 | import androidx.compose.runtime.remember 41 | import androidx.compose.runtime.setValue 42 | import androidx.compose.ui.Modifier 43 | import androidx.compose.ui.input.pointer.PointerIcon 44 | import androidx.compose.ui.input.pointer.pointerHoverIcon 45 | import androidx.compose.ui.unit.Dp 46 | import androidx.compose.ui.unit.dp 47 | import com.spoiligaming.explorer.ui.extensions.onHover 48 | 49 | @Composable 50 | internal fun PocketInfoTooltip( 51 | text: String, 52 | modifier: Modifier = Modifier, 53 | minWidth: Dp = DefaultTooltipMinWidth, 54 | iconSize: Dp = DefaultIconSize, 55 | ) { 56 | val tooltipState = rememberTooltipState(isPersistent = true) 57 | var isHovered by remember { mutableStateOf(false) } 58 | 59 | LaunchedEffect(isHovered) { 60 | if (isHovered) { 61 | tooltipState.show() 62 | } else { 63 | tooltipState.dismiss() 64 | } 65 | } 66 | 67 | TooltipBox( 68 | positionProvider = rememberTooltipPositionProvider(DefaultTooltipAnchorPosition), 69 | state = tooltipState, 70 | tooltip = { 71 | PlainTooltip( 72 | modifier = Modifier.widthIn(min = minWidth), 73 | ) { 74 | Text(text) 75 | } 76 | }, 77 | modifier = 78 | modifier 79 | .onHover { isHovered = it } 80 | .pointerHoverIcon(PointerIcon.Hand), 81 | ) { 82 | Icon( 83 | imageVector = Icons.Outlined.Info, 84 | contentDescription = text, 85 | tint = MaterialTheme.colorScheme.onSurfaceVariant, 86 | modifier = Modifier.size(iconSize), 87 | ) 88 | } 89 | } 90 | 91 | private val DefaultTooltipMinWidth = 240.dp 92 | private val DefaultIconSize = 22.dp 93 | private val DefaultTooltipAnchorPosition = TooltipAnchorPosition.Above 94 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/multiplayer/querymethod/QueryMethodConfigurationTooltip.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | @file:OptIn(ExperimentalMaterial3Api::class) 20 | 21 | package com.spoiligaming.explorer.ui.screens.multiplayer.querymethod 22 | 23 | import androidx.compose.foundation.layout.Arrangement 24 | import androidx.compose.foundation.layout.Column 25 | import androidx.compose.foundation.layout.fillMaxWidth 26 | import androidx.compose.foundation.layout.padding 27 | import androidx.compose.material3.Card 28 | import androidx.compose.material3.ExperimentalMaterial3Api 29 | import androidx.compose.material3.HorizontalDivider 30 | import androidx.compose.material3.MaterialTheme 31 | import androidx.compose.material3.RichTooltip 32 | import androidx.compose.material3.Text 33 | import androidx.compose.material3.TooltipScope 34 | import androidx.compose.runtime.Composable 35 | import androidx.compose.ui.Modifier 36 | import androidx.compose.ui.unit.dp 37 | import com.spoiligaming.explorer.ui.screens.multiplayer.querymethod.items.QueryMethodCheckboxItem 38 | import com.spoiligaming.explorer.ui.screens.multiplayer.querymethod.items.QueryMethodTimeoutItem 39 | 40 | @Composable 41 | internal fun TooltipScope.QueryMethodConfigurationTooltip( 42 | title: String, 43 | items: List, 44 | modifier: Modifier = Modifier, 45 | ) = RichTooltip( 46 | title = { Text(text = title) }, 47 | text = { 48 | Card( 49 | modifier = modifier.padding(top = TooltipSpacingBetweenTitleAndCard), 50 | ) { 51 | Column( 52 | modifier = 53 | Modifier 54 | .fillMaxWidth() 55 | .padding(TooltipContentPadding), 56 | verticalArrangement = Arrangement.spacedBy(ItemSpacing), 57 | ) { 58 | items.forEachIndexed { index, item -> 59 | when (item) { 60 | is QueryMethodCheckboxSpec -> 61 | QueryMethodCheckboxItem( 62 | spec = item, 63 | modifier = Modifier.fillMaxWidth(), 64 | ) 65 | 66 | is TimeoutSliderSpec -> 67 | QueryMethodTimeoutItem( 68 | spec = item, 69 | modifier = Modifier.fillMaxWidth(), 70 | ) 71 | } 72 | 73 | if (index < items.lastIndex) { 74 | HorizontalDivider( 75 | thickness = DividerThickness, 76 | color = MaterialTheme.colorScheme.outlineVariant, 77 | ) 78 | } 79 | } 80 | } 81 | } 82 | }, 83 | ) 84 | 85 | private val TooltipSpacingBetweenTitleAndCard = 12.dp 86 | private val TooltipContentPadding = 12.dp 87 | private val ItemSpacing = 8.dp 88 | private val DividerThickness = 0.5.dp 89 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | # Core toolchain 3 | kotlin = "2.3.0" 4 | compose = "1.9.3" 5 | coroutines = "1.10.2" 6 | serialization = "1.9.0" 7 | 8 | # Build & tooling 9 | shadow = "9.3.0" 10 | buildConfig = "6.0.7" 11 | ktlint = "14.0.1" 12 | proguard = "7.8.2" 13 | licenseReport = "3.0.1" 14 | 15 | # UI & Compose ecosystem 16 | colorPicker = "0.7.0" 17 | fileKit = "0.12.0" 18 | materialKolor = "4.0.5" 19 | navigation-compose = "2.9.1" 20 | reorderable = "3.0.0" 21 | autolinktext = "2.0.2" 22 | shimmer = "1.3.3" 23 | 24 | # Networking 25 | ktor = "3.3.3" 26 | 27 | # Logging 28 | kotlin-logging = "7.0.13" 29 | log4j = "2.25.3" 30 | 31 | # System / native 32 | jna = "5.18.1" 33 | oshi = "6.9.2" 34 | themeDetector = "3.9.1" 35 | 36 | # Minecraft 37 | nbt = "6.1" 38 | mcUtils = "3.1.1" 39 | 40 | [libraries] 41 | # Core language/runtime 42 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 43 | compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } 44 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version.ref = "serialization" } 45 | kotlinx-coroutines-core-jvm = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version.ref = "coroutines" } 46 | 47 | # UI & Compose ecosystem 48 | autolinktext = { module = "sh.calvin.autolinktext:autolinktext", version.ref = "autolinktext" } 49 | navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" } 50 | composeShimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "shimmer" } 51 | materialKolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } 52 | color-picker = { module = "com.godaddy.android.colorpicker:compose-color-picker-jvm", version.ref = "colorPicker" } 53 | reorderable-jvm = { module = "sh.calvin.reorderable:reorderable-jvm", version.ref = "reorderable" } 54 | file-kit = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "fileKit" } 55 | 56 | # Networking 57 | ktor-client-core-jvm = { module = "io.ktor:ktor-client-core-jvm", version.ref = "ktor" } 58 | ktor-client-cio-jvm = { module = "io.ktor:ktor-client-cio-jvm", version.ref = "ktor" } 59 | ktor-client-mock-jvm = { module = "io.ktor:ktor-client-mock-jvm", version.ref = "ktor" } 60 | 61 | # Logging 62 | kotlin-logging = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "kotlin-logging" } 63 | log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" } 64 | log4j-slf4j2-impl = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j" } 65 | 66 | # System / native 67 | jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } 68 | jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } 69 | oshi-core = { module = "com.github.oshi:oshi-core", version.ref = "oshi" } 70 | theme-detector = { module = "com.github.Dansoftowner:jSystemThemeDetector", version.ref = "themeDetector" } 71 | 72 | # Minecraft 73 | nbt = { module = "com.github.Querz:NBT", version.ref = "nbt" } 74 | mcUtils = { module = "tech.aliorpse.mcutils:mcutils-server-status", version.ref = "mcUtils" } 75 | 76 | [plugins] 77 | # Kotlin toolchain 78 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 79 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 80 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 81 | 82 | # Compose & build tooling 83 | compose = { id = "org.jetbrains.compose", version.ref = "compose" } 84 | shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } 85 | buildConfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildConfig" } 86 | ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } 87 | dependencyLicenseReport = { id = "com.github.jk1.dependency-license-report", version.ref = "licenseReport" } -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/settings/components/SettingsSection.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.screens.settings.components 20 | 21 | import androidx.compose.foundation.background 22 | import androidx.compose.foundation.layout.Arrangement 23 | import androidx.compose.foundation.layout.Column 24 | import androidx.compose.foundation.layout.Spacer 25 | import androidx.compose.foundation.layout.fillMaxWidth 26 | import androidx.compose.foundation.layout.height 27 | import androidx.compose.material3.MaterialTheme 28 | import androidx.compose.material3.Surface 29 | import androidx.compose.material3.Text 30 | import androidx.compose.material3.surfaceColorAtElevation 31 | import androidx.compose.runtime.Composable 32 | import androidx.compose.ui.Modifier 33 | import androidx.compose.ui.unit.dp 34 | 35 | @Composable 36 | internal fun SettingsSection( 37 | header: String, 38 | settings: List<@Composable () -> Unit>, 39 | ) = Column(verticalArrangement = Arrangement.spacedBy(OuterArrangement)) { 40 | Text( 41 | text = header, 42 | color = MaterialTheme.colorScheme.onBackground, 43 | style = MaterialTheme.typography.titleLarge, 44 | ) 45 | 46 | Column(modifier = Modifier.fillMaxWidth()) { 47 | val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(SectionContainerElevation) 48 | val dividerColor = MaterialTheme.colorScheme.background 49 | 50 | val sectionOuterShape = MaterialTheme.shapes.large 51 | val sectionInnerShape = MaterialTheme.shapes.extraSmall 52 | val sectionTopShape = 53 | sectionInnerShape.copy( 54 | topStart = sectionOuterShape.topStart, 55 | topEnd = sectionOuterShape.topEnd, 56 | ) 57 | val sectionBottomShape = 58 | sectionInnerShape.copy( 59 | bottomStart = sectionOuterShape.bottomStart, 60 | bottomEnd = sectionOuterShape.bottomEnd, 61 | ) 62 | 63 | settings.forEachIndexed { index, setting -> 64 | val shape = 65 | when { 66 | settings.size == 1 -> sectionOuterShape 67 | index == 0 -> sectionTopShape 68 | index == settings.lastIndex -> sectionBottomShape 69 | else -> sectionInnerShape 70 | } 71 | 72 | Surface( 73 | modifier = Modifier.fillMaxWidth(), 74 | color = containerColor, 75 | tonalElevation = SectionContainerElevation, 76 | shape = shape, 77 | ) { 78 | setting() 79 | } 80 | 81 | if (index != settings.lastIndex) { 82 | Spacer( 83 | modifier = 84 | Modifier 85 | .fillMaxWidth() 86 | .height(SectionDividerSpacing) 87 | .background(dividerColor), 88 | ) 89 | } 90 | } 91 | } 92 | } 93 | 94 | private val OuterArrangement = 8.dp 95 | private val SectionContainerElevation = 8.dp 96 | private val SectionDividerSpacing = 4.dp 97 | -------------------------------------------------------------------------------- /settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/SettingsFile.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.settings.util 20 | 21 | import io.github.oshai.kotlinlogging.KotlinLogging 22 | import kotlinx.coroutines.Dispatchers 23 | import kotlinx.coroutines.withContext 24 | import kotlinx.serialization.KSerializer 25 | import kotlinx.serialization.json.Json 26 | import java.io.File 27 | 28 | internal class SettingsFile( 29 | private val fileName: String, 30 | private val serializer: KSerializer, 31 | private val defaultValueProvider: () -> T, 32 | ) { 33 | private val json = 34 | Json { 35 | prettyPrint = true 36 | encodeDefaults = true 37 | ignoreUnknownKeys = true 38 | } 39 | 40 | private val settingsFile 41 | get() = SettingsStorage.settingsDir.resolve(fileName) 42 | 43 | val lastModifiedMillis 44 | get() = settingsFile.takeIf { it.exists() }?.lastModified() 45 | 46 | suspend fun read() = 47 | withContext(Dispatchers.IO) { 48 | val dir = SettingsStorage.settingsDir 49 | val file = settingsFile 50 | 51 | logger.debug { "Attempting to read settings from: ${file.absolutePath}" } 52 | 53 | if (!file.exists()) { 54 | ensureDirExists(dir) 55 | 56 | val defaultObj = defaultValueProvider() 57 | file.writeText(json.encodeToString(serializer, defaultObj)) 58 | 59 | logger.info { "Created new settings file with default: $defaultObj" } 60 | return@withContext defaultObj 61 | } 62 | 63 | val raw = 64 | file.readText().also { 65 | logger.debug { "Read raw JSON: ${it.take(100).replace("\n", "\\n")}..." } 66 | } 67 | 68 | check(raw.isNotBlank()) { 69 | "SettingsFile.read(): existing file is empty: ${file.absolutePath}" 70 | } 71 | 72 | val loaded = json.decodeFromString(serializer, raw) 73 | logger.info { "Loaded settings from disk: $loaded" } 74 | loaded 75 | } 76 | 77 | suspend fun write( 78 | data: T, 79 | onComplete: (() -> Unit)? = null, 80 | ) = withContext(Dispatchers.IO) { 81 | val dir = SettingsStorage.settingsDir 82 | val file = settingsFile 83 | 84 | ensureDirExists(dir) 85 | 86 | val serialized = json.encodeToString(serializer, data) 87 | file.writeText(serialized) 88 | logger.debug { "Saved settings to disk: $data" } 89 | onComplete?.invoke() 90 | } 91 | 92 | private fun ensureDirExists(dir: File) { 93 | if (!dir.exists()) { 94 | dir.mkdirs().also { created -> 95 | if (created) { 96 | logger.info { "Created settings directory: ${dir.absolutePath}" } 97 | } else { 98 | logger.warn { "Failed to create settings directory: ${dir.absolutePath}" } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | private val logger = KotlinLogging.logger {} 106 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/multiplayer/querymethod/items/QueryMethodTimeoutItem.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.screens.multiplayer.querymethod.items 20 | 21 | import androidx.compose.foundation.layout.Arrangement 22 | import androidx.compose.foundation.layout.Column 23 | import androidx.compose.foundation.layout.Row 24 | import androidx.compose.foundation.layout.fillMaxWidth 25 | import androidx.compose.material3.MaterialTheme 26 | import androidx.compose.material3.Slider 27 | import androidx.compose.material3.Text 28 | import androidx.compose.runtime.Composable 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.unit.dp 32 | import com.spoiligaming.explorer.ui.screens.multiplayer.querymethod.TimeoutSliderSpec 33 | import com.spoiligaming.explorer.ui.t 34 | import com.spoiligaming.explorer.ui.widgets.DebouncedSlider 35 | import com.spoiligaming.explorer.ui.widgets.PocketInfoTooltip 36 | import com.spoiligaming.explorer.ui.widgets.SliderValueAdapters 37 | import server_list_explorer.ui.generated.resources.Res 38 | import server_list_explorer.ui.generated.resources.query_method_timeout_preview_seconds 39 | 40 | @Composable 41 | internal fun QueryMethodTimeoutItem( 42 | spec: TimeoutSliderSpec, 43 | modifier: Modifier = Modifier, 44 | ) = DebouncedSlider( 45 | value = spec.valueSeconds, 46 | onValueChange = spec.onValueChangeSeconds, 47 | valueRange = spec.valueRangeSeconds, 48 | adapter = SliderValueAdapters.IntAdapter, 49 | ) { sliderPosition, onSliderPositionChange, previewSeconds -> 50 | Column( 51 | modifier = modifier, 52 | ) { 53 | Row( 54 | modifier = Modifier.fillMaxWidth(), 55 | verticalAlignment = Alignment.CenterVertically, 56 | horizontalArrangement = Arrangement.SpaceBetween, 57 | ) { 58 | Row( 59 | modifier = Modifier.weight(TITLE_WEIGHT), 60 | verticalAlignment = Alignment.CenterVertically, 61 | horizontalArrangement = Arrangement.spacedBy(TitleSpacing), 62 | ) { 63 | Text( 64 | text = spec.title, 65 | style = MaterialTheme.typography.labelLarge, 66 | color = MaterialTheme.colorScheme.onSurface, 67 | ) 68 | if (!spec.description.isNullOrBlank()) { 69 | PocketInfoTooltip( 70 | text = spec.description, 71 | minWidth = TooltipMinWidth, 72 | ) 73 | } 74 | } 75 | Text( 76 | text = t(Res.string.query_method_timeout_preview_seconds, previewSeconds), 77 | style = MaterialTheme.typography.labelMedium, 78 | color = MaterialTheme.colorScheme.onSurfaceVariant, 79 | ) 80 | } 81 | Slider( 82 | value = sliderPosition, 83 | onValueChange = onSliderPositionChange, 84 | valueRange = spec.valueRangeSeconds, 85 | steps = SLIDER_STEPS, 86 | modifier = Modifier.fillMaxWidth(), 87 | ) 88 | } 89 | } 90 | 91 | private val TitleSpacing = 4.dp 92 | private val TooltipMinWidth = 296.dp 93 | private const val TITLE_WEIGHT = 1f 94 | private const val SLIDER_STEPS = 0 95 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/components/MarkdownAutoLinkText.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.components 20 | 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.Immutable 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.ui.text.AnnotatedString 25 | import androidx.compose.ui.text.TextLinkStyles 26 | import sh.calvin.autolinktext.SimpleTextMatchResult 27 | import sh.calvin.autolinktext.TextMatcher 28 | import sh.calvin.autolinktext.TextRule 29 | import sh.calvin.autolinktext.TextRuleDefaults 30 | import sh.calvin.autolinktext.rememberAutoLinkText 31 | 32 | @Composable 33 | internal fun rememberAutoLinkMarkdownAnnotatedString( 34 | text: String, 35 | parseMarkdownLinks: Boolean = true, 36 | includeDefaultAutoLinks: Boolean = true, 37 | defaultLinkStyles: TextLinkStyles, 38 | markdownLinkStyles: TextLinkStyles = defaultLinkStyles, 39 | ): AnnotatedString { 40 | val parsed = 41 | remember(text, parseMarkdownLinks) { 42 | if (parseMarkdownLinks) parseMarkdownLinks(text) else MarkdownParseResult(text, emptyList()) 43 | } 44 | 45 | val markdownRule = 46 | remember(parsed.matches, markdownLinkStyles) { 47 | TextRule.Url( 48 | textMatcher = TextMatcher.FunctionMatcher { parsed.matches }, 49 | styles = markdownLinkStyles, 50 | urlProvider = { it.data }, 51 | ) 52 | } 53 | 54 | val defaultRules = if (includeDefaultAutoLinks) TextRuleDefaults.defaultList() else emptyList() 55 | 56 | val rules = 57 | remember(markdownRule, includeDefaultAutoLinks, defaultRules) { 58 | buildList> { 59 | add(markdownRule) 60 | addAll(defaultRules) 61 | } 62 | } 63 | 64 | return AnnotatedString.rememberAutoLinkText( 65 | text = parsed.displayText, 66 | textRules = rules.asAnyRules(), 67 | defaultLinkStyles = defaultLinkStyles, 68 | ) 69 | } 70 | 71 | private fun parseMarkdownLinks(input: String): MarkdownParseResult { 72 | val out = StringBuilder(input.length) 73 | val matches = ArrayList>() 74 | 75 | var lastIndex = 0 76 | for (m in MarkdownLinkRegex.findAll(input)) { 77 | out.append(input, lastIndex, m.range.first) 78 | 79 | val label = m.groupValues[1] 80 | val url = m.groupValues[2] 81 | 82 | val start = out.length 83 | out.append(label) 84 | val end = out.length 85 | 86 | matches += SimpleTextMatchResult(start, end, url) 87 | 88 | lastIndex = m.range.last + 1 89 | } 90 | 91 | if (lastIndex < input.length) out.append(input, lastIndex, input.length) 92 | 93 | return MarkdownParseResult( 94 | displayText = out.toString(), 95 | matches = matches, 96 | ) 97 | } 98 | 99 | @Immutable 100 | private data class MarkdownParseResult( 101 | val displayText: String, 102 | val matches: List>, 103 | ) 104 | 105 | @Suppress("UNCHECKED_CAST") 106 | private fun Collection>.asAnyRules() = this as Collection> 107 | 108 | private val MarkdownLinkRegex = Regex("""\[(.+?)]\((https?://[^\s)]+)\)""") 109 | -------------------------------------------------------------------------------- /core/src/main/kotlin/com/spoiligaming/explorer/minecraft/multiplayer/online/backend/mcsrvstat/McSrvStatServerStatusResponse.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.minecraft.multiplayer.online.backend.mcsrvstat 20 | 21 | import kotlinx.serialization.SerialName 22 | import kotlinx.serialization.Serializable 23 | 24 | @Serializable 25 | internal data class McSrvStatServerStatusResponse( 26 | val ip: String, 27 | val port: Long, 28 | val debug: DebugInfo, 29 | val motd: Motd, 30 | val players: Players, 31 | @SerialName("version") 32 | val versionInfo: String, 33 | val online: Boolean, 34 | val protocol: Protocol, 35 | val hostname: String, 36 | @SerialName("icon") 37 | val rawIcon: String, 38 | val software: String? = null, 39 | val info: Info? = null, 40 | @SerialName("eula_blocked") 41 | val eulaBlocked: Boolean, 42 | ) { 43 | val icon 44 | get() = rawIcon.removePrefix("data:image/png;base64,") 45 | } 46 | 47 | @Serializable 48 | internal data class DebugInfo( 49 | val ping: Boolean, 50 | val query: Boolean, 51 | val bedrock: Boolean, 52 | val srv: Boolean, 53 | val querymismatch: Boolean, 54 | val ipinsrv: Boolean, 55 | val cnameinsrv: Boolean, 56 | val animatedmotd: Boolean, 57 | val cachehit: Boolean, 58 | val cachetime: Long, 59 | val cacheexpire: Long, 60 | val apiversion: Long, 61 | val dns: DnsInfo? = null, 62 | val error: QueryError? = null, 63 | ) 64 | 65 | @Serializable 66 | internal data class DnsInfo( 67 | @SerialName("srv") 68 | val srv: List = emptyList(), 69 | @SerialName("srv_a") 70 | val srvA: List = emptyList(), 71 | ) 72 | 73 | @Serializable 74 | internal data class DnsRecord( 75 | val name: String? = null, 76 | val type: String? = null, 77 | @SerialName("class") 78 | val recordClass: String? = null, 79 | val ttl: Long? = null, 80 | val rdlength: Long? = null, 81 | val rdata: String? = null, 82 | val priority: Long? = null, 83 | val weight: Long? = null, 84 | val port: Long? = null, 85 | val target: String? = null, 86 | val typecovered: String? = null, 87 | val algorithm: Long? = null, 88 | val labels: Long? = null, 89 | val origttl: Long? = null, 90 | val sigexp: String? = null, 91 | val sigincep: String? = null, 92 | val keytag: Long? = null, 93 | val signname: String? = null, 94 | val signature: String? = null, 95 | val cname: String? = null, 96 | val address: String? = null, 97 | ) 98 | 99 | @Serializable 100 | internal data class QueryError( 101 | val query: String, 102 | ) 103 | 104 | @Serializable 105 | internal data class Motd( 106 | val raw: List = emptyList(), 107 | val clean: List = emptyList(), 108 | val html: List = emptyList(), 109 | ) 110 | 111 | @Serializable 112 | internal data class Players( 113 | val online: Long, 114 | val max: Long, 115 | ) 116 | 117 | @Serializable 118 | internal data class Protocol( 119 | val version: Long, 120 | val name: String? = null, 121 | ) 122 | 123 | @Serializable 124 | internal data class Info( 125 | val raw: List = emptyList(), 126 | val clean: List = emptyList(), 127 | val html: List = emptyList(), 128 | ) 129 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/multiplayer/querymethod/QueryMethodChipPresets.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.screens.multiplayer.querymethod 20 | 21 | import androidx.compose.material.icons.Icons 22 | import androidx.compose.material.icons.outlined.Verified 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.ui.graphics.vector.ImageVector 25 | import com.spoiligaming.explorer.settings.model.ServerQueryMethod 26 | import com.spoiligaming.explorer.ui.t 27 | import org.jetbrains.compose.resources.StringResource 28 | import server_list_explorer.ui.generated.resources.Res 29 | import server_list_explorer.ui.generated.resources.query_method_chip_api_cached 30 | import server_list_explorer.ui.generated.resources.query_method_chip_editors_choice 31 | import server_list_explorer.ui.generated.resources.query_method_chip_editors_choice_badge 32 | import server_list_explorer.ui.generated.resources.query_method_chip_gs4_query_udp 33 | import server_list_explorer.ui.generated.resources.query_method_chip_hosted_api 34 | import server_list_explorer.ui.generated.resources.query_method_chip_srv_support 35 | 36 | private val EditorsChoicePreset = 37 | ChipPreset( 38 | label = Res.string.query_method_chip_editors_choice, 39 | icon = 40 | ChipPresetIcon( 41 | vector = Icons.Outlined.Verified, 42 | contentDescription = Res.string.query_method_chip_editors_choice_badge, 43 | ), 44 | colors = { QueryMethodChipStyles.green(1f) }, 45 | ) 46 | 47 | private val SrvSupportPreset = 48 | ChipPreset( 49 | label = Res.string.query_method_chip_srv_support, 50 | colors = { QueryMethodChipStyles.common() }, 51 | ) 52 | 53 | private val QueryUdpPreset = 54 | ChipPreset( 55 | label = Res.string.query_method_chip_gs4_query_udp, 56 | colors = { QueryMethodChipStyles.green(0.8f) }, 57 | ) 58 | 59 | private val HostedApiPreset = 60 | ChipPreset( 61 | label = Res.string.query_method_chip_hosted_api, 62 | colors = { QueryMethodChipStyles.blue(0.8f) }, 63 | ) 64 | 65 | private val ApiCachedPreset = 66 | ChipPreset( 67 | label = Res.string.query_method_chip_api_cached, 68 | colors = { QueryMethodChipStyles.yellow(0.5f) }, 69 | ) 70 | 71 | @Composable 72 | internal fun queryMethodChipsFor(method: ServerQueryMethod) = 73 | when (method) { 74 | ServerQueryMethod.McSrvStat -> 75 | listOf( 76 | HostedApiPreset, 77 | ApiCachedPreset, 78 | QueryUdpPreset, 79 | SrvSupportPreset, 80 | ) 81 | 82 | ServerQueryMethod.McUtils -> 83 | listOf( 84 | EditorsChoicePreset, 85 | SrvSupportPreset, 86 | ) 87 | }.map { preset -> 88 | val icon = 89 | preset.icon?.let { iconPreset -> 90 | QueryMethodChip.ChipIcon( 91 | vector = iconPreset.vector, 92 | contentDescription = t(iconPreset.contentDescription), 93 | ) 94 | } 95 | QueryMethodChip( 96 | label = t(preset.label), 97 | icon = icon, 98 | colors = preset.colors(), 99 | ) 100 | } 101 | 102 | private data class ChipPreset( 103 | val label: StringResource, 104 | val icon: ChipPresetIcon? = null, 105 | val colors: @Composable () -> QueryMethodChipColors, 106 | ) 107 | 108 | private data class ChipPresetIcon( 109 | val vector: ImageVector, 110 | val contentDescription: StringResource, 111 | ) 112 | -------------------------------------------------------------------------------- /ui/src/main/kotlin/com/spoiligaming/explorer/ui/window/WindowEffects.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Server List Explorer. 3 | * Copyright (C) 2025 SpoilerRules 4 | * 5 | * Server List Explorer is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * Server List Explorer is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with Server List Explorer. If not, see . 17 | */ 18 | 19 | package com.spoiligaming.explorer.ui.window 20 | 21 | import androidx.compose.material3.MaterialTheme 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.LaunchedEffect 24 | import androidx.compose.runtime.State 25 | import androidx.compose.runtime.collectAsState 26 | import androidx.compose.runtime.getValue 27 | import androidx.compose.runtime.mutableStateOf 28 | import androidx.compose.runtime.remember 29 | import androidx.compose.runtime.rememberUpdatedState 30 | import androidx.compose.runtime.setValue 31 | import com.spoiligaming.explorer.settings.manager.windowAppearanceSettingsManager 32 | import com.spoiligaming.explorer.settings.model.TitleBarColorMode 33 | import com.spoiligaming.explorer.ui.extensions.toComposeColor 34 | import com.spoiligaming.explorer.ui.theme.AppTheme 35 | import com.spoiligaming.explorer.ui.window.dwm.dwmStyler 36 | import com.spoiligaming.explorer.util.OSUtils 37 | import kotlinx.coroutines.delay 38 | import javax.swing.JFrame 39 | 40 | internal class WindowEffects( 41 | private val window: JFrame, 42 | ) { 43 | companion object { 44 | private const val DEBOUNCE_MILLIS = 25L 45 | } 46 | 47 | @Composable 48 | fun applyEffects() = 49 | AppTheme { 50 | val windowAppearance by windowAppearanceSettingsManager.settingsFlow.collectAsState() 51 | 52 | val background = MaterialTheme.colorScheme.surface 53 | val outline = MaterialTheme.colorScheme.outlineVariant 54 | val windowCornerPreference = windowAppearance.windowCornerPreference.dwmValue 55 | 56 | val debouncedBackground by rememberDebouncedState(background, DEBOUNCE_MILLIS) 57 | val debouncedOutline by rememberDebouncedState(outline, DEBOUNCE_MILLIS) 58 | 59 | window.dwmStyler().apply { 60 | LaunchedEffect( 61 | windowAppearance.titleBarColorMode, 62 | windowAppearance.customTitleBarColor, 63 | debouncedBackground, 64 | ) { 65 | when (windowAppearance.titleBarColorMode) { 66 | TitleBarColorMode.AUTO -> { 67 | setCaptionColor(debouncedBackground) 68 | } 69 | 70 | TitleBarColorMode.MANUAL -> { 71 | setCaptionColor(windowAppearance.customTitleBarColor.toComposeColor()) 72 | } 73 | } 74 | } 75 | LaunchedEffect( 76 | windowAppearance.useCustomBorderColor, 77 | windowAppearance.customBorderColor, 78 | debouncedOutline, 79 | ) { 80 | if (windowAppearance.useCustomBorderColor) { 81 | setBorderColor(windowAppearance.customBorderColor.toComposeColor()) 82 | } else { 83 | setBorderColor(debouncedOutline) 84 | } 85 | } 86 | LaunchedEffect(windowCornerPreference) { 87 | if (OSUtils.supportsDwmCornerPreference) { 88 | setCornerPreference(windowCornerPreference) 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | @Composable 96 | private fun rememberDebouncedState( 97 | value: T, 98 | debounceMillis: Long, 99 | ): State { 100 | var debouncedValue by remember { mutableStateOf(value) } 101 | 102 | LaunchedEffect(value) { 103 | delay(debounceMillis) 104 | debouncedValue = value 105 | } 106 | 107 | return rememberUpdatedState(debouncedValue) 108 | } 109 | -------------------------------------------------------------------------------- /ui/src/main/composeResources/drawable/flag_us.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 9 | 10 | 12 | 14 | 15 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 57 | --------------------------------------------------------------------------------