diff --git a/src/main/java/org/polyfrost/polyplus/mixin/client/cosmetics/MixinAbstractClientPlayer.java b/src/main/java/org/polyfrost/polyplus/mixin/client/cosmetics/MixinAbstractClientPlayer.java index e1c2b9f2..95b617b0 100644 --- a/src/main/java/org/polyfrost/polyplus/mixin/client/cosmetics/MixinAbstractClientPlayer.java +++ b/src/main/java/org/polyfrost/polyplus/mixin/client/cosmetics/MixinAbstractClientPlayer.java @@ -2,9 +2,10 @@ //? if > 1.8.9 { import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.mojang.authlib.GameProfile; import net.minecraft.client.multiplayer.PlayerInfo; import net.minecraft.client.player.AbstractClientPlayer; -import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache; +import org.polyfrost.polyplus.client.cosmetics.CapeResolver; import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment; import org.polyfrost.polyplus.client.cosmetics.access.PlayerCosmeticsAccess; import org.polyfrost.polyplus.client.cosmetics.access.PlayerEmotesAccess; @@ -17,7 +18,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; //? if >= 1.21.10 { -import net.minecraft.core.ClientAsset; import net.minecraft.world.entity.player.PlayerSkin; //?} @@ -25,7 +25,7 @@ /*import net.minecraft.client.resources.PlayerSkin; *///?} -@Mixin(AbstractClientPlayer.class) +@Mixin(value = AbstractClientPlayer.class, priority = 1500) public abstract class MixinAbstractClientPlayer implements PlayerEmotesAccess, PlayerCosmeticsAccess { @Shadow private PlayerInfo playerInfo; @@ -67,50 +67,37 @@ public abstract class MixinAbstractClientPlayer implements PlayerEmotesAccess, P return original; } + GameProfile profile = this.playerInfo.getProfile(); //~ if >= 1.21.10 'getId' -> 'id' - var capeLocation = CosmeticAssetCache.getCapeTexture(this.playerInfo.getProfile().id()); - if (capeLocation == null) { - return original; - } - - return new PlayerSkin( - // FIXME: how to simplify this - //? if >= 1.21.10 { - original.body(), - new ClientAsset.ResourceTexture(capeLocation), - original.elytra(), - original.model(), - original.secure() - //?} else { - /*original.texture(), - original.textureUrl(), - capeLocation, - original.elytraTexture(), - original.model(), - original.secure() - *///?} - ); + return CapeResolver.applyCape(profile.id(), profile, original); } } //?} else { -/*import com.mojang.authlib.GameProfile; +/*import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.mojang.authlib.GameProfile; import net.minecraft.client.entity.living.player.ClientPlayerEntity; +import net.minecraft.client.network.PlayerInfo; import net.minecraft.entity.living.player.PlayerEntity; import net.minecraft.resources.Identifier; import net.minecraft.world.World; import org.polyfrost.polyplus.client.PolyPlusConfig; +import org.polyfrost.polyplus.client.cosmetics.CapeResolver; import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache; import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment; +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService; import org.polyfrost.polyplus.client.cosmetics.access.PlayerCosmeticsAccess; import org.polyfrost.polyplus.client.cosmetics.access.PlayerEmotesAccess; import org.polyfrost.polyplus.client.emotes.playback.EmoteController; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -@Mixin(ClientPlayerEntity.class) +import java.util.UUID; + +@Mixin(value = ClientPlayerEntity.class, priority = 1500) public abstract class MixinAbstractClientPlayer extends PlayerEntity implements PlayerEmotesAccess, PlayerCosmeticsAccess { @Unique private EmoteController polyplus$emoteController; @@ -122,6 +109,9 @@ private MixinAbstractClientPlayer(World world, GameProfile profile) { super(world, profile); } + @Shadow + protected abstract PlayerInfo getInfo(); + @Override public EmoteController polyplus$emoteController() { EmoteController controller = polyplus$emoteController; @@ -148,10 +138,27 @@ public void tick() { polyplus$emoteController().tick((ClientPlayerEntity) (Object) this); } - @Inject(method = "getCapeTextureLocation", at = @At("HEAD"), cancellable = true) - private void polyplus$replaceCapeTexture(CallbackInfoReturnable cir) { - Identifier cape = CosmeticAssetCache.getCapeTexture(((ClientPlayerEntity) (Object) this).getUuid()); - if (cape != null) cir.setReturnValue(cape); + @ModifyReturnValue(method = "getCapeTextureLocation", at = @At("RETURN")) + private Identifier polyplus$replaceCapeTexture(Identifier original) { + UUID playerId = ((ClientPlayerEntity) (Object) this).getUuid(); + switch (CapeResolver.decide(playerId, original == null ? null : original.getNamespace())) { + case POLYPLUS: + return polyplus$orOriginal(CosmeticAssetCache.getCapeTexture(playerId), original); + case MOJANG: + return polyplus$orOriginal(MojangCapeService.localCapeTexture(), original); + case VANILLA: + PlayerInfo info = getInfo(); + return info == null ? null : info.getCapeTexture(); + case NONE: + return null; + default: + return original; + } + } + + @Unique + private static Identifier polyplus$orOriginal(Identifier cape, Identifier original) { + return cape != null ? cape : original; } @Inject(method = "getFovModifier", at = @At("HEAD"), cancellable = true) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt index f84b040e..b846b524 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt @@ -15,6 +15,7 @@ import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind import org.polyfrost.polyplus.BackendUrl import org.polyfrost.polyplus.PolyPlusConstants import org.polyfrost.polyplus.client.emotes.EmoteWheelKeybind +import org.polyfrost.polyplus.client.gui.PolyPlusOneConfigIntegration import org.polyfrost.polyplus.client.network.websocket.PolyConnection import org.polyfrost.polyplus.client.privacy.PrivacyEnforcement import org.polyfrost.polyplus.client.social.SocialOverlay @@ -224,6 +225,18 @@ object PolyPlusConfig : Config("${PolyPlusConstants.ID}.json", "${PolyPlusConsta true } + @Keybind( + title = "Open Wardrobe", + subcategory = "Keybinds", + description = "Open the PolyPlus wardrobe", + ) + var wardrobeKeybind = OneConfigKeybind(null, null, KeyModifiers.NONE, 0L) { state -> + if (state) { + PolyPlusOneConfigIntegration.openCosmeticsFromKeybind() + } + true + } + @Dropdown(title = "API URL", description = "The URL used for the PolyPlus API. Only change if you know what you're doing.") var apiUrl: BackendUrl = BackendUrl.PRODUCTION get() = if (PolyPlusConstants.IS_DEV_ENV) field else BackendUrl.PRODUCTION diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusCosmeticsConfig.kt b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusCosmeticsConfig.kt index e8647995..7f5af864 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusCosmeticsConfig.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusCosmeticsConfig.kt @@ -36,6 +36,13 @@ object PolyPlusCosmeticsConfig : Config( ) var hideFeetCosmeticsWithBoots = true + @JvmStatic + @Switch( + title = "Force PolyPlus Capes", + description = "Show the capes picked in the wardrobe even when another mod sets its own cape.", + ) + var forcePolyPlusCapes = false + @JvmStatic @Dropdown( title = "Capes", diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusMainMenuConfig.kt b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusMainMenuConfig.kt index 535dfd54..04dc1705 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusMainMenuConfig.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusMainMenuConfig.kt @@ -73,8 +73,8 @@ object PolyPlusMainMenuConfig : Config( @JvmStatic @Switch( - title = "Hide Cosmetics Button", - description = "Hide the Cosmetics button on the PolyPlus main menu.", + title = "Hide Wardrobe Button", + description = "Hide the Wardrobe button on the PolyPlus main menu.", subcategory = "Elements", ) var hideMainMenuCosmetics = false diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt new file mode 100644 index 00000000..57e9ec5d --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CapeResolver.kt @@ -0,0 +1,111 @@ +package org.polyfrost.polyplus.client.cosmetics + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import net.fabricmc.loader.api.FabricLoader +import org.polyfrost.polyplus.PolyPlusConstants +import org.polyfrost.polyplus.client.PolyPlusCosmeticsConfig +import org.polyfrost.polyplus.client.utils.ClientPlatform +import java.util.UUID + +//? if > 1.8.9 { +import com.mojang.authlib.GameProfile +import net.minecraft.client.Minecraft +import net.minecraft.resources.Identifier +//?} +//? if >= 1.21.10 { +import net.minecraft.core.ClientAsset +import net.minecraft.world.entity.player.PlayerSkin +//?} elif > 1.8.9 { +/*import net.minecraft.client.resources.PlayerSkin +*///?} + +enum class CapeDecision { + KEEP, + POLYPLUS, + MOJANG, + VANILLA, + NONE, +} + +object CapeResolver { + private const val VANILLA_NAMESPACE = "minecraft" + + var localOverrideModId by mutableStateOf(null) + private set + + val localOverrideModName: String? + get() = localOverrideModId?.let { id -> + FabricLoader.getInstance().getModContainer(id).map { it.metadata.name }.orElse(id) + } + + @JvmStatic + fun decide(playerId: UUID, currentNamespace: String?): CapeDecision { + val foreign = currentNamespace != null && + currentNamespace != VANILLA_NAMESPACE && + currentNamespace != PolyPlusConstants.ID + val local = isLocal(playerId) + if (local) { + val modId = if (foreign) currentNamespace else null + if (localOverrideModId != modId) localOverrideModId = modId + } + + if (foreign && !PolyPlusCosmeticsConfig.forcePolyPlusCapes) return CapeDecision.KEEP + if (CosmeticAssetCache.getCapeTexture(playerId) != null) return CapeDecision.POLYPLUS + + val mojangKnown = MojangCapeService.overridesLocalCape(playerId) || + (foreign && local && MojangCapeService.isLoaded()) + if (mojangKnown) { + if (MojangCapeService.localCapeHidden()) return CapeDecision.NONE + if (MojangCapeService.localCapeTexture() != null) return CapeDecision.MOJANG + } + return if (foreign && local) CapeDecision.VANILLA else CapeDecision.KEEP + } + + //? if > 1.8.9 { + @JvmStatic + fun applyCape(playerId: UUID, profile: GameProfile, original: PlayerSkin): PlayerSkin = + when (decide(playerId, capeNamespace(original))) { + CapeDecision.POLYPLUS -> withCape(original, CosmeticAssetCache.getCapeTexture(playerId)) + CapeDecision.MOJANG -> withCape(original, MojangCapeService.localCapeTexture()) + CapeDecision.VANILLA -> withCapeFrom(original, vanillaSkin(profile)) + CapeDecision.NONE -> withCapeFrom(original, null) + CapeDecision.KEEP -> original + } + + private fun vanillaSkin(profile: GameProfile): PlayerSkin? = runCatching { + //? if >= 1.21.10 { + Minecraft.getInstance().skinManager.createLookup(profile, false).get() + //?} else { + /*Minecraft.getInstance().skinManager.getInsecureSkin(profile) + *///?} + }.getOrNull() + + private fun capeNamespace(skin: PlayerSkin): String? = + //? if >= 1.21.10 { + skin.cape()?.texturePath()?.namespace + //?} else { + /*skin.capeTexture()?.namespace + *///?} + + private fun withCape(original: PlayerSkin, cape: Identifier?): PlayerSkin { + if (cape == null) return original + //? if >= 1.21.10 { + return PlayerSkin(original.body(), ClientAsset.ResourceTexture(cape), original.elytra(), original.model(), original.secure()) + //?} else { + /*return PlayerSkin(original.texture(), original.textureUrl(), cape, original.elytraTexture(), original.model(), original.secure()) + *///?} + } + + private fun withCapeFrom(original: PlayerSkin, source: PlayerSkin?): PlayerSkin = + //? if >= 1.21.10 { + PlayerSkin(original.body(), source?.cape(), original.elytra(), original.model(), original.secure()) + //?} else { + /*PlayerSkin(original.texture(), original.textureUrl(), source?.capeTexture(), original.elytraTexture(), original.model(), original.secure()) + *///?} + //?} + + private fun isLocal(playerId: UUID): Boolean = + runCatching { ClientPlatform.localPlayerUuid() }.getOrNull() == playerId +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticAssetCache.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticAssetCache.kt index d3768639..3b76e3d4 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticAssetCache.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticAssetCache.kt @@ -458,6 +458,17 @@ object CosmeticAssetCache { CosmeticType.Emote -> "emote-$id" else -> "cosmetic-$id" } + + fun primaryTextureFile(id: Int): File? { + val definition = CosmeticCatalog.getCosmeticDefinition(id) ?: return null + val dir = baseDir.resolve(definition.cacheKey()) + if (!dir.isDirectory) return null + return dir.walkTopDown() + .filter { it.isFile && !it.name.startsWith(APPLE_DOUBLE_PREFIX) && it.extension.equals("png", ignoreCase = true) } + .sortedBy { it.invariantSeparatorsPath } + .firstOrNull() + ?: dir.resolve("asset.bin").takeIf { it.isFile } + } } private const val MAX_CAPE_FRAME_PIXELS = 1024L * 512 diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticCatalog.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticCatalog.kt index e31d1244..dc147ebe 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticCatalog.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticCatalog.kt @@ -102,7 +102,9 @@ object CosmeticCatalog { }.bodyOrThrow() }.onFailure { reportCatalogFailure(it) } .getOrElse { - CosmeticLoadProgress.fail(it.message ?: it::class.java.simpleName) + CosmeticLoadProgress.fail( + if (isRuntimeMismatch(it)) CosmeticLoadProgress.OUTDATED_RUNTIME else it.message ?: it::class.java.simpleName, + ) return } CosmeticLoadProgress.onCatalogFetched() diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticLoadProgress.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticLoadProgress.kt index bb8b7d9b..df703192 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticLoadProgress.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticLoadProgress.kt @@ -9,6 +9,8 @@ object CosmeticLoadProgress { private const val ASSET_WEIGHT = 0.75f + const val OUTDATED_RUNTIME = "Fabric Language Kotlin is out of date" + data class Snapshot( val started: Boolean, val ready: Boolean, diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticService.kt index 9a708874..ad4e08a4 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticService.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticService.kt @@ -1,6 +1,7 @@ package org.polyfrost.polyplus.client.cosmetics import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary import org.polyfrost.polyplus.client.network.http.responses.BodySlot import org.polyfrost.polyplus.client.network.http.responses.CosmeticType import org.polyfrost.polyplus.client.network.http.responses.PartialEquippedCosmetics @@ -26,6 +27,7 @@ object CosmeticService { } } PolyConnection.sendPacket(ServerboundPacket.SetParticleColor(color)).getOrThrow() + OutfitLibrary.onParticleColorChanged(color) } suspend fun clearEmote(): Result = runCatching { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt new file mode 100644 index 00000000..8e08b2e2 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSwatches.kt @@ -0,0 +1,116 @@ +package org.polyfrost.polyplus.client.cosmetics + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.awt.image.BufferedImage +import java.util.concurrent.ConcurrentHashMap +import javax.imageio.ImageIO + +sealed interface SwatchArt { + data class Picture(val bitmap: ImageBitmap) : SwatchArt + data class Stripes(val colors: List) : SwatchArt +} + +object CosmeticSwatches { + private val LOGGER = LogManager.getLogger("polyplus/swatches") + private val art = ConcurrentHashMap() + private val requested = ConcurrentHashMap.newKeySet() + + private const val RETRY_MS = 60_000L + + private const val CAPE_TEXTURE_WIDTH = 64 + private const val LEGACY_CAPE_TEXTURE_WIDTH = 22 + private const val MAX_STRIPES = 3 + private const val MIN_STRIPE_SHARE = 0.08 + + var version by mutableIntStateOf(0) + private set + + fun get(id: Int): SwatchArt? { + art[id]?.let { return it } + if (requested.add(id)) load(id) + return null + } + + private fun load(id: Int) { + PolyPlusClient.SCOPE.launch { + runSuspendCatching { + CosmeticAssetCache.ensureCosmeticLoaded(id) + val built = withContext(Dispatchers.IO) { build(id) } + if (built == null) { + retryLater(id) + return@runSuspendCatching + } + art[id] = built + version++ + }.onFailure { + retryLater(id) + LOGGER.debug("Could not build a swatch for cosmetic {}", id, it) + } + } + } + + private fun retryLater(id: Int) { + PolyPlusClient.SCOPE.launch { + delay(RETRY_MS) + requested.remove(id) + version++ + } + } + + private fun build(id: Int): SwatchArt? { + val definition = CosmeticCatalog.getCosmeticDefinition(id) ?: return null + val file = CosmeticAssetCache.primaryTextureFile(id) ?: return null + val image = ImageIO.read(file) ?: return null + return if (definition.type == CosmeticType.Cape) { + capeFront(image)?.let { SwatchArt.Picture(it.toComposeImageBitmap()) } + } else { + dominantColors(image).takeIf { it.isNotEmpty() }?.let(SwatchArt::Stripes) + } + } + + private fun capeFront(image: BufferedImage): BufferedImage? { + val unit = if (image.width >= CAPE_TEXTURE_WIDTH) { + image.width / CAPE_TEXTURE_WIDTH.toFloat() + } else { + image.width / LEGACY_CAPE_TEXTURE_WIDTH.toFloat() + } + val offset = unit.toInt() + val width = (unit * 10).toInt() + val height = (unit * 16).toInt() + if (width <= 0 || height <= 0 || offset + width > image.width || offset + height > image.height) return null + return image.getSubimage(offset, offset, width, height) + } + + private fun dominantColors(image: BufferedImage): List { + val counts = HashMap() + var total = 0 + for (py in 0 until image.height) { + for (px in 0 until image.width) { + val argb = image.getRGB(px, py) + if ((argb ushr 24) < 128) continue + val bucket = (argb and 0xF0F0F0) or 0x080808 + counts[bucket] = (counts[bucket] ?: 0) + 1 + total++ + } + } + if (total == 0) return emptyList() + return counts.entries + .sortedByDescending { it.value } + .filter { it.value >= total * MIN_STRIPE_SHARE } + .take(MAX_STRIPES) + .map { Color(0xFF000000.toInt() or it.key) } + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt index 4e7e5c45..1c067099 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/CosmeticSync.kt @@ -34,7 +34,9 @@ import org.polyfrost.polyplus.events.WebSocketMessage import java.time.Duration import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.delay import kotlinx.coroutines.launch object CosmeticSync { @@ -54,6 +56,9 @@ object CosmeticSync { @Volatile private var capWarned = false + private const val LOCAL_RESYNC_DELAY_MS = 500L + private val localResyncPending = AtomicBoolean(false) + //? if >= 1.21.1 || = 1.8.9 { private const val RECONCILE_INTERVAL_TICKS = 20 private var reconcileTicks = 0 @@ -299,6 +304,17 @@ object CosmeticSync { val uuid = UUID.fromString(packet.player) CosmeticCatalog.applyRemoteEquippedSlot(uuid, packet.slot, packet.cosmeticId) applyActiveToPlayer(uuid, packet.cosmeticId?.let(::listOf).orEmpty()) + if (uuid == ClientPlatform.localPlayerUuid() && + packet.slot != BodySlot.Unknown && + CosmeticCatalog.localEquipped().equipped[packet.slot] != packet.cosmeticId && + localResyncPending.compareAndSet(false, true) + ) { + PolyPlusClient.SCOPE.launch { + delay(LOCAL_RESYNC_DELAY_MS) + localResyncPending.set(false) + CosmeticService.syncLocalActive() + } + } } private fun handleParticleColorChanged(packet: ClientboundPacket.PlayerParticleColorChanged) { diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangAuth.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangAuth.kt new file mode 100644 index 00000000..11a06b6f --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangAuth.kt @@ -0,0 +1,25 @@ +package org.polyfrost.polyplus.client.cosmetics + +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.accept +import io.ktor.client.request.header +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import net.minecraft.client.Minecraft + +internal object MojangAuth { + const val PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile" + + fun accessToken(): String? = runCatching { + //? if > 1.8.9 { + Minecraft.getInstance().user.accessToken + //?} else { + /*Minecraft.getInstance().session.accessToken + *///?} + }.getOrNull()?.takeIf { it.isNotBlank() && it != "0" } + + fun HttpRequestBuilder.authorize(token: String) { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer $token") + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt new file mode 100644 index 00000000..08d335fb --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangCapeService.kt @@ -0,0 +1,265 @@ +package org.polyfrost.polyplus.client.cosmetics + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsBytes +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import net.minecraft.resources.Identifier +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.PolyPlusConstants +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.PROFILE_URL +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.accessToken +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.authorize +import org.polyfrost.polyplus.client.cosmetics.assets.RemoteTextures +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.utils.ClientPlatform +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.io.ByteArrayInputStream +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.imageio.ImageIO + +data class MojangCape(val id: String, val name: String, val url: String, val active: Boolean) { + val textureHash: String get() = url.substringAfterLast('/') +} + +sealed interface MojangCapeState { + data object Idle : MojangCapeState + data object Loading : MojangCapeState + data class Loaded(val capes: List) : MojangCapeState + data class Unavailable(val reason: String) : MojangCapeState +} + +class MojangCapeException(message: String) : Exception(message) + +object MojangCapeService { + private val LOGGER = LogManager.getLogger("polyplus/mojang-capes") + + private const val ACTIVE_CAPE_URL = "$PROFILE_URL/capes/active" + private const val SIGNED_OUT_MESSAGE = "Sign in with a Microsoft account to choose a Minecraft cape." + private const val UNREACHABLE_MESSAGE = "Couldn't reach Minecraft's servers." + private const val TEXTURE_RETRY_MS = 60_000L + + private val requestLock = Mutex() + private val textures = ConcurrentHashMap() + private val downloading = ConcurrentHashMap.newKeySet() + private val retryAt = ConcurrentHashMap() + private val cacheDir = CosmeticAssetCache.baseDir.toPath().resolve("mojang") + + var state by mutableStateOf(MojangCapeState.Idle) + private set + + var busy by mutableStateOf(false) + private set + + var textureVersion by mutableIntStateOf(0) + private set + + @Volatile + private var changedThisSession = false + + @Volatile + private var loadedFor: UUID? = null + + @Volatile + private var refreshJob: Deferred>>? = null + + suspend fun refreshIfNeeded() { + val stale = loadedFor != null && loadedFor != ClientPlatform.localPlayerUuid() + if (stale || state is MojangCapeState.Idle || state is MojangCapeState.Unavailable) refresh() + } + + suspend fun refresh(): Result> { + val job = synchronized(this) { + refreshJob?.takeIf { it.isActive } ?: PolyPlusClient.SCOPE.async { + requestLock.withLock { + val owner = ClientPlatform.localPlayerUuid() + if (state !is MojangCapeState.Loaded || loadedFor != owner) state = MojangCapeState.Loading + try { + request { token -> PolyPlusClient.HTTP.get(PROFILE_URL) { authorize(token) } } + } finally { + if (state is MojangCapeState.Loading) state = MojangCapeState.Idle + } + } + }.also { refreshJob = it } + } + return job.await() + } + + suspend fun select(capeId: String): Result> = mutate { token -> + PolyPlusClient.HTTP.put(ACTIVE_CAPE_URL) { + authorize(token) + contentType(ContentType.Application.Json) + setBody(SelectCapeRequest(capeId)) + } + } + + suspend fun clear(): Result> = mutate { token -> + PolyPlusClient.HTTP.delete(ACTIVE_CAPE_URL) { authorize(token) } + } + + fun isLoaded(): Boolean = localCapes() != null + + fun activeCape(): MojangCape? = localCapes()?.firstOrNull { it.active } + + private fun localCapes(): List? { + val capes = (state as? MojangCapeState.Loaded)?.capes ?: return null + val owner = loadedFor ?: return null + return capes.takeIf { owner == runCatching { ClientPlatform.localPlayerUuid() }.getOrNull() } + } + + fun texture(cape: MojangCape): Identifier? { + val hash = cape.textureHash + val location = textures[hash] + if (location != null && RemoteTextures.isRegistered(texturePath(hash))) return location + load(cape) + return null + } + + @JvmStatic + fun localCapeTexture(): Identifier? = activeCape()?.let(::texture) + + @JvmStatic + fun localCapeHidden(): Boolean = localCapes()?.none { it.active } == true + + @JvmStatic + fun overridesLocalCape(uuid: UUID): Boolean = + changedThisSession && state is MojangCapeState.Loaded && uuid == loadedFor && uuid == ClientPlatform.localPlayerUuid() + + fun failureMessage(error: Throwable): String = when (error) { + is MojangCapeException -> error.message ?: SIGNED_OUT_MESSAGE + else -> UNREACHABLE_MESSAGE + } + + private suspend fun mutate(call: suspend (String) -> HttpResponse): Result> = + requestLock.withLock { + busy = true + try { + request(call).onSuccess { capes -> + changedThisSession = true + OutfitLibrary.onMojangCapeChanged(capes.firstOrNull { it.active }?.id) + } + } finally { + busy = false + } + } + + private suspend fun request(call: suspend (String) -> HttpResponse): Result> { + val owner = ClientPlatform.localPlayerUuid() + val result = runSuspendCatching { + val token = accessToken() ?: throw MojangCapeException(SIGNED_OUT_MESSAGE) + val response = call(token) + ensureSuccess(response.status) + response.body().capes.map { it.toCape() } + } + result.onSuccess { capes -> + if (loadedFor != owner) changedThisSession = false + loadedFor = owner + state = MojangCapeState.Loaded(capes) + capes.forEach(::texture) + }.onFailure { error -> + LOGGER.warn("Minecraft cape request failed", error) + if (state !is MojangCapeState.Loaded) state = MojangCapeState.Unavailable(failureMessage(error)) + } + return result + } + + private fun ensureSuccess(status: HttpStatusCode) { + if (status.isSuccess()) return + throw MojangCapeException( + when (status) { + HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden -> SIGNED_OUT_MESSAGE + HttpStatusCode.TooManyRequests -> "Minecraft is limiting cape changes right now. Try again in a minute." + HttpStatusCode.BadRequest -> "Minecraft couldn't use that cape." + else -> UNREACHABLE_MESSAGE + }, + ) + } + + private fun load(cape: MojangCape) { + val hash = cape.textureHash + if (hash.isBlank() || System.currentTimeMillis() < (retryAt[hash] ?: 0L) || !downloading.add(hash)) return + PolyPlusClient.SCOPE.launch { + val file = cacheDir.resolve("$hash.png") + runSuspendCatching { + if (!Files.isRegularFile(file)) { + val response = PolyPlusClient.HTTP.get(cape.url) + if (!response.status.isSuccess()) throw MojangCapeException("Cape texture request returned ${response.status}") + val bytes = response.bodyAsBytes() + withContext(Dispatchers.IO) { + if (ImageIO.read(ByteArrayInputStream(bytes)) == null) throw MojangCapeException("Cape texture isn't an image") + Files.createDirectories(cacheDir) + Files.write(file, bytes) + } + } + RemoteTextures.register(texturePath(hash), file) + textures[hash] = location(hash) + retryAt.remove(hash) + textureVersion++ + }.onFailure { + LOGGER.warn("Failed to load the texture for Minecraft cape {}", cape.name, it) + retryAt[hash] = System.currentTimeMillis() + TEXTURE_RETRY_MS + withContext(Dispatchers.IO) { runCatching { Files.deleteIfExists(file) } } + PolyPlusClient.SCOPE.launch { + delay(TEXTURE_RETRY_MS) + textureVersion++ + } + } + downloading.remove(hash) + } + } + + private fun location(hash: String): Identifier = + Identifier.fromNamespaceAndPath(PolyPlusConstants.ID, "mojang_cape/$hash") + + private fun texturePath(hash: String): Identifier { + //? if >= 1.21.10 { + return Identifier.fromNamespaceAndPath(PolyPlusConstants.ID, "textures/mojang_cape/$hash.png") + //?} else { + /*return location(hash) + *///?} + } + + @Serializable + private data class ProfileResponse(val capes: List = emptyList()) + + @Serializable + private data class CapeResponse( + val id: String, + val state: String = "", + val url: String = "", + val alias: String = "", + ) { + fun toCape(): MojangCape = MojangCape( + id = id, + name = alias.ifBlank { "Minecraft cape" }, + url = url, + active = state.equals("ACTIVE", ignoreCase = true), + ) + } + + @Serializable + private data class SelectCapeRequest(val capeId: String) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt new file mode 100644 index 00000000..d7ace5b3 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/MojangSkinService.kt @@ -0,0 +1,230 @@ +package org.polyfrost.polyplus.client.cosmetics + +import io.ktor.client.call.body +import io.ktor.client.request.accept +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.PROFILE_URL +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.accessToken +import org.polyfrost.polyplus.client.cosmetics.MojangAuth.authorize +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinModel +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.nio.charset.StandardCharsets +import java.util.Base64 +import java.util.UUID + +data class MojangSkin(val url: String, val model: SkinModel) { + val textureHash: String? get() = MojangSkinService.textureHash(url) +} + +class MojangSkinException(message: String) : Exception(message) + +object MojangSkinService { + private val LOGGER = LogManager.getLogger("polyplus/mojang-skins") + + private const val SKINS_URL = "$PROFILE_URL/skins" + private const val SESSION_PROFILE_URL = "https://sessionserver.mojang.com/session/minecraft/profile" + private const val USERNAME_URL = "https://api.mojang.com/users/profiles/minecraft" + private const val SIGNED_OUT_MESSAGE = "Sign in with a Microsoft account to change your Minecraft skin." + private const val UNREACHABLE_MESSAGE = "Couldn't reach Minecraft's servers." + private const val READBACK_ATTEMPTS = 5 + private const val READBACK_DELAY_MS = 1_000L + private val TEXTURE_HASH = Regex("^[0-9a-f]{32,64}$") + + private val requestLock = Mutex() + + @Volatile + private var knownTexture: String? = null + + suspend fun setSkin(url: String, model: SkinModel): Result = change { token -> + PolyPlusClient.HTTP.post(SKINS_URL) { + authorize(token) + contentType(ContentType.Application.Json) + setBody( + buildJsonObject { + put("variant", model.serializedName) + put("url", url) + }, + ) + } + } + + suspend fun uploadSkin(png: ByteArray, model: SkinModel): Result = change { token -> + PolyPlusClient.HTTP.post(SKINS_URL) { + authorize(token) + setBody( + MultiPartFormDataContent( + formData { + append("variant", model.serializedName) + append( + "file", + png, + Headers.build { + append(HttpHeaders.ContentType, "image/png") + append(HttpHeaders.ContentDisposition, "filename=\"skin.png\"") + }, + ) + }, + ), + ) + } + } + + suspend fun activeSkin(): Result = runSuspendCatching { + val token = accessToken() ?: throw MojangSkinException(SIGNED_OUT_MESSAGE) + readProfile(token) + } + + suspend fun profileSkin(uuid: UUID): Result = runSuspendCatching { + val id = uuid.toString().replace("-", "") + val response = PolyPlusClient.HTTP.get("$SESSION_PROFILE_URL/$id") { + accept(ContentType.Application.Json) + } + when (response.status) { + HttpStatusCode.OK -> Unit + HttpStatusCode.NoContent, HttpStatusCode.NotFound -> return@runSuspendCatching null + HttpStatusCode.TooManyRequests -> + throw MojangSkinException("Minecraft is limiting lookups right now. Try again in a minute.") + else -> throw MojangSkinException(UNREACHABLE_MESSAGE) + } + val texturesValue = response.body().properties + .firstOrNull { it.name == "textures" } + ?.value + ?: return@runSuspendCatching null + val decoded = String(Base64.getDecoder().decode(texturesValue), StandardCharsets.UTF_8) + val skin = PolyPlusClient.JSON.decodeFromString(TexturesPayload.serializer(), decoded).textures["SKIN"] + ?: return@runSuspendCatching null + if (skin.url.isBlank()) return@runSuspendCatching null + MojangSkin(secure(skin.url), SkinModel.fromSerialized(skin.metadata["model"])) + } + + suspend fun uuidForName(username: String): Result = runSuspendCatching { + val response = PolyPlusClient.HTTP.get("$USERNAME_URL/$username") { + accept(ContentType.Application.Json) + } + when { + response.status == HttpStatusCode.OK -> parseUndashed(response.body().id) + response.status == HttpStatusCode.NoContent || response.status == HttpStatusCode.NotFound -> null + response.status == HttpStatusCode.TooManyRequests -> + throw MojangSkinException("Minecraft is limiting lookups right now. Try again in a minute.") + else -> throw MojangSkinException(UNREACHABLE_MESSAGE) + } + } + + fun textureHash(url: String): String? = + url.substringBefore('?') + .substringBefore('#') + .trimEnd('/') + .substringAfterLast('/') + .lowercase() + .takeIf { TEXTURE_HASH.matches(it) } + + fun failureMessage(error: Throwable): String = when (error) { + is MojangSkinException -> error.message ?: SIGNED_OUT_MESSAGE + else -> UNREACHABLE_MESSAGE + } + + private suspend fun change(call: suspend (String) -> HttpResponse): Result = requestLock.withLock { + runSuspendCatching { + val token = accessToken() ?: throw MojangSkinException(SIGNED_OUT_MESSAGE) + val previous = knownTexture ?: runSuspendCatching { readProfile(token) }.getOrNull()?.textureHash + val response = call(token) + ensureSkinChanged(response) + val reported = runSuspendCatching { response.body() }.getOrNull()?.let(::activeSkin) + if (reported != null) { + knownTexture = reported.textureHash + reported + } else { + readBack(token, previous) + } + }.onFailure { LOGGER.warn("Minecraft skin change failed", it) } + } + + private suspend fun readBack(token: String, previous: String?): MojangSkin? { + repeat(READBACK_ATTEMPTS) { + delay(READBACK_DELAY_MS) + val skin = runSuspendCatching { readProfile(token) }.getOrNull() + if (skin != null && skin.textureHash != previous) return skin + } + return null + } + + private suspend fun readProfile(token: String): MojangSkin? { + val response = PolyPlusClient.HTTP.get(PROFILE_URL) { authorize(token) } + val status = response.status + if (status == HttpStatusCode.Unauthorized || status == HttpStatusCode.Forbidden || status == HttpStatusCode.NotFound) { + return null + } + if (!status.isSuccess()) throw MojangSkinException(UNREACHABLE_MESSAGE) + return activeSkin(response.body()).also { knownTexture = it?.textureHash } + } + + private fun activeSkin(profile: ServicesProfile): MojangSkin? { + val active = profile.skins + .firstOrNull { it.state.equals("ACTIVE", ignoreCase = true) && it.url.isNotBlank() } + ?: return null + return MojangSkin(secure(active.url), SkinModel.fromSerialized(active.variant)) + } + + private fun ensureSkinChanged(response: HttpResponse) { + val status = response.status + if (status.isSuccess()) return + throw MojangSkinException( + when (status) { + HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden -> SIGNED_OUT_MESSAGE + HttpStatusCode.TooManyRequests -> "Minecraft is limiting skin changes right now. Try again in a minute." + HttpStatusCode.BadRequest -> "Minecraft couldn't use that skin." + else -> "Minecraft's servers couldn't change your skin right now." + }, + ) + } + + private fun parseUndashed(id: String): UUID? = runCatching { + if (id.length != 32) return@runCatching UUID.fromString(id) + UUID.fromString( + "${id.substring(0, 8)}-${id.substring(8, 12)}-${id.substring(12, 16)}-${id.substring(16, 20)}-${id.substring(20)}", + ) + }.getOrNull() + + private fun secure(url: String): String = + if (url.startsWith("http://textures.minecraft.net/")) "https://" + url.removePrefix("http://") else url + + @Serializable + private data class SessionProfile(val properties: List = emptyList()) + + @Serializable + private data class ProfileProperty(val name: String = "", val value: String = "") + + @Serializable + private data class TexturesPayload(val textures: Map = emptyMap()) + + @Serializable + private data class TextureEntry(val url: String = "", val metadata: Map = emptyMap()) + + @Serializable + private data class ServicesProfile(val skins: List = emptyList()) + + @Serializable + private data class ServicesSkin(val state: String = "", val url: String = "", val variant: String = "") + + @Serializable + private data class NameLookup(val id: String = "") +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitApi.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitApi.kt new file mode 100644 index 00000000..60df65d9 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitApi.kt @@ -0,0 +1,59 @@ +package org.polyfrost.polyplus.client.cosmetics.outfits + +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.ServerResponseException +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import kotlinx.serialization.json.JsonObject +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.PolyPlusConfig +import org.polyfrost.polyplus.client.cosmetics.MojangSkinException +import org.polyfrost.polyplus.client.network.http.requestBodyAuthorized +import org.polyfrost.polyplus.client.privacy.OnlineServicesDisabledException +import org.polyfrost.polyplus.privacy.PrivacyConsent + +internal object OutfitApi { + const val PRIVACY_MESSAGE = + "Outfits need the Terms of Service and Privacy Policy. Accept them under Privacy in the PolyPlus settings." + private const val OFFLINE_MESSAGE = "Couldn't reach the PolyPlus servers." + + fun url(path: String): String = "${PolyPlusConfig.apiUrl}$path" + + fun allowed(): Boolean = PrivacyConsent.allowsOnlineServices() + + fun privacyFailure(): Result = Result.failure(OutfitException(PRIVACY_MESSAGE)) + + suspend inline fun send( + method: HttpMethod, + path: String, + body: JsonObject? = null, + ): Result = PolyPlusClient.HTTP.requestBodyAuthorized(url(path), method) { + if (body != null) { + contentType(ContentType.Application.Json) + setBody(body) + } + } + + fun failureMessage(error: Throwable, notFound: String, conflict: String, badRequest: String): String = when (error) { + is OutfitException -> error.message ?: OFFLINE_MESSAGE + is MojangSkinException -> error.message ?: OFFLINE_MESSAGE + is OnlineServicesDisabledException -> PRIVACY_MESSAGE + is ClientRequestException -> when (error.response.status) { + HttpStatusCode.NotFound -> notFound + HttpStatusCode.Conflict -> conflict + HttpStatusCode.BadRequest -> backendText(error) ?: badRequest + HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden -> + "Couldn't sign in to PolyPlus. Try again in a moment." + HttpStatusCode.TooManyRequests -> "Too many changes at once. Try again in a moment." + else -> backendText(error) ?: OFFLINE_MESSAGE + } + is ServerResponseException -> "The PolyPlus servers are having trouble. Try again later." + else -> OFFLINE_MESSAGE + } + + private fun backendText(error: ClientRequestException): String? = + error.message.substringAfter("Text: \"", "").substringBeforeLast('"').trim().takeIf { it.isNotBlank() } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt new file mode 100644 index 00000000..757dc64f --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitLibrary.kt @@ -0,0 +1,386 @@ +package org.polyfrost.polyplus.client.cosmetics.outfits + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.ktor.client.plugins.ClientRequestException +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.cosmetics.CosmeticService +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.MojangSkinService +import org.polyfrost.polyplus.client.utils.ClientPlatform +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +object OutfitLibrary { + private val LOGGER = LogManager.getLogger("polyplus/outfits") + + private val requestLock = Mutex() + private val adopting = AtomicBoolean(false) + private val adoptionAttempts = ConcurrentHashMap, Long>() + private const val ADOPTION_RETRY_MS = 10 * 60_000L + + var outfits by mutableStateOf>(emptyList()) + private set + + var activeOutfitId by mutableStateOf(null) + private set + + var state by mutableStateOf(LibraryState.Idle) + private set + + var limit by mutableIntStateOf(LIBRARY_LIMIT) + private set + + var busy by mutableStateOf(false) + private set + + @Volatile + private var applying = false + + @Volatile + private var loadedFor: UUID? = null + + @Volatile + private var refreshJob: Deferred>? = null + + suspend fun refreshIfNeeded(reload: Boolean = false) { + val stale = loadedFor != null && loadedFor != ClientPlatform.localPlayerUuid() + if (reload || stale || state is LibraryState.Idle || state is LibraryState.Unavailable) refresh() + SkinLibrary.recordInBackground() + } + + suspend fun refresh(): Result { + val job = synchronized(this) { + refreshJob?.takeIf { it.isActive } + ?: PolyPlusClient.SCOPE.async { requestLock.withLock { refreshLocked() } }.also { refreshJob = it } + } + return job.await() + } + + suspend fun createFromCurrent(): Result { + if (!OutfitApi.allowed()) return unavailable() + return requestLock.withLock { + busy = true + try { + MojangCapeService.refresh().onFailure { + return@withLock Result.failure( + OutfitException( + "Couldn't read your Minecraft cape, so the outfit wasn't saved (${MojangCapeService.failureMessage(it)}).", + ), + ) + } + val skinId = SkinLibrary.recordCurrent() + .getOrElse { + LOGGER.warn("Could not add the current Minecraft skin to the library", it) + return@withLock Result.failure( + OutfitException( + "Couldn't save your current skin, so the outfit wasn't saved (${SkinLibrary.failureMessage(it)}).", + ), + ) + }?.id + val capeId = MojangCapeService.activeCape()?.id + val particleColor = CosmeticCatalog.getParticleColor(ClientPlatform.localPlayerUuid()) + val body = buildJsonObject { + put("skin_id", skinId) + put("mojang_cape_id", capeId) + put("particle_color", particleColor) + } + OutfitApi.send(HttpMethod.Post, "/outfits", body) + .map { it.toInfo() } + .onSuccess { created -> + outfits = outfits.filter { it.id != created.id } + created + activeOutfitId = created.id + refreshLocked() + } + .onFailure { LOGGER.error("Failed to create an outfit", it) } + } finally { + busy = false + } + } + } + + suspend fun apply(id: Int): Result { + if (!OutfitApi.allowed()) return unavailable() + return requestLock.withLock { + busy = true + applying = true + try { + applyLocked(id) + } finally { + applying = false + busy = false + } + } + } + + suspend fun rename(id: Int, name: String): Result { + val trimmed = name.trim() + if (trimmed.isEmpty()) return Result.failure(OutfitException("Give the outfit a name.")) + return patch(id, buildJsonObject { put("name", trimmed) }) + } + + suspend fun setFavorite(id: Int, favorite: Boolean): Result = + patch(id, buildJsonObject { put("favorite", favorite) }) + + suspend fun delete(id: Int): Result { + if (!OutfitApi.allowed()) return unavailable() + return OutfitApi.send(HttpMethod.Delete, "/outfits/$id") + .onSuccess { + outfits = outfits.filter { it.id != id } + if (activeOutfitId == id) activeOutfitId = null + } + .onFailure { + LOGGER.error("Failed to delete outfit {}", id, it) + forgetIfMissing(id, it) + } + } + + fun failureMessage(error: Throwable): String = OutfitApi.failureMessage( + error, + notFound = "That outfit no longer exists.", + conflict = "You've reached the limit of $limit outfits. Delete one to make room.", + badRequest = "PolyPlus couldn't save that outfit.", + ) + + internal fun activeOutfit(): OutfitInfo? = activeOutfitId?.let { id -> outfits.firstOrNull { it.id == id } } + + internal fun onMojangCapeChanged(capeId: String?) { + if (applying || !OutfitApi.allowed()) return + PolyPlusClient.SCOPE.launch { + val outfit = liveSyncTarget() ?: return@launch + if (outfit.blank) { + adoptLive(outfit).onFailure { + LOGGER.debug("Could not adopt the current look into outfit {}: {}", outfit.id, it.message) + } + return@launch + } + if (outfit.mojangCapeId == capeId) return@launch + patch(outfit.id, buildJsonObject { put("mojang_cape_id", capeId) }) + } + } + + internal fun onParticleColorChanged(color: Int?) { + val outfit = activeOutfit() ?: return + if (outfit.particleColor != color) replace(outfit.copy(particleColor = color)) + } + + internal suspend fun onSkinWorn(skinId: Int): Result { + val outfit = liveSyncTarget() ?: return Result.success(Unit) + if (outfit.blank) { + adoptLive(outfit, skinId).onFailure { + LOGGER.debug("Could not adopt the current look into outfit {}: {}", outfit.id, it.message) + } + return Result.success(Unit) + } + if (outfit.skinId == skinId) return Result.success(Unit) + return patch(outfit.id, buildJsonObject { put("skin_id", skinId) }).map { } + } + + internal fun onSkinDeleted(skinId: Int) { + outfits = outfits.map { if (it.skinId == skinId) it.copy(skinId = null) else it } + } + + private suspend fun liveSyncTarget(): OutfitInfo? { + if (applying || !OutfitApi.allowed() || state !is LibraryState.Loaded) return null + if (refresh().isFailure || applying) return null + return activeOutfit() + } + + private suspend fun adoptLive(target: OutfitInfo, wornSkinId: Int? = null): Result = requestLock.withLock { + val outfit = activeOutfit()?.takeIf { it.id == target.id } ?: return@withLock Result.success(Unit) + when { + outfit.blank -> adoptCurrentLook(outfit, wornSkinId = wornSkinId) + wornSkinId != null && outfit.skinId != wornSkinId -> + patch(outfit.id, buildJsonObject { put("skin_id", wornSkinId) }).map { } + else -> Result.success(Unit) + } + } + + private suspend fun patch(id: Int, body: JsonObject): Result { + if (!OutfitApi.allowed()) return unavailable() + return OutfitApi.send(HttpMethod.Patch, "/outfits/$id", body) + .map { it.toInfo() } + .onSuccess(::replace) + .onFailure { + LOGGER.error("Failed to update outfit {}", id, it) + forgetIfMissing(id, it) + } + } + + private suspend fun refreshLocked(): Result { + if (!OutfitApi.allowed()) { + outfits = emptyList() + activeOutfitId = null + state = LibraryState.Unavailable(OutfitApi.PRIVACY_MESSAGE) + return OutfitApi.privacyFailure() + } + val owner = ClientPlatform.localPlayerUuid() + if (state !is LibraryState.Loaded || loadedFor != owner) state = LibraryState.Loading + try { + return OutfitApi.send(HttpMethod.Get, "/outfits") + .onSuccess { response -> + outfits = response.outfits.map { it.toInfo() } + activeOutfitId = response.activeOutfitId + limit = response.limit + loadedFor = owner + state = LibraryState.Loaded + activeOutfit()?.takeIf { it.blank && claimBackgroundAdoption(owner, it.id) }?.let { target -> + PolyPlusClient.SCOPE.launch { + requestLock.withLock { + activeOutfit()?.takeIf { it.id == target.id && it.blank }?.let { outfit -> + adoptCurrentLook(outfit, background = true).onFailure { + LOGGER.debug("Could not adopt the current look into outfit {}: {}", outfit.id, it.message) + } + } + } + } + } + } + .onFailure { error -> + LOGGER.error("Failed to fetch outfits", error) + if (state !is LibraryState.Loaded) state = LibraryState.Unavailable(failureMessage(error)) + } + .map { } + } finally { + if (state is LibraryState.Loading) state = LibraryState.Idle + } + } + + private suspend fun applyLocked(id: Int): Result { + val response = OutfitApi.send(HttpMethod.Post, "/outfits/$id/apply") + .onFailure { + LOGGER.error("Failed to apply outfit {}", id, it) + forgetIfMissing(id, it) + } + .getOrElse { return Result.failure(it) } + val outfit = response.outfit.toInfo() + replace(outfit) + activeOutfitId = outfit.id + response.skin?.let { skin -> SkinLibrary.remember(skin.toInfo()) } + + val problems = mutableListOf() + if (response.adopted) { + adoptCurrentLook(outfit).onFailure { + problems += "your current skin and cape weren't saved to it (${it.message})" + } + } + val keepLook = response.adopted || outfit.blank + response.skin?.toInfo()?.takeIf { !keepLook }?.let { skin -> + val current = MojangSkinService.activeSkin().getOrNull() + if (current == null || current.textureHash != skin.hash || current.model != skin.model) { + MojangSkinService.setSkin(skin.url, skin.model) + .onSuccess { SkinLibrary.recordInBackground(force = true) } + .onFailure { + problems += "your Minecraft skin wasn't changed (${MojangSkinService.failureMessage(it)})" + } + } + } + if (!keepLook) { + applyMojangCape(outfit.mojangCapeId).onFailure { + problems += "your Minecraft cape wasn't changed (${MojangCapeService.failureMessage(it)})" + } + } + + CosmeticService.syncLocalActive().onFailure { LOGGER.warn("Failed to resync cosmetics after applying outfit {}", id, it) } + val uuid = ClientPlatform.localPlayerUuid() + val color = outfit.particleColor + ClientPlatform.runOnMain { + if (color != null) { + CosmeticCatalog.setParticleColor(uuid, color) + } else { + CosmeticCatalog.clearParticleColor(uuid) + } + } + + if (problems.isEmpty()) return Result.success(outfit) + val message = "Outfit applied, but " + problems.joinToString(" and ") + "." + LOGGER.warn("Outfit {} applied with problems: {}", id, message) + return Result.failure(OutfitException(message)) + } + + private suspend fun adoptCurrentLook( + outfit: OutfitInfo, + background: Boolean = false, + wornSkinId: Int? = null, + ): Result { + if (!adopting.compareAndSet(false, true)) return Result.success(Unit) + try { + MojangCapeService.refresh().onFailure { + return Result.failure(OutfitException(MojangCapeService.failureMessage(it))) + } + if (!MojangCapeService.isLoaded()) return Result.failure(OutfitException("Couldn't read your Minecraft cape.")) + val capeId = MojangCapeService.activeCape()?.id + val skinId = wornSkinId ?: SkinLibrary.recordCurrent(background) + .onFailure { LOGGER.warn("Could not add the current Minecraft skin to the library", it) } + .getOrElse { return Result.failure(OutfitException(SkinLibrary.failureMessage(it))) } + ?.id + patch(outfit.id, buildJsonObject { + put("skin_id", skinId) + put("mojang_cape_id", capeId) + put("blank", false) + }).onFailure { return Result.failure(OutfitException(failureMessage(it))) } + return Result.success(Unit) + } finally { + adopting.set(false) + } + } + + private fun claimBackgroundAdoption(owner: UUID, id: Int): Boolean { + val now = System.currentTimeMillis() + val key = owner to id + val last = adoptionAttempts[key] + if (last != null && now - last < ADOPTION_RETRY_MS) return false + adoptionAttempts[key] = now + return true + } + + private suspend fun applyMojangCape(capeId: String?): Result = runSuspendCatching { + MojangCapeService.refresh() + val loaded = MojangCapeService.isLoaded() + val current = MojangCapeService.activeCape()?.id + if (capeId == null) { + if (!loaded || current == null) return@runSuspendCatching + MojangCapeService.clear().getOrThrow() + } else { + if (loaded && current == capeId) return@runSuspendCatching + MojangCapeService.select(capeId).getOrThrow() + } + Unit + } + + private fun forgetIfMissing(id: Int, error: Throwable) { + if ((error as? ClientRequestException)?.response?.status != HttpStatusCode.NotFound) return + outfits = outfits.filter { it.id != id } + if (activeOutfitId == id) activeOutfitId = null + } + + private fun replace(outfit: OutfitInfo) { + val existing = outfits + outfits = if (existing.any { it.id == outfit.id }) { + existing.map { if (it.id == outfit.id) outfit else it } + } else { + existing + outfit + } + } + + private fun unavailable(): Result { + state = LibraryState.Unavailable(OutfitApi.PRIVACY_MESSAGE) + return OutfitApi.privacyFailure() + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt new file mode 100644 index 00000000..5180f335 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/OutfitModels.kt @@ -0,0 +1,117 @@ +package org.polyfrost.polyplus.client.cosmetics.outfits + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.polyfrost.polyplus.client.network.http.responses.BodySlot + +enum class SkinModel(val serializedName: String) { + Classic("classic"), + Slim("slim"); + + internal companion object { + fun fromSerialized(value: String?): SkinModel = + if (value.equals(Slim.serializedName, ignoreCase = true)) Slim else Classic + } +} + +data class PlayerSkinInfo( + val id: Int, + val name: String, + val model: SkinModel, + val url: String, + val hash: String, + val favorite: Boolean, + val createdAt: String, +) + +data class OutfitInfo( + val id: Int, + val name: String, + val favorite: Boolean, + val skinId: Int?, + val mojangCapeId: String?, + val particleColor: Int?, + val equipped: Map, + val createdAt: String, + val updatedAt: String, + val blank: Boolean = false, +) + +sealed interface LibraryState { + data object Idle : LibraryState + data object Loading : LibraryState + data object Loaded : LibraryState + data class Unavailable(val reason: String) : LibraryState +} + +class OutfitException(message: String) : Exception(message) + +@Serializable +internal data class SkinResponse( + val id: Int, + val name: String = "", + val model: String = SkinModel.Classic.serializedName, + val url: String = "", + val hash: String = "", + val favorite: Boolean = false, + @SerialName("created_at") val createdAt: String = "", +) { + fun toInfo(): PlayerSkinInfo = PlayerSkinInfo( + id = id, + name = name, + model = SkinModel.fromSerialized(model), + url = url, + hash = hash.lowercase(), + favorite = favorite, + createdAt = createdAt, + ) +} + +@Serializable +internal data class OutfitResponse( + val id: Int, + val name: String = "", + val favorite: Boolean = false, + @SerialName("skin_id") val skinId: Int? = null, + @SerialName("mojang_cape_id") val mojangCapeId: String? = null, + @SerialName("particle_color") val particleColor: Int? = null, + val blank: Boolean = false, + val equipped: Map = emptyMap(), + @SerialName("created_at") val createdAt: String = "", + @SerialName("updated_at") val updatedAt: String = "", +) { + fun toInfo(): OutfitInfo = OutfitInfo( + id = id, + name = name, + favorite = favorite, + skinId = skinId, + mojangCapeId = mojangCapeId, + particleColor = particleColor, + equipped = equipped.filterKeys { it != BodySlot.Unknown }, + createdAt = createdAt, + updatedAt = updatedAt, + blank = blank, + ) +} + +@Serializable +internal data class OutfitListResponse( + val outfits: List = emptyList(), + @SerialName("active_outfit_id") val activeOutfitId: Int? = null, + val limit: Int = LIBRARY_LIMIT, +) + +@Serializable +internal data class SkinListResponse( + val skins: List = emptyList(), + val limit: Int = LIBRARY_LIMIT, +) + +@Serializable +internal data class ApplyOutfitResponse( + val outfit: OutfitResponse, + val skin: SkinResponse? = null, + val adopted: Boolean = false, +) + +internal const val LIBRARY_LIMIT = 128 diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt new file mode 100644 index 00000000..6987f3ad --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/cosmetics/outfits/SkinLibrary.kt @@ -0,0 +1,544 @@ +package org.polyfrost.polyplus.client.cosmetics.outfits + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsBytes +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentLength +import io.ktor.http.isSuccess +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.PolyPlusConstants +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.MojangSkin +import org.polyfrost.polyplus.client.cosmetics.MojangSkinException +import org.polyfrost.polyplus.client.cosmetics.MojangSkinService +import org.polyfrost.polyplus.client.utils.ClientPlatform +import org.polyfrost.polyplus.client.utils.runSuspendCatching +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference +import javax.imageio.ImageIO + +object SkinLibrary { + private val LOGGER = LogManager.getLogger("polyplus/skins") + + private const val MAX_SKIN_BYTES = 64 * 1024 + private const val MAX_DOWNLOAD_BYTES = 1024L * 1024L + private const val RECORD_INTERVAL_MS = 30_000L + private const val NOT_A_SKIN = "That image isn't a Minecraft skin." + internal const val TOO_LARGE = "That skin file is too large. Skins can be at most 64 KB." + internal const val MAX_FILE_BYTES = 64L * 1024L + private const val BITMAP_RETRY_MS = 60_000L + private const val RECORD_FAILURE_BACKOFF_MS = 10 * 60_000L + private const val DOWNLOAD_FAILED = "Couldn't download that image." + private val USERNAME_PATTERN = Regex("^[A-Za-z0-9_]{1,16}$") + private val HASH_PATTERN = Regex("^[0-9a-f]{32,64}$") + private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + + private val requestLock = Mutex() + private val recordLock = Mutex() + private val recordJobLock = Mutex() + private val cacheDir = File("${PolyPlusConstants.NAME}/skins") + private val bitmaps = ConcurrentHashMap() + private val loading = ConcurrentHashMap.newKeySet() + private val failed = ConcurrentHashMap() + private val signedInFor = AtomicReference(null) + + var skins by mutableStateOf>(emptyList()) + private set + + var state by mutableStateOf(LibraryState.Idle) + private set + + var limit by mutableIntStateOf(LIBRARY_LIMIT) + private set + + var busy by mutableStateOf(false) + private set + + var bitmapVersion by mutableIntStateOf(0) + private set + + @Volatile + private var loadedFor: UUID? = null + + @Volatile + private var recordJob: Deferred>? = null + + @Volatile + private var recordJobBackground = false + + @Volatile + private var refreshJob: Deferred>? = null + + private data class RecordFailure(val at: Long, val conflict: Boolean) + + private class SkinRecordSkipped(reason: String) : Exception(reason) + + private val recordFailures = ConcurrentHashMap, RecordFailure>() + + private var wornHash by mutableStateOf?>(null) + + @Volatile + private var lastRecordOwner: UUID? = null + + @Volatile + private var lastRecordAt = 0L + + suspend fun refreshIfNeeded() { + val stale = loadedFor != null && loadedFor != ClientPlatform.localPlayerUuid() + if (stale || state is LibraryState.Idle || state is LibraryState.Unavailable) { + refresh() + } else { + recordInBackground() + } + } + + suspend fun refresh(): Result { + val job = synchronized(this) { + refreshJob?.takeIf { it.isActive } ?: PolyPlusClient.SCOPE.async { refreshLocked() }.also { refreshJob = it } + } + return job.await().also { recordInBackground() } + } + + suspend fun recordCurrent(background: Boolean = false): Result = + recordShared(fresh = false, background = background) + + suspend fun addFromBytes(png: ByteArray, name: String?, model: SkinModel): Result { + if (!OutfitApi.allowed()) return unavailable() + runSuspendCatching { withContext(Dispatchers.IO) { validate(png) } } + .onFailure { return Result.failure(it) } + return uploadAndRecord(name) { MojangSkinService.uploadSkin(png, model) } + } + + suspend fun addFromUrl(url: String, name: String?, model: SkinModel): Result { + if (!OutfitApi.allowed()) return unavailable() + val link = url.trim() + if (!link.startsWith("https://", ignoreCase = true) && !link.startsWith("http://", ignoreCase = true)) { + return Result.failure(OutfitException("Enter a link that starts with https://.")) + } + return uploadAndRecord(name) { MojangSkinService.setSkin(link, model) } + } + + suspend fun addFromUsername(username: String): Result { + if (!OutfitApi.allowed()) return unavailable() + val name = username.trim() + if (!USERNAME_PATTERN.matches(name)) { + return Result.failure(OutfitException("That isn't a valid Minecraft username.")) + } + val uuid = MojangSkinService.uuidForName(name) + .getOrElse { return Result.failure(mojangFailure(it)) } + ?: return Result.failure(OutfitException("No Minecraft account is called $name.")) + val skin = MojangSkinService.profileSkin(uuid) + .getOrElse { return Result.failure(mojangFailure(it)) } + ?: return Result.failure(OutfitException("$name is using a default skin.")) + busy = true + try { + return recordLock.withLock { record(skin, name, ClientPlatform.localPlayerUuid()) } + } finally { + busy = false + } + } + + suspend fun wear(id: Int): Result { + if (!OutfitApi.allowed()) return unavailable() + val skin = skins.firstOrNull { it.id == id } + ?: return Result.failure(OutfitException("That skin is no longer in your library.")) + busy = true + try { + MojangSkinService.setSkin(skin.url, skin.model) + .getOrElse { return Result.failure(mojangFailure(it)) } + wornHash = ClientPlatform.localPlayerUuid() to skin.hash + val patched = OutfitLibrary.onSkinWorn(id) + recordInBackground(force = true) + patched.onFailure { + return Result.failure(OutfitException("Your skin changed, but the active outfit couldn't be updated.")) + } + return Result.success(Unit) + } finally { + busy = false + } + } + + suspend fun rename(id: Int, name: String): Result { + val trimmed = name.trim() + if (trimmed.isEmpty()) return Result.failure(OutfitException("Give the skin a name.")) + return patch(id, buildJsonObject { put("name", trimmed) }) + } + + suspend fun setFavorite(id: Int, favorite: Boolean): Result = + patch(id, buildJsonObject { put("favorite", favorite) }) + + suspend fun setModel(id: Int, model: SkinModel): Result { + val worn = wornSkinId() == id + val updated = patch(id, buildJsonObject { put("model", model.serializedName) }) + .getOrElse { return Result.failure(it) } + if (!worn) return Result.success(updated) + busy = true + try { + MojangSkinService.setSkin(updated.url, updated.model).getOrElse { + return Result.failure( + OutfitException("${updated.name} was updated, but your Minecraft skin wasn't changed (${MojangSkinService.failureMessage(it)})."), + ) + } + recordInBackground(force = true) + } finally { + busy = false + } + return Result.success(updated) + } + + suspend fun delete(id: Int): Result { + if (!OutfitApi.allowed()) return unavailable() + return OutfitApi.send(HttpMethod.Delete, "/skins/$id") + .onSuccess { + forget(id) + val owner = ClientPlatform.localPlayerUuid() + recordFailures.entries.removeIf { it.key.first == owner && it.value.conflict } + } + .onFailure { + LOGGER.error("Failed to delete skin {}", id, it) + forgetIfMissing(id, it) + } + } + + fun wornSkinId(): Int? { + val owner = runCatching { ClientPlatform.localPlayerUuid() }.getOrNull() + val hash = wornHash?.takeIf { it.first == owner }?.second ?: return OutfitLibrary.activeOutfit()?.skinId + return skins.firstOrNull { it.hash == hash }?.id + } + + fun bitmap(skin: PlayerSkinInfo): ImageBitmap? { + val key = skin.hash.ifBlank { skin.url } + bitmaps[key]?.let { return it } + if (System.currentTimeMillis() >= (failed[key] ?: 0L) && loading.add(key)) loadBitmap(skin, key) + return null + } + + fun failureMessage(error: Throwable): String = OutfitApi.failureMessage( + error, + notFound = "That skin no longer exists.", + conflict = "Your skin library is full ($limit skins). Delete one to make room.", + badRequest = NOT_A_SKIN, + ) + + internal fun remember(skin: PlayerSkinInfo) { + if (state !is LibraryState.Loaded) return + replace(skin) + } + + internal fun onSignedIn(profileId: UUID) { + if (signedInFor.getAndSet(profileId) == profileId) return + recordInBackground(force = true, background = true) + } + + internal fun recordInBackground(force: Boolean = false, background: Boolean = !force) { + if (!OutfitApi.allowed()) return + val owner = runCatching { ClientPlatform.localPlayerUuid() }.getOrNull() ?: return + if (!force && lastRecordOwner == owner && System.currentTimeMillis() - lastRecordAt < RECORD_INTERVAL_MS) return + PolyPlusClient.SCOPE.launch { + recordShared(fresh = force, background = background).onFailure { + if (it is SkinRecordSkipped) { + LOGGER.debug("Skipped saving the current Minecraft skin: {}", it.message) + } else { + LOGGER.warn("Could not save the current Minecraft skin", it) + } + } + } + } + + private suspend fun recordShared(fresh: Boolean, background: Boolean): Result { + if (!OutfitApi.allowed()) return OutfitApi.privacyFailure() + val job = recordJobLock.withLock { + val running = recordJob?.takeIf { it.isActive } + if (running != null && !fresh && (background || !recordJobBackground)) { + running + } else { + PolyPlusClient.SCOPE.async { + running?.join() + recordLock.withLock { recordActive(background) } + }.also { + recordJob = it + recordJobBackground = background + } + } + } + return job.await() + } + + private suspend fun recordActive(background: Boolean): Result { + val owner = ClientPlatform.localPlayerUuid() + try { + val current = MojangSkinService.activeSkin() + .getOrElse { return Result.failure(mojangFailure(it)) } + ?: return Result.success(null) + current.textureHash?.let { wornHash = owner to it } + return record(current, null, owner, background, correctModel = true) + } finally { + lastRecordOwner = owner + lastRecordAt = System.currentTimeMillis() + } + } + + private suspend fun uploadAndRecord( + name: String?, + upload: suspend () -> Result, + ): Result { + busy = true + try { + val recorded = recordLock.withLock { + val owner = ClientPlatform.localPlayerUuid() + if (loadedFor == owner && skins.size >= limit) { + return Result.failure(OutfitException("Your skin library is full ($limit skins). Delete one to make room.")) + } + val worn = upload().getOrElse { return Result.failure(mojangFailure(it)) } + ?: return Result.failure( + OutfitException("Your skin changed, but Minecraft hasn't reported it yet. Try again in a moment."), + ) + worn.textureHash?.let { wornHash = owner to it } + record(worn, name, owner, correctModel = true).also { + lastRecordOwner = owner + lastRecordAt = System.currentTimeMillis() + }.recoverCatching { + throw OutfitException("Your skin changed, but it couldn't be saved to your library (${failureMessage(it)}).") + } + } + recorded.onSuccess { skin -> + OutfitLibrary.onSkinWorn(skin.id) + .onFailure { LOGGER.warn("Could not move the active outfit to skin {}", skin.id, it) } + } + return recorded + } finally { + busy = false + } + } + + private suspend fun record( + skin: MojangSkin, + name: String?, + owner: UUID, + background: Boolean = false, + correctModel: Boolean = false, + ): Result { + val hash = skin.textureHash + ?: return Result.failure(OutfitException("Minecraft didn't report a usable skin texture.")) + val key = owner to hash + if (background) { + recordFailures[key]?.let { failure -> + if (System.currentTimeMillis() - failure.at < RECORD_FAILURE_BACKOFF_MS) { + val reason = if (failure.conflict) "the skin library is full" else "the last attempt failed recently" + return Result.failure(SkinRecordSkipped(reason)) + } + } + } + val label = name?.trim()?.takeIf { it.isNotEmpty() } + val body = buildJsonObject { + put("texture_hash", hash) + put("model", skin.model.serializedName) + if (label != null) put("name", label) + } + val recorded = OutfitApi.send(HttpMethod.Post, "/skins", body) + .map { it.toInfo() } + .onSuccess { recorded -> + recordFailures.remove(key) + if (loadedFor == owner) replace(recorded) + } + .onFailure { + LOGGER.error("Failed to save skin {}", hash, it) + val conflict = (it as? ClientRequestException)?.response?.status == HttpStatusCode.Conflict + recordFailures[key] = RecordFailure(System.currentTimeMillis(), conflict) + } + .getOrElse { return Result.failure(it) } + if (!correctModel || recorded.model == skin.model) return Result.success(recorded) + return OutfitApi.send(HttpMethod.Patch, "/skins/${recorded.id}", buildJsonObject { put("model", skin.model.serializedName) }) + .map { it.toInfo() } + .onSuccess { updated -> if (loadedFor == owner) replace(updated) } + .onFailure { LOGGER.warn("Failed to update the arm model of skin {}", recorded.id, it) } + .recover { recorded } + } + + private suspend fun refreshLocked(): Result = requestLock.withLock { + failed.clear() + if (!OutfitApi.allowed()) { + skins = emptyList() + state = LibraryState.Unavailable(OutfitApi.PRIVACY_MESSAGE) + return@withLock OutfitApi.privacyFailure() + } + val owner = ClientPlatform.localPlayerUuid() + if (state !is LibraryState.Loaded || loadedFor != owner) state = LibraryState.Loading + try { + OutfitApi.send(HttpMethod.Get, "/skins") + .onSuccess { response -> + skins = response.skins.map { it.toInfo() } + limit = response.limit + loadedFor = owner + state = LibraryState.Loaded + } + .onFailure { error -> + LOGGER.error("Failed to fetch skins", error) + if (state !is LibraryState.Loaded) state = LibraryState.Unavailable(failureMessage(error)) + } + .map { } + } finally { + if (state is LibraryState.Loading) state = LibraryState.Idle + } + } + + private suspend fun patch(id: Int, body: JsonObject): Result { + if (!OutfitApi.allowed()) return unavailable() + return OutfitApi.send(HttpMethod.Patch, "/skins/$id", body) + .map { it.toInfo() } + .onSuccess(::replace) + .onFailure { + LOGGER.error("Failed to update skin {}", id, it) + forgetIfMissing(id, it) + } + } + + private fun forgetIfMissing(id: Int, error: Throwable) { + if ((error as? ClientRequestException)?.response?.status == HttpStatusCode.NotFound) forget(id) + } + + private fun forget(id: Int) { + skins = skins.filter { it.id != id } + OutfitLibrary.onSkinDeleted(id) + } + + private suspend fun download(url: String): Result = runSuspendCatching { + val response = runSuspendCatching { PolyPlusClient.HTTP.get(url) } + .getOrElse { throw OutfitException(DOWNLOAD_FAILED) } + if (!response.status.isSuccess()) throw OutfitException(DOWNLOAD_FAILED) + val length = response.contentLength() + if (length != null && length > MAX_DOWNLOAD_BYTES) throw OutfitException(NOT_A_SKIN) + runSuspendCatching { response.bodyAsBytes() }.getOrElse { throw OutfitException(DOWNLOAD_FAILED) } + }.onFailure { LOGGER.warn("Failed to download a skin from {}", url, it) } + + private fun validate(png: ByteArray) { + if (png.size > MAX_SKIN_BYTES) throw OutfitException(TOO_LARGE) + if (png.size < PNG_SIGNATURE.size || !PNG_SIGNATURE.indices.all { png[it] == PNG_SIGNATURE[it] }) { + throw OutfitException("Skins must be PNG images.") + } + val size = runCatching { + ImageIO.createImageInputStream(ByteArrayInputStream(png)).use { input -> + val reader = ImageIO.getImageReaders(input).asSequence().firstOrNull() ?: return@use null + try { + reader.input = input + reader.getWidth(0) to reader.getHeight(0) + } finally { + reader.dispose() + } + } + }.getOrNull() ?: throw OutfitException(NOT_A_SKIN) + if (size.first != 64 || (size.second != 64 && size.second != 32)) throw OutfitException(NOT_A_SKIN) + runCatching { ImageIO.read(ByteArrayInputStream(png)) }.getOrNull() ?: throw OutfitException(NOT_A_SKIN) + } + + private fun loadBitmap(skin: PlayerSkinInfo, key: String) { + PolyPlusClient.SCOPE.launch { + runSuspendCatching { + val image = withContext(Dispatchers.IO) { readCache(skin.hash) } + ?: download(skin.url).getOrThrow().let { fresh -> + withContext(Dispatchers.IO) { + val decoded = decode(fresh) ?: throw OutfitException(NOT_A_SKIN) + writeCache(skin.hash, fresh) + decoded + } + } + bitmaps[key] = previewImage(image).toComposeImageBitmap() + failed.remove(key) + bitmapVersion++ + }.onFailure { + failed[key] = System.currentTimeMillis() + BITMAP_RETRY_MS + LOGGER.debug("Could not load the preview for skin {}", skin.id, it) + } + loading.remove(key) + } + } + + private fun previewImage(source: BufferedImage): BufferedImage { + val image = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_ARGB) + image.createGraphics().apply { drawImage(source, 0, 0, null) }.dispose() + opaque(image, 0, 0, 32, 16) + if (image.height <= 32) clearIfOpaque(image, 32, 0, 64, 32) + opaque(image, 0, 16, 64, 32) + opaque(image, 16, 48, 48, 64) + return image + } + + private fun clearIfOpaque(image: BufferedImage, x0: Int, y0: Int, x1: Int, y1: Int) { + for (x in x0 until minOf(x1, image.width)) { + for (y in y0 until minOf(y1, image.height)) { + if (image.getRGB(x, y) ushr 24 < 128) return + } + } + for (x in x0 until minOf(x1, image.width)) { + for (y in y0 until minOf(y1, image.height)) { + image.setRGB(x, y, image.getRGB(x, y) and 0xFFFFFF) + } + } + } + + private fun opaque(image: BufferedImage, x0: Int, y0: Int, x1: Int, y1: Int) { + for (x in x0 until minOf(x1, image.width)) { + for (y in y0 until minOf(y1, image.height)) { + image.setRGB(x, y, image.getRGB(x, y) or (0xFF shl 24)) + } + } + } + + private fun cacheFile(hash: String): File? = + hash.lowercase().takeIf { HASH_PATTERN.matches(it) }?.let { File(cacheDir, "$it.png") } + + private fun readCache(hash: String): BufferedImage? { + val file = cacheFile(hash)?.takeIf { it.isFile } ?: return null + val image = runCatching { file.readBytes() }.getOrNull()?.let(::decode) + if (image == null) runCatching { file.delete() } + return image + } + + private fun decode(bytes: ByteArray): BufferedImage? = + runCatching { ImageIO.read(ByteArrayInputStream(bytes)) }.getOrNull() + + private fun writeCache(hash: String, bytes: ByteArray) { + val file = cacheFile(hash) ?: return + if (file.isFile) return + if (!cacheDir.isDirectory && !cacheDir.mkdirs()) return + file.writeBytes(bytes) + } + + private fun replace(skin: PlayerSkinInfo) { + val existing = skins + skins = if (existing.any { it.id == skin.id }) { + existing.map { if (it.id == skin.id) skin else it } + } else { + listOf(skin) + existing + } + } + + private fun mojangFailure(error: Throwable): Throwable = MojangSkinException(MojangSkinService.failureMessage(error)) + + private fun unavailable(): Result { + state = LibraryState.Unavailable(OutfitApi.PRIVACY_MESSAGE) + return OutfitApi.privacyFailure() + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheelKeybind.kt b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheelKeybind.kt index 10e6ab60..f4eec537 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheelKeybind.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheelKeybind.kt @@ -1,6 +1,11 @@ package org.polyfrost.polyplus.client.emotes import net.minecraft.client.Minecraft +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind +import org.polyfrost.oneconfig.internal.ui.components.asRenderText +import org.polyfrost.oneconfig.internal.ui.components.localizedTitle +import org.polyfrost.oneconfig.internal.ui.keybind.collectAllKeybindGroups +import org.polyfrost.polyplus.client.PolyPlusConfig import org.polyfrost.polyplus.client.gui.EmoteWheelScreen object EmoteWheelKeybind { @@ -30,4 +35,22 @@ object EmoteWheelKeybind { *///?} } } + + fun isBound(): Boolean = PolyPlusConfig.emoteWheelKeybind.isBound + + fun conflictingKeybindNames(): List { + val wheel = PolyPlusConfig.emoteWheelKeybind + if (!wheel.isBound) return emptyList() + return runCatching { + collectAllKeybindGroups().asSequence() + .flatMap { it.entries.asSequence() } + .mapNotNull { entry -> + val other = entry.prop.get() as? OneConfigKeybind ?: return@mapNotNull null + if (other === wheel || !wheel.conflictsWith(other)) return@mapNotNull null + entry.prop.localizedTitle().asRenderText().ifBlank { entry.path } + } + .distinct() + .toList() + }.getOrDefault(emptyList()) + } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt new file mode 100644 index 00000000..be293a38 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/emotes/EmoteWheels.kt @@ -0,0 +1,106 @@ +package org.polyfrost.polyplus.client.emotes + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.serialization.Serializable +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.PolyPlusConstants +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import java.io.File + +object EmoteWheels { + const val SLOT_COUNT = 8 + const val MAX_WHEELS = 8 + + private val LOGGER = LogManager.getLogger("polyplus/emote-wheels") + private val file = File("${PolyPlusConstants.NAME}/emote-wheels.json") + + @Serializable + private data class Stored(val active: Int = 0, val wheels: List> = emptyList()) + + private var stored by mutableStateOf(load()) + + val wheelCount: Int get() = stored.wheels.size.coerceAtLeast(1) + + val activeIndex: Int get() = stored.active.coerceIn(0, wheelCount - 1) + + fun slots(index: Int = activeIndex): List { + val owned = CosmeticCatalog.ownedEmoteIds() + val wheel = stored.wheels.getOrNull(index) ?: return defaultSlots(owned) + return List(SLOT_COUNT) { slot -> wheel.getOrNull(slot)?.takeIf { it in owned } } + } + + fun assign(index: Int, slot: Int, emoteId: Int?) { + if (slot !in 0 until SLOT_COUNT) return + val wheels = materialized().toMutableList() + while (wheels.size <= index) wheels += List(SLOT_COUNT) { null } + val next = MutableList(SLOT_COUNT) { wheels[index].getOrNull(it) } + if (emoteId != null) { + for (i in next.indices) if (next[i] == emoteId) next[i] = null + } + next[slot] = emoteId + wheels[index] = next + update(stored.copy(wheels = wheels)) + } + + fun addToFirstEmpty(index: Int, emoteId: Int): Int? { + val current = slots(index) + if (emoteId in current) return current.indexOf(emoteId) + val raw = stored.wheels.getOrNull(index)?.let { wheel -> List(SLOT_COUNT) { wheel.getOrNull(it) } } ?: current + val slot = raw.indexOfFirst { it == null }.takeIf { it >= 0 } + ?: current.indexOfFirst { it == null }.takeIf { it >= 0 } + ?: return null + assign(index, slot, emoteId) + return slot + } + + fun addWheel(): Boolean { + if (wheelCount >= MAX_WHEELS) return false + val wheels = materialized() + listOf(List(SLOT_COUNT) { null }) + update(Stored(active = wheels.lastIndex, wheels = wheels)) + return true + } + + fun removeWheel(index: Int) { + val wheels = materialized() + if (wheels.size <= 1 || index !in wheels.indices) return + val next = wheels.filterIndexed { i, _ -> i != index } + update(Stored(active = activeIndex.coerceAtMost(next.lastIndex), wheels = next)) + } + + fun setActive(index: Int) { + if (index == activeIndex || index !in 0 until wheelCount) return + update(stored.copy(active = index)) + } + + fun cycle(delta: Int) { + if (wheelCount <= 1) return + setActive(Math.floorMod(activeIndex + delta, wheelCount)) + } + + private fun defaultSlots(owned: Set): List { + val ids = CosmeticCatalog.allEmoteDefinitions() + .filter { it.id in owned } + .sortedBy { it.id } + .take(SLOT_COUNT) + .map { it.id } + return List(SLOT_COUNT) { ids.getOrNull(it) } + } + + private fun materialized(): List> = + stored.wheels.ifEmpty { listOf(slots(0)) } + + private fun update(next: Stored) { + stored = next + runCatching { + file.parentFile?.mkdirs() + file.writeText(PolyPlusClient.JSON.encodeToString(Stored.serializer(), next)) + }.onFailure { LOGGER.warn("Failed to save emote wheels", it) } + } + + private fun load(): Stored = runCatching { + if (file.isFile) PolyPlusClient.JSON.decodeFromString(Stored.serializer(), file.readText()) else Stored() + }.onFailure { LOGGER.warn("Failed to read emote wheels; starting fresh", it) }.getOrDefault(Stored()) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt index b4aaf90b..5d715914 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/EmoteWheelScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment @@ -41,6 +42,7 @@ import androidx.compose.ui.unit.sp import net.minecraft.client.Minecraft import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.Logger +import org.polyfrost.oneconfig.api.platform.v1.DesktopHelper import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -49,6 +51,8 @@ import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticService import org.polyfrost.polyplus.client.emotes.EmoteWheelKeybind +import org.polyfrost.polyplus.client.emotes.EmoteWheels +import kotlin.math.abs import kotlin.math.atan2 import kotlin.math.sqrt import kotlinx.coroutines.isActive @@ -72,6 +76,9 @@ private const val RING_STROKE_W = 1f private const val SLOT_COUNT = 8 private const val SECTOR_DEG = 360f / SLOT_COUNT +private const val SCROLL_NOTCH = 8f +private const val MAC_SCROLL_NOTCH = 2f + private val PopInEasing = CubicBezierEasing(0.0f, 0.55f, 0.45f, 1.0f) private data class WheelSlot(val id: Int, val name: String) @@ -134,14 +141,12 @@ class EmoteWheelScreen : ComposeScreen(RenderMode.CONTINUOUS) { @OptIn(ExperimentalComposeUiApi::class) @Composable private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { - val slots = remember { - val ownedIds = CosmeticCatalog.ownedEmoteIds() - val owned = CosmeticCatalog.allEmoteDefinitions() - .filter { it.id in ownedIds } - .sortedBy { it.id } - .take(SLOT_COUNT) - .map { WheelSlot(it.id, it.name) } - List(SLOT_COUNT) { index -> owned.getOrNull(index) } + val wheelIndex = EmoteWheels.activeIndex + val wheelCount = EmoteWheels.wheelCount + val slots = remember(wheelIndex, wheelCount) { + EmoteWheels.slots(wheelIndex).map { id -> + id?.let { emoteId -> CosmeticCatalog.getEmoteDefinition(emoteId)?.let { WheelSlot(it.id, it.name) } } + } } var mouseXRoot by remember { mutableFloatStateOf(0f) } @@ -166,7 +171,7 @@ private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { val dy = mouseYRoot - cy val dist = sqrt(dx * dx + dy * dy) if (dist in innerRadiusPx * popIn.value..wheelRadiusPx * popIn.value) { - val angle = (Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() + 90f + 360f) % 360f + val angle = (Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() + 90f + SECTOR_DEG / 2f + 360f) % 360f (angle / SECTOR_DEG).toInt().coerceIn(0, SLOT_COUNT - 1) } else { -1 @@ -174,15 +179,17 @@ private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { } } + val currentSlots by rememberUpdatedState(slots) LaunchedEffect(Unit) { while (isActive) { withFrameNanos {} if (!EmoteWheelKeybind.isHeld()) { - onRelease(slots.getOrNull(hoveredSlot)?.id) + onRelease(currentSlots.getOrNull(hoveredSlot)?.id) break } } } + val scrollAccum = remember { FloatArray(1) } Box( modifier = Modifier @@ -192,6 +199,16 @@ private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { val pos = event.changes.firstOrNull()?.position ?: return@onPointerEvent mouseXRoot = pos.x + rootPos.x mouseYRoot = pos.y + rootPos.y + } + .onPointerEvent(PointerEventType.Scroll) { event -> + val delta = event.changes.firstOrNull()?.scrollDelta?.y ?: return@onPointerEvent + val notch = if (DesktopHelper.isMac) MAC_SCROLL_NOTCH else SCROLL_NOTCH + scrollAccum[0] += delta + while (abs(scrollAccum[0]) >= notch) { + val step = if (scrollAccum[0] > 0f) 1 else -1 + EmoteWheels.cycle(step) + scrollAccum[0] -= step * notch + } }, contentAlignment = Alignment.Center, ) { @@ -204,6 +221,17 @@ private fun EmoteWheelContent(logger: Logger, onRelease: (Int?) -> Unit) { ) val hoveredName = slots.getOrNull(hoveredSlot)?.name + if (hoveredName == null && wheelCount > 1) { + BasicText( + text = "Wheel ${wheelIndex + 1} of $wheelCount", + style = TextStyle( + color = LocalTheme.current.textColorSecondary, + fontSize = 12.sp, + fontFamily = LocalTheme.current.typography.family, + textAlign = TextAlign.Center, + ), + ) + } if (hoveredName != null) { Box( modifier = Modifier.widthIn(max = INNER_DIAMETER - 48.dp), @@ -258,7 +286,7 @@ private fun WheelCanvas( val center = Offset(cx, cy) for (i in 0 until SLOT_COUNT) { - val startDeg = -90f + i * SECTOR_DEG + val startDeg = -90f - SECTOR_DEG / 2f + i * SECTOR_DEG val hasEmote = slots.getOrNull(i) != null val isHovered = i == hoveredSlot && hasEmote diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt index e3daa6b9..57f3518a 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusCosmeticsScreen.kt @@ -1,152 +1,60 @@ package org.polyfrost.polyplus.client.gui -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import net.minecraft.client.gui.screens.TitleScreen import org.polyfrost.oneconfig.api.ui.v1.OneConfigUI -import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus -import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.navigation.NavigationGroup import org.polyfrost.oneconfig.internal.ui.navigation.NavigationRoute import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModsGraph -import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.cosmetics.BillingService -import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog -import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment -import org.polyfrost.polyplus.client.cosmetics.CosmeticGroupView import org.polyfrost.polyplus.client.cosmetics.CosmeticLoadProgress import org.polyfrost.polyplus.client.cosmetics.CosmeticService import org.polyfrost.polyplus.client.cosmetics.CosmeticStore -import org.polyfrost.polyplus.client.gui.preview.PlayerPreview -import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource -import org.polyfrost.polyplus.client.network.http.responses.BodySlot -import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo -import org.polyfrost.polyplus.client.network.http.responses.CosmeticType -import org.polyfrost.polyplus.client.network.http.responses.TransactionInfo -import org.polyfrost.polyplus.client.network.http.responses.TransactionStatus +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.gui.wardrobe.CartEntry +import org.polyfrost.polyplus.client.gui.wardrobe.CosmeticsLoadingScreen +import org.polyfrost.polyplus.client.gui.wardrobe.MinecraftCapesOnlyScreen +import org.polyfrost.polyplus.client.gui.wardrobe.WardrobeShell +import org.polyfrost.polyplus.client.gui.wardrobe.cartName +import org.polyfrost.polyplus.client.gui.wardrobe.checkoutFailureMessage +import org.polyfrost.polyplus.client.gui.wardrobe.cosmeticCartKey +import org.polyfrost.polyplus.client.gui.wardrobe.equip +import org.polyfrost.polyplus.client.gui.wardrobe.rememberCosmeticItems +import org.polyfrost.polyplus.client.gui.wardrobe.selectedVariantId +import org.polyfrost.polyplus.client.gui.wardrobe.toCartEntry +import org.polyfrost.polyplus.client.gui.wardrobe.unequip import org.polyfrost.polyplus.client.utils.ClientPlatform import org.polyfrost.polyplus.privacy.PrivacyConsent -import java.awt.Color as AwtColor -import java.time.Duration -import java.time.Instant -import java.time.OffsetDateTime -import kotlin.math.ceil -import kotlin.math.roundToInt -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable @Serializable data object PolyPlusCosmeticsRoute object PolyPlusOneConfigIntegration { private val cosmeticsRoute = NavigationRoute( - id = "cosmetics", + id = "wardrobe", icon = "assets/polyplus/ico/stars.svg", route = PolyPlusCosmeticsRoute, ) @JvmStatic fun navigationGroups(original: List): List { - if (!PrivacyConsent.allowsOnlineServices()) return original - if (original.any { group -> group.routes.any { it.route == PolyPlusCosmeticsRoute } }) { return original } @@ -164,13 +72,19 @@ object PolyPlusOneConfigIntegration { @JvmStatic fun addRoutes(builder: NavGraphBuilder) { - if (!PrivacyConsent.allowsOnlineServices()) return builder.polyPlusCosmeticsGraph() } @JvmStatic fun openCosmetics() = openOneConfig(PolyPlusCosmeticsRoute) + fun openCosmeticsFromKeybind() { + when (ClientPlatform.currentScreen()) { + null, is TitleScreen, is PolyPlusMainMenuScreen -> openCosmetics() + else -> Unit + } + } + @JvmStatic fun openMods() = openOneConfig(ModsGraph) @@ -185,95 +99,14 @@ fun NavGraphBuilder.polyPlusCosmeticsGraph() { } } -private enum class PolyPlusTab { - Wardrobe, - Store, - History, -} - -private data class CartEntry( - val key: String, - val name: String, - val description: String?, - val basePrice: Float?, - val finalPrice: Float?, - val discountRate: Int?, - val storeProductId: String?, - val coverAssetId: Int? = null, -) { - val free: Boolean get() = (finalPrice ?: 0f) <= 0f - - val discounted: Boolean get() = (discountRate ?: 0) > 0 && !free -} - -private fun CosmeticStoreInfo.toCartEntry(variant: CosmeticVariantUi, storeProductId: String?): CartEntry = - CartEntry( - cosmeticCartKey(variant.id), - cartName(variant), - description, - basePrice, - finalPrice, - discountRate, - storeProductId, - coverAssetId, - ) - -private fun cosmeticCartKey(variantId: Int): String = "cosmetic-$variantId" - -private fun CosmeticStoreInfo.cartName(variant: CosmeticVariantUi): String = - if (variantList.size > 1 && !variant.name.equals(name, ignoreCase = true)) "$name - ${variant.name}" else name - -private data class CosmeticVariantUi(val id: Int, val name: String) - -private fun CosmeticStoreInfo.uiVariants(): List { - val byLabel = LinkedHashMap() - for (variant in variantList.sortedBy { it.variantOrder }) { - val label = variant.variantName ?: name - byLabel.getOrPut(label) { CosmeticVariantUi(variant.id, label) } - } - return byLabel.values.toList() -} - -private fun CosmeticStoreInfo.fullyOwned(ownedIds: Set): Boolean = - uiVariants().all { it.id in ownedIds } - -private fun selectedStoreVariant( - info: CosmeticStoreInfo, - picks: Map, - ownedIds: Set, -): CosmeticVariantUi { - val variants = info.uiVariants() - val picked = picks[info.id] - return variants.firstOrNull { it.id == picked } - ?: variants.firstOrNull { it.id !in ownedIds } - ?: variants.first() -} - -private fun checkoutFailureMessage(error: Throwable): String = - if (error is BillingService.AlreadyOwnedException) { - error.message ?: "You already own this item." - } else { - "Checkout failed: ${error.message}" - } - -private data class CosmeticUiItem( - val groupId: Int, - val type: CosmeticType, - val name: String, - val collection: String, - val owned: Boolean, - val equipped: Boolean, - val variants: List, - val equippedVariantId: Int?, -) { - val id: Int get() = groupId - val hasVariants: Boolean get() = variants.size > 1 -} - @Composable private fun PolyPlusCosmeticsScreen() { + DisposableEffect(Unit) { + ShellState.title = "Wardrobe" + onDispose { } + } if (!PrivacyConsent.allowsOnlineServices()) { - OnlineFeaturesDisabled() + MinecraftCapesOnlyScreen() return } var loadState by remember { mutableStateOf(CosmeticLoadProgress.snapshot()) } @@ -295,31 +128,18 @@ private fun PolyPlusCosmeticsScreen() { ) return } - var tab by remember { mutableStateOf(PolyPlusTab.Wardrobe) } - var tabResolved by remember { mutableStateOf(false) } - var showCart by remember { mutableStateOf(false) } var refreshKey by remember { mutableIntStateOf(0) } var manualRefreshKey by remember { mutableIntStateOf(0) } var status by remember { mutableStateOf(null) } val cart = remember { mutableStateListOf() } val allItems = rememberCosmeticItems(refreshKey) val ownedIds = remember(refreshKey) { CosmeticCatalog.ownedIds() } - var selectedId by remember { mutableStateOf(null) } - val selected = allItems.firstOrNull { it.id == selectedId } ?: allItems.firstOrNull() val variantPicks = remember { mutableStateMapOf() } var auraColor by remember { mutableStateOf(CosmeticCatalog.getParticleColor(ClientPlatform.localPlayerUuid())) } LaunchedEffect(refreshKey) { auraColor = CosmeticCatalog.getParticleColor(ClientPlatform.localPlayerUuid()) } - LaunchedEffect(allItems, tabResolved) { - if (tabResolved || allItems.isEmpty()) return@LaunchedEffect - if (allItems.none { it.owned }) { - tab = PolyPlusTab.Store - } - tabResolved = true - } - LaunchedEffect(Unit) { PolyPlusClient.refreshCosmeticsIfNeeded() while (true) { @@ -328,16 +148,21 @@ private fun PolyPlusCosmeticsScreen() { } } - fun checkout() { - val productIds = cart.mapNotNull { it.storeProductId } + var checkoutPending by remember { mutableStateOf(false) } + + fun checkout(productIds: List, claimed: Boolean = false) { + if (checkoutPending && !claimed) return if (productIds.isEmpty()) { + checkoutPending = false status = "Nothing purchasable in your cart yet." return } + checkoutPending = true status = "Opening checkout..." PolyPlusClient.SCOPE.launch { val result = BillingService.checkoutAndOpen(productIds) ClientPlatform.runOnMain { + checkoutPending = false status = result.fold( onSuccess = { "Checkout opened in your browser." }, onFailure = ::checkoutFailureMessage, @@ -346,2020 +171,116 @@ private fun PolyPlusCosmeticsScreen() { } } - Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Toolbar( - activeTab = tab, - cartSize = cart.size, - onWardrobe = { - tab = PolyPlusTab.Wardrobe - tabResolved = true - status = null - }, - onStore = { - tab = PolyPlusTab.Store - tabResolved = true - showCart = false - status = null - }, - onHistory = { - tab = PolyPlusTab.History - tabResolved = true - status = null - }, - onCart = { - tab = PolyPlusTab.Store - tabResolved = true - showCart = true - }, - onRefresh = { - PolyPlusClient.refreshCosmetics() - refreshKey++ - manualRefreshKey++ - status = "Refreshing cosmetic data..." - }, - ) - - when (tab) { - PolyPlusTab.Wardrobe -> WardrobeScreen( - items = allItems.filter { it.owned }, - selected = selected, - status = status, - variantPicks = variantPicks, - auraColor = auraColor, - onPreviewAuraColor = { color -> - auraColor = color - CosmeticCatalog.setParticleColor(ClientPlatform.localPlayerUuid(), color) - }, - onSelectAuraColor = { color -> - auraColor = color - status = if (color != null) "Updating aura color..." else "Clearing aura color..." - PolyPlusClient.SCOPE.launch { - val result = CosmeticService.setParticleColor(color) - ClientPlatform.runOnMain { - status = result.fold( - onSuccess = { if (color != null) "Aura color updated." else "Aura color cleared." }, - onFailure = { "Failed to set aura color: ${it.message}" }, - ) - } - } - }, - onSelect = { selectedId = it.id }, - onSelectVariant = { groupId, variantId -> - variantPicks[groupId] = variantId - val item = allItems.firstOrNull { it.groupId == groupId } - if (item != null && item.equipped && item.equippedVariantId != variantId) { - status = "Equipping ${item.name}..." - equip(item, variantId) { - refreshKey++ - status = it - } - } - }, - onEquip = { item -> - val variantId = selectedVariantId(item, variantPicks) - if (item.equipped && variantId == item.equippedVariantId) { - status = "Unequipping ${item.name}..." - unequip(item) { - refreshKey++ - status = it - } - } else if (variantId == null) { - status = "${item.name} has no variant to equip." - } else { - status = "Equipping ${item.name}..." - equip(item, variantId) { - refreshKey++ - status = it - } - } - }, - ) - - PolyPlusTab.Store -> StoreScreen( - cart = cart, - showCart = showCart, - status = status, - ownedIds = ownedIds, - onRemoveFromCart = { key -> cart.removeAll { it.key == key } }, - onBackToBrowse = { showCart = false }, - onCheckout = { checkout() }, - onAddToCart = { info, variant -> - val key = cosmeticCartKey(variant.id) - val label = info.cartName(variant) - if (variant.id in ownedIds) { - status = "You already own $label." - } else if (cart.any { it.key == key }) { - status = "$label is already in your cart." - } else { - status = "Adding $label to cart..." - PolyPlusClient.SCOPE.launch { - val view = CosmeticStore.view(variant.id).getOrNull() - ClientPlatform.runOnMain { - val priceId = view?.storeProductId - if (priceId.isNullOrBlank()) { - status = "$label isn't purchasable yet." - } else if (cart.none { it.key == key }) { - cart += info.toCartEntry(variant, priceId) - status = "$label added to cart." - } - } - } - } - }, - onBuyNow = { info, variant -> - val label = info.cartName(variant) - if (variant.id in ownedIds) { - status = "You already own $label." - return@StoreScreen - } - status = "Opening checkout..." - PolyPlusClient.SCOPE.launch { - val view = CosmeticStore.view(variant.id).getOrNull() - val priceId = view?.storeProductId - if (priceId.isNullOrBlank()) { - ClientPlatform.runOnMain { status = "$label isn't purchasable yet." } - } else { - val result = BillingService.checkoutAndOpen(listOf(priceId)) - ClientPlatform.runOnMain { - status = result.fold( - onSuccess = { "Checkout opened in your browser." }, - onFailure = ::checkoutFailureMessage, - ) - } - } - } - }, - ) - - PolyPlusTab.History -> HistoryScreen(ownedIds = ownedIds, reloadKey = manualRefreshKey) - } - } -} - -@Composable -private fun Toolbar( - activeTab: PolyPlusTab, - cartSize: Int, - onWardrobe: () -> Unit, - onStore: () -> Unit, - onHistory: () -> Unit, - onCart: () -> Unit, - onRefresh: () -> Unit, -) { - Row(modifier = Modifier.fillMaxWidth().height(33.dp), verticalAlignment = Alignment.CenterVertically) { - TabButton("Wardrobe", activeTab == PolyPlusTab.Wardrobe, onWardrobe) - Spacer(Modifier.width(8.dp)) - TabButton("Store", activeTab == PolyPlusTab.Store, onStore) - Spacer(Modifier.width(8.dp)) - TabButton("History", activeTab == PolyPlusTab.History, onHistory) - Spacer(Modifier.weight(1f)) - SmallButton("Refresh", iconPath = "refresh", primary = false, onClick = onRefresh) - Spacer(Modifier.width(8.dp)) - SmallButton("Cart ($cartSize)", iconPath = "assets/polyplus/ico/shopping-cart/0.svg", primary = true, onClick = onCart) - } -} - -@Composable -private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) { - Box( - modifier = Modifier.height(33.dp) - .clip(ppShape(7.dp)) - .background(if (selected) Accent else LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(7.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 14.dp), - contentAlignment = Alignment.Center, - ) { - SocialText( - label, - color = if (selected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - } -} - -@Composable -private fun SmallButton( - label: String, - iconPath: String, - primary: Boolean, - onClick: () -> Unit, -) { - Row( - modifier = Modifier.height(33.dp) - .clip(ppShape(7.dp)) - .background(if (primary) Accent else LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(7.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(iconPath, color = if (primary) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, modifier = Modifier.size(14.dp)) - Spacer(Modifier.width(8.dp)) - SocialText(label, color = if (primary) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) - } -} - -@Composable -private fun WardrobeScreen( - items: List, - selected: CosmeticUiItem?, - status: String?, - variantPicks: Map, - auraColor: Int?, - onPreviewAuraColor: (Int) -> Unit, - onSelectAuraColor: (Int?) -> Unit, - onSelect: (CosmeticUiItem) -> Unit, - onSelectVariant: (Int, Int) -> Unit, - onEquip: (CosmeticUiItem) -> Unit, -) { - var selectedType by remember { mutableStateOf(CosmeticType.Cape) } - var stockedTypes by remember { - mutableStateOf(CosmeticType.entries.filter { it != CosmeticType.Unknown }) - } - LaunchedEffect(Unit) { - stockedTypes = CosmeticStore.stockedTypes() - } - - val railTypes = remember(stockedTypes, items) { - val owned = items.mapTo(mutableSetOf()) { it.type } - CosmeticType.entries.filter { - it != CosmeticType.Unknown && (it in stockedTypes || it in owned) - } - } - - LaunchedEffect(railTypes) { - if (railTypes.isNotEmpty() && selectedType !in railTypes) { - selectedType = railTypes.first() - } - } - - val displayItems = remember(items, selectedType) { - items.filter { it.type == selectedType } - } - - LaunchedEffect(selectedType, displayItems.isEmpty()) { - if (selected?.type != selectedType) { - displayItems.firstOrNull()?.let(onSelect) - } - } - - val previewItem = selected?.takeIf { it.type == selectedType } ?: displayItems.firstOrNull() - Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(19.dp)) { - CategoryRail( - selected = selectedType, - onSelect = { selectedType = it }, - types = railTypes, - ) - CosmeticGrid( - items = displayItems, - modifier = Modifier.width(596.dp).fillMaxHeight(), - onSelect = onSelect, - onEquip = onEquip, - ) - PreviewPanel( - selected = previewItem, - status = status, - selectedVariantId = previewItem?.let { selectedVariantId(it, variantPicks) }, - onSelectVariant = { variantId -> - previewItem?.let { onSelectVariant(it.groupId, variantId) } - }, - showAuraColor = selectedType == CosmeticType.Aura, - auraColor = auraColor, - onPreviewAuraColor = onPreviewAuraColor, - onSelectAuraColor = onSelectAuraColor, - modifier = Modifier.weight(1f).fillMaxHeight(), - ) - } -} - -@Composable -private fun HistoryScreen(ownedIds: Set, reloadKey: Int) { - var transactions by remember { mutableStateOf>(emptyList()) } - var loading by remember { mutableStateOf(true) } - var loadError by remember { mutableStateOf(null) } - - // keyed on ownership rather than the catalog poll so transactions only refetch when a purchase lands - LaunchedEffect(ownedIds, reloadKey) { - loading = true - loadError = null - BillingService.fetchTransactions() - .onSuccess { transactions = it } - .onFailure { loadError = "Couldn't load transactions: ${it.message}" } - loading = false - } - - Box( - modifier = Modifier.fillMaxSize().clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) - .padding(18.dp), - ) { - when { - loading && transactions.isEmpty() -> CenteredNote("Loading transactions...") - loadError != null && transactions.isEmpty() -> CenteredNote(loadError!!) - transactions.isEmpty() -> CenteredNote("No transactions yet.") - else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) { - items(transactions, key = { it.id }) { tx -> TransactionRow(tx) } - } - } - } -} - -@Composable -private fun TransactionRow(tx: TransactionInfo) { - Row( - modifier = Modifier.fillMaxWidth() - .clip(ppShape(8.dp)) - .border(1.dp, LocalTheme.current.borderColor, ppShape(8.dp)) - .padding(horizontal = 13.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(13.dp), - ) { - Column(Modifier.weight(1f)) { - SocialText("Order #${tx.id}", color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - SocialText(tx.provider.displayName, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - tx.amount?.let { SocialText(money(it, tx.currency), color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) } - SocialText( - tx.status.displayName, - color = statusColor(tx.status), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - ) - } -} - -private fun statusColor(status: TransactionStatus): Color = when (status) { - TransactionStatus.Completed -> Color(0xFF239A60) - TransactionStatus.Pending -> Color(0xFFE0A030) - TransactionStatus.Failed -> Color(0xFFFF4444) - TransactionStatus.Refunded, TransactionStatus.PartiallyRefunded, TransactionStatus.Chargeback -> Color(0xFF8A9296) - TransactionStatus.Unknown -> Color(0xFF8A9296) -} - -@Composable -private fun CategoryRail( - selected: CosmeticType, - onSelect: (CosmeticType) -> Unit, - types: List = CosmeticType.entries.filter { it != CosmeticType.Unknown }, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(58.dp)) { - types.forEach { type -> - val isSelected = type == selected - Box( - modifier = Modifier.fillMaxWidth().height(57.dp) - .clip(ppShape(7.dp)) - .background(if (isSelected) Accent else LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(7.dp)) - .clickable { onSelect(type) }, - contentAlignment = Alignment.Center, - ) { - SocialText( - type.displayName, - color = if (isSelected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, - fontSize = 12.sp, - textAlign = TextAlign.Center, - ) - } - } - } -} - -@Composable -private fun CosmeticGrid( - items: List, - modifier: Modifier, - onSelect: (CosmeticUiItem) -> Unit, - onEquip: (CosmeticUiItem) -> Unit, -) { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(19.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - items(items) { item -> - CosmeticCard( - item = item, - onSelect = { onSelect(item) }, - onEquip = { onEquip(item) }, - ) - } - } -} - -@Composable -private fun CosmeticCard( - item: CosmeticUiItem, - onSelect: () -> Unit, - onEquip: () -> Unit, -) { - val border = if (item.equipped) Accent else LocalTheme.current.borderColor - val buttonColor = if (item.equipped) Accent else Color(0xB3232D32) - val activate = { onSelect(); onEquip() } - - Box( - modifier = Modifier.size(180.dp, 258.dp) - .clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, border, ppShape(12.dp)) - .clickable(onClick = activate), - ) { - val (source, loadTick) = rememberCosmeticPreviewSource(item) - CosmeticThumbnail( - source = source, - type = item.type, - previewKey = "card-${item.groupId}-$loadTick", - modifier = Modifier.offset(17.dp, 17.dp).size(144.dp), - ) - CardLabel(item.name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.offset(17.dp, 169.dp).width(146.dp)) - CardLabel(item.collection, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, modifier = Modifier.offset(17.dp, 192.dp).width(146.dp)) - - Row( - modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(36.dp) - .background(buttonColor) - .clickable(onClick = activate), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - SocialText(if (item.equipped) "Equipped" else "Equip", color = LocalTheme.current.accentTextColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - } - } -} - -@Composable -private fun PriceLabel(basePrice: Float?, finalPrice: Float?, discounted: Boolean) { - when { - finalPrice == null -> SocialText("—", color = LocalTheme.current.textColorSecondary, fontSize = 14.sp) - finalPrice <= 0f -> SocialText("FREE", color = Color(0xFF239A60), fontSize = 14.sp, fontWeight = FontWeight.Medium) - discounted && basePrice != null -> { - SocialText(money(basePrice), color = Color(0xFFFF4444), fontSize = 10.sp, textDecoration = TextDecoration.LineThrough) - Spacer(Modifier.width(4.dp)) - SocialText(money(finalPrice), color = Color(0xFF239A60), fontSize = 14.sp, fontWeight = FontWeight.Medium) - } - else -> SocialText(money(finalPrice), color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - } -} - -@Composable -private fun PriceLabel(info: CosmeticStoreInfo) = PriceLabel(info.basePrice, info.finalPrice, info.discounted) - -@Composable -private fun PriceLabel(entry: CartEntry) = PriceLabel(entry.basePrice, entry.finalPrice, entry.discounted) - -@Composable -private fun PreviewPanel( - selected: CosmeticUiItem?, - status: String?, - modifier: Modifier, - selectedVariantId: Int? = null, - onSelectVariant: (Int) -> Unit = {}, - showAuraColor: Boolean = false, - auraColor: Int? = null, - onPreviewAuraColor: (Int) -> Unit = {}, - onSelectAuraColor: (Int?) -> Unit = {}, -) { - Box( - modifier = modifier.clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), - ) { - val hasHeadCosmetic = CosmeticCatalog.localEquipped().equipped - .containsKey(BodySlot.Hat) - PlayerPreview( - Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), - source = PlayerPreviewSource.LocalLive, - autoSpin = false, - verticalAnchor = if (hasHeadCosmetic) 0.64f else 0.5f, - initialYaw = FRONT_YAW_DEG, - live = true, - ) - Column(Modifier.align(Alignment.TopStart).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (selected != null) { - Column { - SocialText(selected.name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - SocialText(selected.collection, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - } - if (status != null) { - SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - } - val variants = if (selected != null && selected.hasVariants) selected.variants else emptyList() - if (variants.isNotEmpty() || showAuraColor) { - PreviewPill( - variants = variants, - selectedVariantId = selectedVariantId, - onSelectVariant = onSelectVariant, - showAuraColor = showAuraColor, - auraColor = auraColor, - onPreviewAuraColor = onPreviewAuraColor, - onSelectAuraColor = onSelectAuraColor, - modifier = Modifier.align(Alignment.BottomCenter).padding(18.dp), - ) - } - } -} - -@Composable -private fun PreviewPill( - variants: List, - selectedVariantId: Int?, - onSelectVariant: (Int) -> Unit, - showAuraColor: Boolean, - auraColor: Int?, - onPreviewAuraColor: (Int) -> Unit, - onSelectAuraColor: (Int?) -> Unit, - modifier: Modifier = Modifier, -) { - var customOpen by remember { mutableStateOf(false) } - LaunchedEffect(showAuraColor) { if (!showAuraColor) customOpen = false } - - var pillHeight by remember { mutableIntStateOf(0) } - val popoverGapPx = with(LocalDensity.current) { 8.dp.roundToPx() } - - Box(modifier) { - Row( - modifier = Modifier - .onSizeChanged { pillHeight = it.height } - .clip(ppShape(PILL_RADIUS)) - .background(LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(PILL_RADIUS)) - .padding(horizontal = 8.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (variants.isNotEmpty()) { - VariantStrip( - variants = variants, - selectedVariantId = selectedVariantId, - onSelect = onSelectVariant, - ) - } - if (variants.isNotEmpty() && showAuraColor) { - Box(Modifier.width(1.dp).height(20.dp).background(LocalTheme.current.borderColor)) - } - if (showAuraColor) { - AuraSwatchRow( - selectedColor = auraColor, - customOpen = customOpen, - onToggleCustom = { customOpen = !customOpen }, - onCommit = onSelectAuraColor, - ) - } - } - if (customOpen && showAuraColor) { - Popup( - alignment = Alignment.TopEnd, - offset = IntOffset(0, pillHeight + popoverGapPx), - onDismissRequest = { customOpen = false }, - properties = PopupProperties(focusable = true), - ) { - AuraCustomPopover( - selectedColor = auraColor, - onPreview = onPreviewAuraColor, - onCommit = onSelectAuraColor, - ) - } - } - } -} - -@Composable -private fun RowScope.VariantStrip( - variants: List, - selectedVariantId: Int?, - onSelect: (Int) -> Unit, -) { - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - val wheelStep = with(LocalDensity.current) { VARIANT_WHEEL_STEP.toPx() } - val arrowStep = with(LocalDensity.current) { VARIANT_ARROW_STEP.toPx() } - val overflows = scrollState.maxValue > 0 && scrollState.maxValue != Int.MAX_VALUE - - if (overflows) { - ScrollArrow( - iconName = "left-arrow", - enabled = scrollState.value > 0, - onClick = { scope.launch { scrollState.animateScrollBy(-arrowStep) } }, - ) - } - Row( - modifier = Modifier.weight(1f, fill = false) - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.type != PointerEventType.Scroll) continue - val delta = event.changes.first().scrollDelta - if (delta.x == 0f && delta.y != 0f) { - scope.launch { scrollState.scrollBy(delta.y * wheelStep) } - event.changes.forEach { it.consume() } - } - } - } - } - .horizontalScroll(scrollState), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - for (variant in variants) { - val isSelected = variant.id == selectedVariantId - Box( - modifier = Modifier - .clip(ppShape(6.dp)) - .background(if (isSelected) Accent else LocalTheme.current.componentBackground) - .border(1.dp, if (isSelected) Accent else LocalTheme.current.borderColor, ppShape(6.dp)) - .clickable { onSelect(variant.id) } - .padding(horizontal = 10.dp, vertical = 5.dp), - ) { - SocialText( - variant.name, - color = if (isSelected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, - fontSize = 12.sp, - ) - } - } - } - if (overflows) { - ScrollArrow( - iconName = "right-arrow", - enabled = scrollState.value < scrollState.maxValue, - onClick = { scope.launch { scrollState.animateScrollBy(arrowStep) } }, - ) - } -} - -@Composable -private fun ScrollArrow(iconName: String, enabled: Boolean, onClick: () -> Unit) { - Box( - modifier = Modifier.size(18.dp) - .clip(ppShape(4.dp)) - .background(LocalTheme.current.componentBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(4.dp)) - .alpha(if (enabled) 1f else 0.35f) - .clickable(enabled = enabled, onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon(iconName, color = LocalTheme.current.textColor, modifier = Modifier.size(10.dp)) - } -} - -@Composable -private fun AuraSwatchRow( - selectedColor: Int?, - customOpen: Boolean, - onToggleCustom: () -> Unit, - onCommit: (Int?) -> Unit, -) { - Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { - AuraDot( - color = Color(DEFAULT_AURA_ARGB), - isSelected = selectedColor == null, - onClick = { onCommit(null) }, - ) - for (color in AURA_PRESETS) { - AuraDot( - color = Color(color), - isSelected = selectedColor == color, - onClick = { onCommit(color) }, - ) - } - val isCustom = selectedColor != null && selectedColor !in AURA_PRESETS - AuraDot( - color = if (isCustom) Color(selectedColor!!) else LocalTheme.current.componentBackground, - isSelected = isCustom || customOpen, - onClick = onToggleCustom, - ) { - if (!isCustom) { - SocialText("+", color = LocalTheme.current.textColorSecondary, fontSize = 11.sp) - } - } - } -} - -@Composable -private fun AuraDot( - color: Color, - isSelected: Boolean, - onClick: () -> Unit, - content: @Composable () -> Unit = {}, -) { - Box( - modifier = Modifier.size(18.dp) - .clip(ppShape(4.dp)) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Checkerboard(Modifier.fillMaxSize()) - Box(Modifier.fillMaxSize().background(color)) - Box( - Modifier.fillMaxSize().border( - width = if (isSelected) 2.dp else 1.dp, - color = if (isSelected) Accent else LocalTheme.current.borderColor, - shape = ppShape(4.dp), - ), - ) - content() - } -} - -@Composable -private fun VariantPicker( - variants: List, - selectedVariantId: Int?, - onSelect: (Int) -> Unit, - ownedVariantIds: Set = emptySet(), -) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.width(220.dp)) { - SocialText("Variant", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - for (row in variants.chunked(2)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - for (variant in row) { - val isSelected = variant.id == selectedVariantId - Box( - modifier = Modifier - .alpha(if (variant.id in ownedVariantIds && !isSelected) 0.55f else 1f) - .clip(ppShape(6.dp)) - .background(if (isSelected) Accent else LocalTheme.current.chipBackground) - .border(1.dp, if (isSelected) Accent else LocalTheme.current.borderColor, ppShape(6.dp)) - .clickable { onSelect(variant.id) } - .padding(horizontal = 10.dp, vertical = 5.dp), - ) { - SocialText( - variant.name, - color = if (isSelected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, - fontSize = 12.sp, - ) - } - } - } - } - } -} - -private val PILL_RADIUS = 18.dp - -private val VARIANT_WHEEL_STEP = 48.dp - -private val VARIANT_ARROW_STEP = 96.dp - -private val AURA_COLORS: List = listOf( - 0xFFFF4444.toInt(), - 0xFFFF9E3D.toInt(), - 0xFFF4D03F.toInt(), - 0xFF2ECC71.toInt(), - 0xFF1ABC9C.toInt(), - 0xFF3DA5FF.toInt(), - 0xFF6C5CE7.toInt(), - 0xFFB05CFF.toInt(), - 0xFFFF6FD8.toInt(), - 0xFFFFFFFF.toInt(), - 0xFF3A3F43.toInt(), - 0xFF9BA2A6.toInt(), -) - -private const val DEFAULT_AURA_ARGB: Int = -1 - -private val AURA_PRESETS: List = AURA_COLORS.filter { it != DEFAULT_AURA_ARGB } - -/** Converts an ARGB int to a HSB float array (hue in degrees, saturation and brightness as a float between 0 and 1). */ -private fun argbToHsb(argb: Int): FloatArray = - AwtColor.RGBtoHSB((argb shr 16) and 0xFF, (argb shr 8) and 0xFF, argb and 0xFF, null) - .also { it[0] *= 360f } - -private fun hsbToColor(hue: Float, saturation: Float, brightness: Float): Color = - Color.hsv(hue, saturation, brightness) - -private fun argbHex(argb: Int): String = - "%02X%02X%02X".format((argb shr 16) and 0xFF, (argb shr 8) and 0xFF, argb and 0xFF) - -private fun hexToArgb(hex: String, alpha: Float): Int? { - val h = hex.removePrefix("#").trim() - if (h.length != 6) return null - return try { - ((alpha.coerceIn(0f, 1f) * 255f).roundToInt() shl 24) or Integer.parseInt(h, 16) - } catch (_: Exception) { - null - } -} - -private fun Color.toArgb(alpha: Float): Int = - ((alpha.coerceIn(0f, 1f) * 255f).roundToInt() shl 24) or - ((red.coerceIn(0f, 1f) * 255f).roundToInt() shl 16) or - ((green.coerceIn(0f, 1f) * 255f).roundToInt() shl 8) or - (blue.coerceIn(0f, 1f) * 255f).roundToInt() - -private class AuraPickerState(argb: Int) { - var hue by mutableFloatStateOf(0f) - var saturation by mutableFloatStateOf(0f) - var brightness by mutableFloatStateOf(0f) - var alpha by mutableFloatStateOf(1f) - - var lastEmitted: Int? = null - - init { - applyFrom(argb) - } - - fun applyFrom(argb: Int) { - val hsb = argbToHsb(argb) - hue = hsb[0] - saturation = hsb[1] - brightness = hsb[2] - alpha = ((argb ushr 24) and 0xFF) / 255f - } - - fun currentArgb(): Int = hsbToColor(hue, saturation, brightness).toArgb(alpha) -} - -@Composable -private fun Checkerboard(modifier: Modifier, cell: Dp = 4.dp) { - Box( - modifier = modifier.drawBehind { - val step = cell.toPx() - val cols = ceil(size.width / step).toInt() - val rows = ceil(size.height / step).toInt() - for (x in 0..cols) { - for (y in 0..rows) { - drawRect( - color = if ((x + y) % 2 == 0) Color(0xFF5F6568) else Color(0xFF3D4245), - topLeft = Offset(x * step, y * step), - size = Size(step, step), + WardrobeShell( + items = allItems, + ownedIds = ownedIds, + cart = cart, + status = status, + variantPicks = variantPicks, + auraColor = auraColor, + historyReloadKey = manualRefreshKey, + onStatus = { status = it }, + onRefresh = { + PolyPlusClient.refreshCosmetics() + PolyPlusClient.SCOPE.launch { MojangCapeService.refresh() } + refreshKey++ + manualRefreshKey++ + status = "Refreshing cosmetic data..." + }, + onPreviewAuraColor = { color -> + auraColor = color + CosmeticCatalog.setParticleColor(ClientPlatform.localPlayerUuid(), color) + }, + onSelectAuraColor = { color -> + auraColor = color + status = if (color != null) "Updating aura color..." else "Clearing aura color..." + PolyPlusClient.SCOPE.launch { + val result = CosmeticService.setParticleColor(color) + ClientPlatform.runOnMain { + status = result.fold( + onSuccess = { if (color != null) "Aura color updated." else "Aura color cleared." }, + onFailure = { "Failed to set aura color: ${it.message}" }, ) } } }, - ) -} - -@Composable -private fun AuraCustomPopover( - selectedColor: Int?, - onPreview: (Int) -> Unit, - onCommit: (Int?) -> Unit, - modifier: Modifier = Modifier, -) { - val state = remember { AuraPickerState(selectedColor ?: 0xFFFFFFFF.toInt()) } - var hexText by remember { mutableStateOf(argbHex(state.currentArgb())) } - LaunchedEffect(selectedColor) { - if (selectedColor != null && selectedColor != state.lastEmitted) { - state.applyFrom(selectedColor) - hexText = argbHex(selectedColor) - } - } - - fun emitPreview() { - val argb = state.currentArgb() - state.lastEmitted = argb - hexText = argbHex(argb) - onPreview(argb) - } - - fun emitCommit() { - val argb = state.currentArgb() - state.lastEmitted = argb - onCommit(argb) - } - - val hue = state.hue - val saturation = state.saturation - val brightness = state.brightness - val alpha = state.alpha - val current = hsbToColor(hue, saturation, brightness) - - Column( - modifier = modifier.width(190.dp) - .clip(ppShape(10.dp)) - .background(LocalTheme.current.popupBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(10.dp)) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - SocialText("Aura Color", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - - var paneSize by remember { mutableStateOf(Size.Zero) } - Box( - modifier = Modifier.fillMaxWidth().height(120.dp) - .clip(ppShape(6.dp)) - .onSizeChanged { paneSize = Size(it.width.toFloat(), it.height.toFloat()) } - .drawWithCache { - val hueColor = hsbToColor(hue, 1f, 1f) - val horizontal = Brush.horizontalGradient(listOf(Color.White, hueColor)) - val vertical = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) - onDrawBehind { - drawRect(horizontal) - drawRect(vertical) - } + onSelectVariant = { groupId, variantId -> + variantPicks[groupId] = variantId + val item = allItems.firstOrNull { it.groupId == groupId && it.variants.any { variant -> variant.id == variantId } } + if (item != null && item.equipped && item.equippedVariantId != variantId) { + status = "Equipping ${item.name}..." + equip(item, variantId) { + refreshKey++ + status = it } - .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown() - fun update(x: Float, y: Float) { - state.saturation = (x / paneSize.width).coerceIn(0f, 1f) - state.brightness = 1f - (y / paneSize.height).coerceIn(0f, 1f) - emitPreview() - } - update(down.position.x, down.position.y) - do { - val event = awaitPointerEvent() - event.changes.forEach { ch -> - if (ch.pressed) { - update(ch.position.x, ch.position.y) - ch.consume() - } - } - } while (event.changes.any { it.pressed }) - emitCommit() - } - }, - ) { - val selX = (saturation * paneSize.width).coerceIn(0f, paneSize.width) - val selY = ((1f - brightness) * paneSize.height).coerceIn(0f, paneSize.height) - Box( - modifier = Modifier - .offset { IntOffset((selX - 7.dp.toPx()).roundToInt(), (selY - 7.dp.toPx()).roundToInt()) } - .size(14.dp) - .clip(ppShape(7.dp)) - .background(current) - .border(2.dp, Color.White, ppShape(7.dp)), - ) - } - - Box( - modifier = Modifier.fillMaxWidth().height(14.dp) - .clip(ppShape(4.dp)) - .drawWithCache { - val gradient = Brush.horizontalGradient( - listOf(Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red), - ) - onDrawBehind { drawRect(gradient) } - } - .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown() - fun update(x: Float) { - state.hue = ((x / size.width) * 360f).coerceIn(0f, 360f) - emitPreview() - } - update(down.position.x) - do { - val event = awaitPointerEvent() - event.changes.forEach { ch -> - if (ch.pressed) { - update(ch.position.x) - ch.consume() - } - } - } while (event.changes.any { it.pressed }) - emitCommit() - } - }, - ) { - var barWidth by remember { mutableStateOf(0f) } - Box(Modifier.fillMaxWidth().height(14.dp).onSizeChanged { barWidth = it.width.toFloat() }) - Box( - modifier = Modifier.align(Alignment.CenterStart) - .offset { IntOffset(((hue / 360f) * barWidth - 5.dp.toPx()).roundToInt().coerceAtLeast(0), 0) } - .size(10.dp) - .clip(ppShape(5.dp)) - .background(Color.White) - .border(1.dp, Color.White.copy(0.5f), ppShape(5.dp)), - ) - } - - Box( - modifier = Modifier.fillMaxWidth().height(14.dp) - .clip(ppShape(4.dp)) - .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown() - fun update(x: Float) { - state.alpha = (x / size.width).coerceIn(0f, 1f) - emitPreview() - } - update(down.position.x) - do { - val event = awaitPointerEvent() - event.changes.forEach { ch -> - if (ch.pressed) { - update(ch.position.x) - ch.consume() - } - } - } while (event.changes.any { it.pressed }) - emitCommit() - } - }, - ) { - Checkerboard(Modifier.fillMaxWidth().height(14.dp)) - Box( - modifier = Modifier.fillMaxWidth().height(14.dp) - .drawWithCache { - val gradient = Brush.horizontalGradient(listOf(current.copy(alpha = 0f), current)) - onDrawBehind { drawRect(gradient) } - }, - ) - var alphaBarWidth by remember { mutableStateOf(0f) } - Box(Modifier.fillMaxWidth().height(14.dp).onSizeChanged { alphaBarWidth = it.width.toFloat() }) - Box( - modifier = Modifier.align(Alignment.CenterStart) - .offset { IntOffset((alpha * alphaBarWidth - 5.dp.toPx()).roundToInt().coerceAtLeast(0), 0) } - .size(10.dp) - .clip(ppShape(5.dp)) - .background(Color.White) - .border(1.dp, Color.White.copy(0.5f), ppShape(5.dp)), - ) - } - - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Box(modifier = Modifier.size(28.dp).clip(ppShape(5.dp))) { - Checkerboard(Modifier.fillMaxSize()) - Box(Modifier.fillMaxSize().background(current.copy(alpha = alpha))) - Box(Modifier.fillMaxSize().border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp))) - } - BasicTextField( - value = hexText, - onValueChange = { input -> - val filtered = input.filter { it.isLetterOrDigit() }.take(6) - hexText = filtered - hexToArgb(filtered, state.alpha)?.let { argb -> - state.applyFrom(argb) - state.lastEmitted = argb - onCommit(argb) - } - }, - singleLine = true, - textStyle = TextStyle( - color = LocalTheme.current.textColor, - fontSize = 12.sp, - fontFamily = LocalTheme.current.typography.family, - ), - cursorBrush = SolidColor(LocalTheme.current.textColor), - modifier = Modifier.weight(1f) - .clip(ppShape(5.dp)) - .background(LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)) - .padding(horizontal = 8.dp, vertical = 6.dp) - .trackTextInputFocus(), - decorationBox = { inner -> - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - SocialText("#", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - inner() - } - }, - ) - } - - } -} - -@Composable -private fun CartPanel( - items: List, - status: String?, - modifier: Modifier, - onRemove: (String) -> Unit, - onCheckout: () -> Unit, -) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(11.dp)) { - if (items.isEmpty()) { - CenteredNote("Your cart is empty.") - return@Column - } - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(11.dp), - ) { - items(items, key = { it.key }) { item -> - CartRow(item = item, onRemove = { onRemove(item.key) }) - } - } - - val subtotal = items.sumOf { (it.basePrice ?: 0f).toDouble() }.toFloat() - val total = items.sumOf { (it.finalPrice ?: it.basePrice ?: 0f).toDouble() }.toFloat() - val discount = (subtotal - total).coerceAtLeast(0f) - RowTotals("Subtotal", money(subtotal), LocalTheme.current.textColor) - if (discount > 0f) { - RowTotals("Discounts", "-${money(discount)}", Color(0xFF239A60)) - } - RowTotals("Total", if (total <= 0f) "FREE" else money(total), Color.White, large = true) - if (status != null) { - SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - SmallButton( - label = "Checkout ${items.size} item${if (items.size == 1) "" else "s"}", - iconPath = "assets/polyplus/ico/shopping-cart/0.svg", - primary = true, - onClick = onCheckout, - ) - } -} - -@Composable -private fun CartRow(item: CartEntry, onRemove: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().height(81.dp) - .clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) - .padding(horizontal = 13.dp, vertical = 11.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(13.dp), - ) { - CoverThumbnail(item.coverAssetId, Modifier.size(58.dp)) - Column(Modifier.weight(1f)) { - SocialText(item.name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - item.description?.takeIf { it.isNotBlank() }?.let { - SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) } - } - Column(horizontalAlignment = Alignment.End) { - Row(verticalAlignment = Alignment.CenterVertically) { PriceLabel(item) } - if (item.discounted) SocialText("SAVE ${item.discountRate}%", color = Color(0xFF239A60), fontSize = 12.sp) - SocialText("Remove", color = Color(0xFFFF4444), fontSize = 12.sp, modifier = Modifier.clickable(onClick = onRemove)) - } - } -} - -@Composable -private fun RowTotals(label: String, value: String, color: Color, large: Boolean = false) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - SocialText(label, color = LocalTheme.current.textColor, fontSize = if (large) 18.sp else 12.sp, modifier = Modifier.weight(1f)) - SocialText(value, color = color, fontSize = if (large) 22.sp else 12.sp, fontWeight = if (large) FontWeight.SemiBold else FontWeight.Normal) - } -} - -@Composable -private fun CenteredNote(text: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - SocialText(text, color = LocalTheme.current.textColorSecondary, fontSize = 13.sp, textAlign = TextAlign.Center) - } -} - -@Composable -private fun CosmeticThumbnail( - source: PlayerPreviewSource?, - type: CosmeticType, - previewKey: String, - modifier: Modifier, -) { - val framing = cosmeticPreviewFraming(type) - Box(modifier) { - CheckerThumbnail(Modifier.fillMaxSize()) - if (source != null) { - PlayerPreview( - Modifier.fillMaxSize(), - source = source, - autoSpin = false, - allowDrag = false, - modelScale = framing.modelScale, - verticalAnchor = framing.verticalAnchor, - initialYaw = framing.yawDeg, - previewKey = previewKey, - live = needsLivePreview(type, source), - ) - } - } -} - -private fun needsLivePreview(type: CosmeticType, source: PlayerPreviewSource) = when (type) { - CosmeticType.Aura -> true - CosmeticType.Cape -> - (source as? PlayerPreviewSource.Override)?.capeCosmeticId?.let(CosmeticAssetCache::isCapeAnimated) == true - else -> false -} - -private data class PreviewFraming(val yawDeg: Float, val modelScale: Float, val verticalAnchor: Float) - -private const val FRONT_YAW_DEG = 180f + 22.9f -private const val BACK_YAW_DEG = 22.9f -private const val STORE_PREVIEW_FADE_FRACTION = 0.22f - -private fun cosmeticPreviewFraming(type: CosmeticType): PreviewFraming = when (type) { - CosmeticType.Hat, CosmeticType.Glasses -> PreviewFraming(FRONT_YAW_DEG, 1.0f, 1.5f) - CosmeticType.Boots -> PreviewFraming(FRONT_YAW_DEG, 0.85f, 0.26f) - CosmeticType.Shoulder -> PreviewFraming(FRONT_YAW_DEG, 0.58f, 0.62f) - CosmeticType.Wings -> PreviewFraming(BACK_YAW_DEG, 0.5f, 0.5f) - CosmeticType.Backpack -> PreviewFraming(BACK_YAW_DEG, 0.55f, 0.55f) - CosmeticType.Cape -> PreviewFraming(BACK_YAW_DEG, 0.42f, 0.5f) - CosmeticType.Aura -> PreviewFraming(FRONT_YAW_DEG, 0.5f, 0.5f) - CosmeticType.Glove -> PreviewFraming(FRONT_YAW_DEG, 0.7f, 0.42f) - else -> PreviewFraming(FRONT_YAW_DEG, 0.42f, 0.52f) -} - -@Composable -private fun rememberCosmeticPreviewSource(item: CosmeticUiItem): Pair { - val cosmeticId = item.equippedVariantId ?: item.variants.firstOrNull()?.id ?: return null to 0 - return rememberCosmeticPreviewSource(cosmeticId, item.type) -} - -@Composable -private fun rememberCosmeticPreviewSource(cosmeticId: Int, type: CosmeticType): Pair { - val isCape = type == CosmeticType.Cape - val isPet = type == CosmeticType.Pet - - var previewId by remember(cosmeticId) { mutableIntStateOf(cosmeticId) } - val loaded = CosmeticAssetCache.installs.let { isPreviewAssetLoaded(previewId, isCape, isPet) } - // a trim can evict a preview's assets while the screen is closed, so it reloads them when shown again - LaunchedEffect(cosmeticId, loaded) { - val slim = ClientPlatform.runOnMainSync { ClientPlatform.localSkinSlim() } - val id = CosmeticCatalog.resolveVariantForSkin(cosmeticId, slim) - previewId = id - if (!isPreviewAssetLoaded(id, isCape, isPet)) CosmeticAssetCache.ensureCosmeticLoaded(id) - } - - val loadTick = (if (loaded) 1 else 0) + (if (isCape && CosmeticAssetCache.isCapeAnimated(previewId)) 2 else 0) - - val source = remember(previewId, loadTick, isCape, isPet) { - when { - isCape -> PlayerPreviewSource.Override(CosmeticEquipment(), capeCosmeticId = previewId) - isPet -> { - val attached = CosmeticAssetCache.getAttachedCosmetic(previewId) - if (attached != null) { - val equipment = CosmeticEquipment() - equipment.equip(attached) - PlayerPreviewSource.Override(equipment) - } else { - val pet = CosmeticAssetCache.getPetDefinition(previewId) ?: return@remember null - PlayerPreviewSource.Override(CosmeticEquipment(), pet = pet) + }, + onEquip = { item -> + val variantId = selectedVariantId(item, variantPicks) + if (item.equipped && variantId == item.equippedVariantId) { + status = "Unequipping ${item.name}..." + unequip(item) { + refreshKey++ + status = it } - } - else -> { - val attached = CosmeticAssetCache.getAttachedCosmetic(previewId) ?: return@remember null - val equipment = CosmeticEquipment() - equipment.equip(attached) - PlayerPreviewSource.Override(equipment) - } - } - } - - return source to loadTick -} - -@Composable -private fun BoxScope.OwnershipBadge(owned: Boolean, createdAt: String, modifier: Modifier) { - val label = when { - owned -> "OWNED" - isNewItem(createdAt) -> "NEW" - else -> return - } - Box( - modifier = modifier.align(Alignment.TopStart) - .clip(ppShape(4.dp)) - .background(if (owned) Color(0xFF239A60) else Accent) - .padding(horizontal = 7.dp, vertical = 2.dp), - ) { - SocialText(label, color = LocalTheme.current.accentTextColor, fontSize = 10.sp, fontWeight = FontWeight.Medium) - } -} - -private fun isPreviewAssetLoaded(id: Int, isCape: Boolean, isPet: Boolean): Boolean = when { - isCape -> CosmeticAssetCache.isCapeLoaded(id) - isPet -> CosmeticAssetCache.getAttachedCosmetic(id) != null || CosmeticAssetCache.getPetDefinition(id) != null - else -> CosmeticAssetCache.getAttachedCosmetic(id) != null -} - -private fun isNewItem(createdAt: String): Boolean { - if (createdAt.isBlank()) return false - return runCatching { - val created = OffsetDateTime.parse(createdAt).toInstant() - created.isAfter(Instant.now().minus(Duration.ofDays(7))) - }.getOrDefault(false) -} - -private const val STORE_PAGE_SIZE = CosmeticStore.MAX_PAGE_SIZE - -private const val STORE_PREFETCH_DISTANCE = 6 - -@Composable -private fun StoreScreen( - cart: List, - showCart: Boolean, - status: String?, - ownedIds: Set, - onRemoveFromCart: (String) -> Unit, - onBackToBrowse: () -> Unit, - onCheckout: () -> Unit, - onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, - onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, -) { - val variantPicks = remember { mutableStateMapOf() } - var query by remember { mutableStateOf("") } - var submitted by remember { mutableStateOf("") } - var selectedType by remember { mutableStateOf(CosmeticType.Cape) } - var nextPage by remember { mutableIntStateOf(1) } - var results by remember { mutableStateOf>(emptyList()) } - var hasMore by remember { mutableStateOf(true) } - var loading by remember { mutableStateOf(true) } - var loadError by remember { mutableStateOf(null) } - var selectedId by remember { mutableStateOf(null) } - var stockedTypes by remember { - mutableStateOf(CosmeticType.entries.filter { it != CosmeticType.Unknown }) - } - val gridState = rememberLazyGridState() - - LaunchedEffect(Unit) { - stockedTypes = CosmeticStore.stockedTypes() - } - - LaunchedEffect(submitted, selectedType, nextPage) { - loading = true - loadError = null - CosmeticStore.search( - page = nextPage, - perPage = STORE_PAGE_SIZE, - text = submitted.ifBlank { null }, - types = listOf(selectedType.serializedName), - ) - .onSuccess { resp -> - results = if (nextPage == 1) resp.results else results + resp.results - hasMore = nextPage < resp.pagination.totalPages - } - .onFailure { - loadError = "Couldn't load store: ${it.message}" - hasMore = false - } - loading = false - } - - val items = remember(results, ownedIds) { - results.sortedBy { if (it.fullyOwned(ownedIds)) 1 else 0 } - } - - val nearEnd by remember { - derivedStateOf { - val info = gridState.layoutInfo - val last = info.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true - last.index >= info.totalItemsCount - STORE_PREFETCH_DISTANCE - } - } - LaunchedEffect(nearEnd, hasMore, loading) { - if (nearEnd && hasMore && !loading) nextPage++ - } - - LaunchedEffect(items) { - if (selectedId == null || items.none { it.id == selectedId }) { - selectedId = items.firstOrNull()?.id - } - } - - val cartKeys = cart.map { it.key }.toSet() - val selected = items.firstOrNull { it.id == selectedId } - - fun reset() { - results = emptyList() - hasMore = true - nextPage = 1 - } - - fun submit() { - submitted = query.trim() - reset() - } - - LaunchedEffect(stockedTypes) { - if (stockedTypes.isNotEmpty() && selectedType !in stockedTypes) { - selectedType = stockedTypes.first() - reset() - } - } - - Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(19.dp)) { - CategoryRail( - selected = selectedType, - onSelect = { - if (it != selectedType) { - selectedType = it - reset() + } else if (variantId == null) { + status = "${item.name} has no variant to equip." + } else { + status = "Equipping ${item.name}..." + equip(item, variantId) { + refreshKey++ + status = it } - }, - types = stockedTypes, - ) - Column(modifier = Modifier.width(596.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(11.dp)) { - StoreSearchBar(query = query, onQueryChange = { query = it }, onSubmit = { submit() }) - when { - loading && items.isEmpty() -> CenteredNote("Loading cosmetics...") - loadError != null && items.isEmpty() -> CenteredNote(loadError!!) - items.isEmpty() -> CenteredNote( - if (submitted.isBlank()) { - "No ${selectedType.displayName.lowercase()} cosmetics available yet." - } else { - "No cosmetics match \"$submitted\"." - }, - ) - else -> StoreGrid( - items = items, - cartKeys = cartKeys, - ownedIds = ownedIds, - variantPicks = variantPicks, - state = gridState, - modifier = Modifier.weight(1f).fillMaxWidth(), - onSelect = { selectedId = it.id }, - onAddToCart = onAddToCart, - ) } - } - - val selectedVariant = selected?.let { selectedStoreVariant(it, variantPicks, ownedIds) } - Column(modifier = Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(14.dp)) { - if (showCart) { - SmallButton("Back to store", iconPath = "left-arrow", primary = false, onClick = onBackToBrowse) - CartPanel( - items = cart, - status = status, - modifier = Modifier.fillMaxSize(), - onRemove = onRemoveFromCart, - onCheckout = onCheckout, - ) + }, + onAddToCart = { info, variant -> + val key = cosmeticCartKey(variant.id) + val label = info.cartName(variant) + if (variant.id in ownedIds) { + status = "You already own $label." + } else if (cart.any { it.key == key }) { + status = "$label is already in your cart." } else { - StoreDetailPanel( - info = selected, - variant = selectedVariant, - status = status, - ownedIds = ownedIds, - inCart = selectedVariant?.let { cosmeticCartKey(it.id) in cartKeys } ?: false, - onSelectVariant = { variantId -> selected?.let { variantPicks[it.id] = variantId } }, - onAddToCart = onAddToCart, - onBuyNow = onBuyNow, - modifier = Modifier.fillMaxSize(), - ) - } - } - } -} - -@Composable -private fun StoreSearchBar(query: String, onQueryChange: (String) -> Unit, onSubmit: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().height(33.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - BasicTextField( - value = query, - onValueChange = onQueryChange, - singleLine = true, - textStyle = TextStyle( - color = LocalTheme.current.textColor, - fontSize = 13.sp, - fontFamily = LocalTheme.current.typography.family, - ), - cursorBrush = SolidColor(LocalTheme.current.textColor), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { onSubmit() }), - modifier = Modifier.weight(1f).fillMaxHeight() - .clip(ppShape(7.dp)) - .background(LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(7.dp)) - .padding(horizontal = 12.dp) - .trackTextInputFocus(), - decorationBox = { inner -> - Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.weight(1f)) { - if (query.isEmpty()) { - SocialText("Search cosmetics...", color = LocalTheme.current.textColorSecondary, fontSize = 13.sp) + status = "Adding $label to cart..." + PolyPlusClient.SCOPE.launch { + val view = CosmeticStore.view(variant.id).getOrNull() + ClientPlatform.runOnMain { + val priceId = view?.storeProductId + if (priceId.isNullOrBlank()) { + status = "$label isn't purchasable yet." + } else if (cart.none { it.key == key }) { + cart += info.toCartEntry(variant, priceId) + status = "$label added to cart." } - inner() } } - }, - ) - SmallButton("Search", iconPath = "assets/polyplus/ico/search.svg", primary = true, onClick = onSubmit) - } -} - -@Composable -private fun StoreGrid( - items: List, - cartKeys: Set, - ownedIds: Set, - variantPicks: Map, - state: LazyGridState, - modifier: Modifier, - onSelect: (CosmeticStoreInfo) -> Unit, - onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, -) { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - state = state, - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(19.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - items(items, key = { it.id }) { info -> - val variant = selectedStoreVariant(info, variantPicks, ownedIds) - StoreCard( - info = info, - variant = variant, - owned = info.fullyOwned(ownedIds), - variantOwned = variant.id in ownedIds, - inCart = cosmeticCartKey(variant.id) in cartKeys, - onSelect = { onSelect(info) }, - onAddToCart = { onAddToCart(info, variant) }, - ) - } - } -} - -@Composable -private fun StoreCard( - info: CosmeticStoreInfo, - variant: CosmeticVariantUi, - owned: Boolean, - variantOwned: Boolean, - inCart: Boolean, - onSelect: () -> Unit, - onAddToCart: () -> Unit, -) { - val variantCount = info.uiVariants().size - val border = when { - inCart -> Accent - owned -> LocalTheme.current.borderColor - info.discounted -> Color(0xFF239A60) - else -> LocalTheme.current.borderColor - } - val buttonColor = if (inCart) Accent else Color(0xB3232D32) - - Box( - modifier = Modifier.size(180.dp, 258.dp) - .alpha(if (owned) 0.6f else 1f) - .clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, border, ppShape(12.dp)) - .clickable(onClick = onSelect), - ) { - val (source, loadTick) = rememberCosmeticPreviewSource(variant.id, info.type) - CosmeticThumbnail( - source = source, - type = info.type, - previewKey = "store-${variant.id}-$loadTick", - modifier = Modifier.offset(17.dp, 17.dp).size(144.dp), - ) - CardLabel(info.name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.offset(17.dp, 169.dp).width(146.dp)) - Row(modifier = Modifier.offset(17.dp, 193.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - PriceLabel(info) - if (variantCount > 1) { - SocialText("$variantCount variants", color = LocalTheme.current.textColorSecondary, fontSize = 11.sp) - } - } - - OwnershipBadge(owned, info.createdAt, Modifier.offset(x = 8.dp, y = 8.dp)) - - if (info.discounted && !owned) { - Box( - modifier = Modifier.align(Alignment.TopEnd).size(66.dp, 21.dp) - .background(Color(0xFF239A60), ppShapeOf(bottomStart = 4.dp, topEnd = 12.dp)), - contentAlignment = Alignment.Center, - ) { - SocialText("${info.discountRate}% OFF", color = LocalTheme.current.accentTextColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) - } - } - - Row( - modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(36.dp) - .background(buttonColor) - .clickable(onClick = if (inCart || variantOwned) onSelect else onAddToCart), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - val label = when { - variantOwned && owned -> "Owned" - variantOwned -> "Owned - see variants" - inCart -> "In cart" - else -> "Add to cart" } - SocialText(label, color = LocalTheme.current.accentTextColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - } - } -} - -@Composable -private fun StoreDetailPanel( - info: CosmeticStoreInfo?, - variant: CosmeticVariantUi?, - status: String?, - ownedIds: Set, - inCart: Boolean, - onSelectVariant: (Int) -> Unit, - onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, - onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, - modifier: Modifier, -) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(14.dp)) { - Box( - modifier = Modifier.fillMaxWidth().height(330.dp) - .clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), - ) { - if (info == null || variant == null) { - CenteredNote("Select a cosmetic to preview it.") - } else { - val (source, _) = rememberCosmeticPreviewSource(variant.id, info.type) - val fades = info.type != CosmeticType.Boots - PlayerPreview( - Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), - source = source ?: PlayerPreviewSource.LocalLive, - autoSpin = false, - bottomFadeFraction = if (fades) STORE_PREVIEW_FADE_FRACTION else 0f, - verticalAnchor = when (info.type) { - CosmeticType.Hat -> 0.70f - CosmeticType.Boots -> 0.44f - else -> 0.56f - }, - initialYaw = FRONT_YAW_DEG, - previewKey = "store-detail-${variant.id}", - live = true, - ) - OwnershipBadge(variant.id in ownedIds, info.createdAt, Modifier.padding(12.dp)) - } - } - - Box( - modifier = Modifier.weight(1f).fillMaxWidth() - .clip(ppShape(12.dp)) - .background(cardBrush()) - .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), - ) { - if (info == null || variant == null) { - CenteredNote("Browse the store to buy individual cosmetics.") - } else { - val variants = info.uiVariants() - Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Column( - modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - SocialText(info.type.displayName, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - SocialText(info.name, color = LocalTheme.current.textColor, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) - Row(verticalAlignment = Alignment.CenterVertically) { PriceLabel(info) } - if (variants.size > 1) { - VariantPicker( - variants = variants, - selectedVariantId = variant.id, - onSelect = onSelectVariant, - ownedVariantIds = ownedIds, - ) - } - if (info.tags.all.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - for (tag in info.tags.all.take(4)) { - Box( - modifier = Modifier.clip(ppShape(5.dp)) - .background(LocalTheme.current.chipBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)) - .padding(horizontal = 8.dp, vertical = 3.dp), - ) { - SocialText(tag.replaceFirstChar { it.uppercase() }, color = LocalTheme.current.textColor, fontSize = 11.sp) - } - } - } - } - info.description?.takeIf { it.isNotBlank() }?.let { - SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + }, + onBuyNow = { info, variant -> + val label = info.cartName(variant) + if (variant.id in ownedIds) { + status = "You already own $label." + } else if (!checkoutPending) { + checkoutPending = true + status = "Opening checkout..." + PolyPlusClient.SCOPE.launch { + val priceId = CosmeticStore.view(variant.id).getOrNull()?.storeProductId + if (priceId.isNullOrBlank()) { + ClientPlatform.runOnMain { + checkoutPending = false + status = "$label isn't purchasable yet." } - } - if (status != null) { - SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } - if (variant.id in ownedIds) { - SocialText( - "You already own this cosmetic.", - color = LocalTheme.current.textColorSecondary, - fontSize = 12.sp, - ) } else { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SmallButton( - label = if (inCart) "In cart" else "Add to cart", - iconPath = "assets/polyplus/ico/shopping-cart/0.svg", - primary = false, - onClick = { if (!inCart) onAddToCart(info, variant) }, - ) - SmallButton( - label = "Buy now", - iconPath = "assets/polyplus/ico/shopping-bag.svg", - primary = true, - onClick = { onBuyNow(info, variant) }, - ) - } - } - } - } - } - } -} - -@Composable -private fun CoverThumbnail(coverAssetId: Int?, modifier: Modifier) { - val cover = rememberCoverImage(coverAssetId) - Box(modifier) { - CheckerThumbnail(Modifier.fillMaxSize()) - if (cover != null) { - Image( - bitmap = cover, - contentDescription = null, - filterQuality = FilterQuality.None, - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize().clip(ppShape(5.dp)), - ) - } - } -} - -@Composable -private fun CheckerThumbnail(modifier: Modifier) { - Checkerboard( - modifier = modifier.clip(ppShape(5.dp)) - .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)), - cell = 9.dp, - ) -} - -@Composable -private fun CosmeticsLoadingScreen( - state: CosmeticLoadProgress.Snapshot, - onRetry: () -> Unit, -) { - val failed = state.failure != null - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), - ) { - SocialText( - if (failed) "Couldn't load cosmetics" else "Loading cosmetics", - color = LocalTheme.current.textColor, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ) - SocialText( - if (failed) { - "PolyPlus couldn't reach its servers (${state.failure}). Check your connection and try again." - } else { - state.label - }, - color = LocalTheme.current.textColorSecondary, - fontSize = 13.sp, - modifier = Modifier.width(360.dp), - textAlign = TextAlign.Center, - ) - if (failed) { - Spacer(Modifier.height(2.dp)) - SmallButton("Retry", iconPath = "refresh", primary = true, onClick = onRetry) - } else { - ProgressBar(state.fraction, Modifier.width(360.dp)) - } - } -} - -private const val SWEEP_WIDTH_FRACTION = 0.35f - -@Composable -private fun ProgressBar(fraction: Float?, modifier: Modifier) { - val track = LocalTheme.current.chipBackground - val shape = ppShape(4.dp) - Box( - modifier - .height(8.dp) - .clip(shape) - .background(track) - .border(1.dp, LocalTheme.current.borderColor, shape), - ) { - if (fraction != null) { - val animated by animateFloatAsState(fraction.coerceIn(0f, 1f), animationSpec = tween(220)) - Box( - Modifier - .fillMaxHeight() - .fillMaxWidth(animated) - .clip(shape) - .background(Accent), - ) - } else { - val transition = rememberInfiniteTransition(label = "cosmeticsLoadingSweep") - val head by transition.animateFloat( - initialValue = -SWEEP_WIDTH_FRACTION, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(1_100, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "cosmeticsLoadingSweepHead", - ) - BoxWithConstraints(Modifier.fillMaxSize()) { - val width = maxWidth - Box( - Modifier - .offset(x = width * head) - .fillMaxHeight() - .width(width * SWEEP_WIDTH_FRACTION) - .clip(shape) - .background(Accent), - ) - } - } - } -} - -@Composable -private fun OnlineFeaturesDisabled() { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), - ) { - SocialText( - "Cosmetics are unavailable", - color = LocalTheme.current.textColor, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ) - SocialText( - "The Terms of Service and Privacy Policy were declined, so PolyPlus does not contact its " + - "servers. Accept them under Privacy in the PolyPlus settings to use cosmetics.", - color = LocalTheme.current.textColorSecondary, - fontSize = 13.sp, - modifier = Modifier.width(420.dp), - textAlign = TextAlign.Center, - ) - } -} - -@Composable -private fun CardLabel( - text: String, - color: Color, - fontSize: TextUnit, - modifier: Modifier = Modifier, - fontWeight: FontWeight = FontWeight.Normal, -) { - var truncated by remember(text) { mutableStateOf(false) } - val interaction = remember { MutableInteractionSource() } - val hovered by interaction.collectIsHoveredAsState() - - Box(modifier.hoverable(interaction)) { - SocialText( - text, - color = color, - fontSize = fontSize, - fontWeight = fontWeight, - maxLines = 1, - softWrap = true, - overflow = TextOverflow.Ellipsis, - onTextLayout = { truncated = it.hasVisualOverflow }, - ) - if (truncated && hovered) { - TooltipPopup(text) - } - } -} - -@Composable -private fun TooltipPopup(text: String) { - val gap = with(LocalDensity.current) { 6.dp.roundToPx() } - val positionProvider = remember(gap) { - object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset { - val x = anchorBounds.left.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) - val above = anchorBounds.top - gap - popupContentSize.height - val y = if (above >= 0) above else anchorBounds.bottom + gap - return IntOffset(x, y) - } - } - } - Popup( - popupPositionProvider = positionProvider, - properties = PopupProperties(focusable = false, clippingEnabled = false), - ) { - Box( - modifier = Modifier.widthIn(max = 240.dp) - .clip(ppShape(6.dp)) - .background(LocalTheme.current.popupBackground) - .border(1.dp, LocalTheme.current.borderColor, ppShape(6.dp)) - .padding(horizontal = 8.dp, vertical = 5.dp), - ) { - SocialText(text, color = LocalTheme.current.textColor, fontSize = 12.sp) - } - } -} - -@Composable -private fun rememberCosmeticItems(refreshKey: Int): List = - remember(refreshKey) { - val ownedIds = CosmeticCatalog.ownedIds() - val equippedIds = CosmeticCatalog.localEquipped().equipped.values.toSet() - val selectedEmote = CosmeticCatalog.selectedEmoteId() - - val groupItems = CosmeticCatalog.cosmeticGroupViews() - .sortedWith(compareBy { it.type.ordinal }.thenBy { it.groupId }) - .map { group -> - val byLabel = LinkedHashMap() - for (variant in group.variants) { - byLabel.getOrPut(variant.variantName) { - CosmeticVariantUi(variant.id, variant.variantName) + ClientPlatform.runOnMain { checkout(listOf(priceId), claimed = true) } } } - val equippedVariantId = group.variants.firstOrNull { it.id in equippedIds }?.id - CosmeticUiItem( - groupId = group.groupId, - type = group.type, - name = group.name, - collection = "${group.type.displayName} Collection", - owned = group.variants.any { it.id in ownedIds }, - equipped = equippedVariantId != null, - variants = byLabel.values.toList(), - equippedVariantId = equippedVariantId, - ) - } - - val emoteItems = CosmeticCatalog.allEmoteDefinitions() - .sortedBy { it.id } - .map { emote -> - CosmeticUiItem( - groupId = emote.id, - type = CosmeticType.Emote, - name = emote.name, - collection = "${CosmeticType.Emote.displayName} Collection", - owned = emote.id in ownedIds, - equipped = selectedEmote == emote.id, - variants = listOf(CosmeticVariantUi(emote.id, emote.name)), - equippedVariantId = if (selectedEmote == emote.id) emote.id else null, - ) } - - groupItems + emoteItems - } - -private fun selectedVariantId(item: CosmeticUiItem, picks: Map): Int? = - picks[item.groupId] ?: item.equippedVariantId ?: item.variants.firstOrNull()?.id - -private fun equip(item: CosmeticUiItem, variantId: Int, onComplete: (String) -> Unit) { - if (!item.owned) { - onComplete("${item.name} is not in your locker.") - return - } - - PolyPlusClient.SCOPE.launch { - val result = if (item.type == CosmeticType.Emote) { - CosmeticService.equipEmote(variantId) - } else { - CosmeticService.equip(variantId) - } - ClientPlatform.runOnMain { - onComplete( - result.fold( - onSuccess = { "Equipped ${item.name}." }, - onFailure = { "Failed to equip ${item.name}: ${it.message}" }, - ), - ) - } - } -} - -private fun unequip(item: CosmeticUiItem, onComplete: (String) -> Unit) { - PolyPlusClient.SCOPE.launch { - val result = if (item.type == CosmeticType.Emote) { - CosmeticService.clearEmote() - } else { - val slot = CosmeticCatalog.localEquipped().equipped.entries - .firstOrNull { it.value == item.equippedVariantId }?.key - if (slot == null) { - Result.failure(IllegalStateException("${item.name} is not equipped")) - } else { - CosmeticService.clearSlot(slot) - } - } - ClientPlatform.runOnMain { - onComplete( - result.fold( - onSuccess = { "Unequipped ${item.name}." }, - onFailure = { "Failed to unequip ${item.name}: ${it.message}" }, - ), - ) - } - } -} - -private fun cardBrush(): Brush = - Brush.verticalGradient( - listOf( - Color(0x59232D32), - Color(0xB3232D32), - ), + }, + onBuyBundle = { bundle -> + val priceId = bundle.storeProductId + if (priceId.isNullOrBlank()) status = "${bundle.name} isn't purchasable yet." else checkout(listOf(priceId)) + }, + onRemoveFromCart = { key -> cart.removeAll { it.key == key } }, + onCheckout = { checkout(cart.mapNotNull { it.storeProductId }) }, ) - -private fun money(amount: Float, currency: String? = null): String { - val formatted = String.format("%.2f", amount) - return if (currency == null || currency.equals("usd", ignoreCase = true)) "$$formatted" else "$formatted ${currency.uppercase()}" } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt index 4bfbdad3..1d3ce09e 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt @@ -896,9 +896,9 @@ private fun RightColumn(modifier: Modifier, screen: Screen) { badge = groups.any { it.unread }, ) } - if (!PolyPlusMainMenuConfig.hideMainMenuCosmetics && PrivacyConsent.allowsOnlineServices()) { + if (!PolyPlusMainMenuConfig.hideMainMenuCosmetics) { PillButton( - "Cosmetics", + "Wardrobe", ASSETS + "diamond-01.svg", Modifier.fillMaxWidth(), onClick = { PolyPlusOneConfigIntegration.openCosmetics() }, diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt index 913b23b9..0c0f7d8a 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt @@ -46,9 +46,10 @@ fun PlayerPreview( previewKey: Any = source, live: Boolean = false, bottomFadeFraction: Float = 0f, + rotation: PreviewRotation? = null, ) { if (live) { - PlayerPreviewLive(modifier, source, autoSpin, allowDrag, modelScale, verticalAnchor, initialYaw, previewKey, bottomFadeFraction) + PlayerPreviewLive(modifier, source, autoSpin, allowDrag, modelScale, verticalAnchor, initialYaw, previewKey, bottomFadeFraction, rotation) return } PlayerPreviewBitmap(modifier, source, autoSpin, allowDrag, modelScale, verticalAnchor, initialYaw, previewKey) @@ -65,23 +66,32 @@ private fun PlayerPreviewLive( initialYaw: Float, previewKey: Any, bottomFadeFraction: Float, + rotation: PreviewRotation?, ) { val entry = remember { PlayerPreviewOffscreen.register() } DisposableEffect(entry) { onDispose { PlayerPreviewOffscreen.unregister(entry) } } - remember(previewKey, initialYaw) { - entry.dragYaw = 0f; entry.dragPitch = 0f - entry.spinAccum = 0f; entry.lastSpinNanos = 0L + if (rotation == null) { + remember(previewKey, initialYaw) { + entry.dragYaw = 0f; entry.dragPitch = 0f + entry.spinAccum = 0f; entry.lastSpinNanos = 0L + } } + val sharedYaw = rotation?.yaw + val sharedPitch = rotation?.pitch SideEffect { entry.source = source entry.modelScale = modelScale entry.verticalAnchor = verticalAnchor entry.autoSpin = autoSpin - entry.initialYaw = initialYaw + entry.initialYaw = sharedYaw ?: initialYaw + if (sharedPitch != null) { + entry.dragYaw = 0f + entry.dragPitch = sharedPitch + } } val frameNanos by produceState(0L) { while (true) withFrameNanos { value = it } } @@ -90,15 +100,24 @@ private fun PlayerPreviewLive( val dragModifier: Modifier = if (allowDrag) { - Modifier.pointerInput(entry) { + Modifier.pointerInput(entry, rotation) { detectDragGestures( onDragStart = { entry.dragging = true }, onDragEnd = { entry.dragging = false }, onDragCancel = { entry.dragging = false }, ) { change, drag -> - entry.dragYaw -= drag.x * DRAG_YAW_SENSITIVITY - entry.dragPitch = (entry.dragPitch + drag.y * DRAG_PITCH_SENSITIVITY) - .coerceIn(-MAX_PITCH, MAX_PITCH) + if (rotation != null) { + rotation.yaw -= drag.x * DRAG_YAW_SENSITIVITY + rotation.pitch = (rotation.pitch + drag.y * DRAG_PITCH_SENSITIVITY) + .coerceIn(-MAX_PITCH, MAX_PITCH) + rotation.userRotated = true + entry.initialYaw = rotation.yaw + entry.dragPitch = rotation.pitch + } else { + entry.dragYaw -= drag.x * DRAG_YAW_SENSITIVITY + entry.dragPitch = (entry.dragPitch + drag.y * DRAG_PITCH_SENSITIVITY) + .coerceIn(-MAX_PITCH, MAX_PITCH) + } change.consume() } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt index 3741a3ee..d938e7d1 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt @@ -15,6 +15,7 @@ import org.jetbrains.skia.ImageInfo import org.joml.Quaternionf import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService import org.polyfrost.polyplus.client.cosmetics.access.PlayerCosmeticsAccess import org.polyfrost.polyplus.client.utils.ClientPlatform import org.polyfrost.polyplus.client.utils.rotateBy @@ -579,7 +580,8 @@ object PlayerPreviewRenderer { if (mc.entityRenderDispatcher.getRenderer(player) == null) return val state = mc.entityRenderDispatcher.extractEntity(player, 1.0f) as? AvatarRenderState ?: return state.lightCoords = 0xF000F0 - state.showCape = source !is PlayerPreviewSource.Override || equipmentByEntityId[player.id]?.get(BodySlot.Backpack) == null + state.showCape = (source !is PlayerPreviewSource.Override || equipmentByEntityId[player.id]?.get(BodySlot.Backpack) == null) && + !hidesCape(source) capeOverride(source)?.let { state.skin = withCape(state.skin, it) } state.bodyRot = yawDeg state.yRot = 0f @@ -617,6 +619,7 @@ object PlayerPreviewRenderer { if (mc.entityRenderDispatcher.getRenderer(state) == null) return state.id = PREVIEW_ENTITY_ID if (source is PlayerPreviewSource.Override && equipment.get(BodySlot.Backpack) != null) state.showCape = false + if (hidesCape(source)) state.showCape = false state.bodyRot = yawDeg state.boundingBoxWidth = PLAYER_BB_WIDTH state.boundingBoxHeight = PLAYER_BB_HEIGHT @@ -914,11 +917,17 @@ object PlayerPreviewRenderer { is PlayerPreviewSource.Override -> source.capeCosmeticId?.let { org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache.getCapeResource(it) - } + } ?: source.mojangCape PlayerPreviewSource.LocalLive -> org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog.localEquipped().cape?.let { org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache.getCapeResource(it) - } + } ?: MojangCapeService.localCapeTexture() + } + + private fun hidesCape(source: PlayerPreviewSource): Boolean = when (source) { + is PlayerPreviewSource.Override -> source.capeCosmeticId == null && source.mojangCape == null + PlayerPreviewSource.LocalLive -> + CosmeticCatalog.localEquipped().cape == null && MojangCapeService.localCapeHidden() } //? if >= 1.21.10 { @@ -969,6 +978,9 @@ object PlayerPreviewRenderer { override fun isModelPartShown(part: PlayerModelPart): Boolean = true } + private fun withoutCape(skin: PlayerSkin): PlayerSkin = + PlayerSkin(skin.texture(), skin.textureUrl(), null, skin.elytraTexture(), skin.model(), skin.secure()) + private object PreviewWorld { var cached: ClientLevel? = null private set @@ -1161,6 +1173,7 @@ object PlayerPreviewRenderer { } equipmentByEntityId[LEGACY_PREVIEW_ENTITY_ID] = equipment val state = legacyState(skin, yawDeg) + if (hidesCape(source)) state.showCape = false val renderer = legacyPlayerRenderer(mc, skin) ?: return false //?} //? if < 1.21.4 { @@ -1170,7 +1183,7 @@ object PlayerPreviewRenderer { val player = legacyDummy(mc, level) ?: return false val cape = capeOverride(source) ?: CosmeticAssetCache.getCapeTexture(mc.gameProfile.id) - player.skinOverride = cape?.let { withCape(skin, it) } ?: skin + player.skinOverride = if (hidesCape(source)) withoutCape(skin) else cape?.let { withCape(skin, it) } ?: skin bindLegacyEquipment(player, source) player.setYRot(0f); player.yRotO = 0f player.yBodyRot = yawDeg; player.yBodyRotO = yawDeg @@ -1339,6 +1352,7 @@ import org.polyfrost.polyplus.client.utils.runSuspendCatching import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.cosmetics.render.CosmeticRenderer import org.polyfrost.polyplus.client.network.http.responses.BodySlot @@ -1628,12 +1642,12 @@ object PlayerPreviewRenderer { private fun capeFor(mc: Minecraft, source: PlayerPreviewSource): Identifier? = when (source) { is PlayerPreviewSource.Override -> if (source.equipment.get(BodySlot.Backpack) != null) null - else source.capeCosmeticId?.let(CosmeticAssetCache::getCapeResource) ?: accountCape(mc) + else source.capeCosmeticId?.let(CosmeticAssetCache::getCapeResource) ?: source.mojangCape PlayerPreviewSource.LocalLive -> CosmeticCatalog.localEquipped().cape?.let(CosmeticAssetCache::getCapeResource) - ?: mc.player?.capeTextureLocation - ?: optifineCape(mc) - ?: accountCape(mc) + ?: MojangCapeService.localCapeTexture() + ?: if (MojangCapeService.localCapeHidden()) null + else mc.player?.capeTextureLocation ?: optifineCape(mc) ?: accountCape(mc) } private fun equipmentFor(source: PlayerPreviewSource): CosmeticEquipment = when (source) { @@ -1690,7 +1704,8 @@ object PlayerPreviewRenderer { }, false) } fetchedSkin?.let { return it } - return DefaultSkinUtils.getDefaultSkin(profile.id) to (DefaultSkinUtils.getDefaultModelType(profile.id) == "slim") + val id = ClientPlatform.localPlayerUuid() + return DefaultSkinUtils.getDefaultSkin(id) to (DefaultSkinUtils.getDefaultModelType(id) == "slim") } private fun accountCape(mc: Minecraft): Identifier? = diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewSource.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewSource.kt index 51fe2612..10bde2b9 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewSource.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewSource.kt @@ -1,5 +1,6 @@ package org.polyfrost.polyplus.client.gui.preview +import net.minecraft.resources.Identifier import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment import org.polyfrost.polyplus.client.cosmetics.PetDefinition @@ -10,5 +11,6 @@ sealed interface PlayerPreviewSource { val equipment: CosmeticEquipment, val pet: PetDefinition? = null, val capeCosmeticId: Int? = null, + val mojangCape: Identifier? = null, ) : PlayerPreviewSource } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PreviewRotation.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PreviewRotation.kt new file mode 100644 index 00000000..6b608796 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PreviewRotation.kt @@ -0,0 +1,12 @@ +package org.polyfrost.polyplus.client.gui.preview + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class PreviewRotation(initialYaw: Float) { + var yaw by mutableFloatStateOf(initialYaw) + var pitch by mutableFloatStateOf(0f) + var userRotated by mutableStateOf(false) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt new file mode 100644 index 00000000..f9968e0b --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/AuraColorPicker.kt @@ -0,0 +1,440 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.awt.Color as AwtColor +import kotlin.math.ceil +import kotlin.math.roundToInt +import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun AuraSwatchRow( + selectedColor: Int?, + customOpen: Boolean, + onToggleCustom: () -> Unit, + onCommit: (Int?) -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { + AuraDot( + color = Color(DEFAULT_AURA_ARGB), + isSelected = selectedColor == null, + onClick = { onCommit(null) }, + ) + for (color in AURA_PRESETS) { + AuraDot( + color = Color(color), + isSelected = selectedColor == color, + onClick = { onCommit(color) }, + ) + } + val isCustom = selectedColor != null && selectedColor !in AURA_PRESETS + AuraDot( + color = if (isCustom) Color(selectedColor!!) else LocalTheme.current.componentBackground, + isSelected = isCustom || customOpen, + onClick = onToggleCustom, + ) { + if (!isCustom) { + SocialText("+", color = LocalTheme.current.textColorSecondary, fontSize = 11.sp) + } + } + } +} + +@Composable +private fun AuraDot( + color: Color, + isSelected: Boolean, + onClick: () -> Unit, + content: @Composable () -> Unit = {}, +) { + Box( + modifier = Modifier.size(18.dp) + .clip(ppShape(6.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Checkerboard(Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().background(color)) + Box( + Modifier.fillMaxSize().border( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) Accent else LocalTheme.current.borderColor, + shape = ppShape(6.dp), + ), + ) + content() + } +} + +private val AURA_COLORS: List = listOf( + 0xFFFF4444.toInt(), + 0xFFFF9E3D.toInt(), + 0xFFF4D03F.toInt(), + 0xFF2ECC71.toInt(), + 0xFF1ABC9C.toInt(), + 0xFF3DA5FF.toInt(), + 0xFF6C5CE7.toInt(), + 0xFFB05CFF.toInt(), + 0xFFFF6FD8.toInt(), + 0xFFFFFFFF.toInt(), + 0xFF3A3F43.toInt(), + 0xFF9BA2A6.toInt(), +) + +private const val DEFAULT_AURA_ARGB: Int = -1 + +private val AURA_PRESETS: List = AURA_COLORS.filter { it != DEFAULT_AURA_ARGB } + +/** Converts an ARGB int to a HSB float array (hue in degrees, saturation and brightness as a float between 0 and 1). */ +private fun argbToHsb(argb: Int): FloatArray = + AwtColor.RGBtoHSB((argb shr 16) and 0xFF, (argb shr 8) and 0xFF, argb and 0xFF, null) + .also { it[0] *= 360f } + +private fun hsbToColor(hue: Float, saturation: Float, brightness: Float): Color = + Color.hsv(hue, saturation, brightness) + +private fun argbHex(argb: Int): String = + "%02X%02X%02X".format((argb shr 16) and 0xFF, (argb shr 8) and 0xFF, argb and 0xFF) + +private fun hexToArgb(hex: String, alpha: Float): Int? { + val h = hex.removePrefix("#").trim() + if (h.length != 6) return null + return try { + ((alpha.coerceIn(0f, 1f) * 255f).roundToInt() shl 24) or Integer.parseInt(h, 16) + } catch (_: Exception) { + null + } +} + +private fun Color.toArgb(alpha: Float): Int = + ((alpha.coerceIn(0f, 1f) * 255f).roundToInt() shl 24) or + ((red.coerceIn(0f, 1f) * 255f).roundToInt() shl 16) or + ((green.coerceIn(0f, 1f) * 255f).roundToInt() shl 8) or + (blue.coerceIn(0f, 1f) * 255f).roundToInt() + +private class AuraPickerState(argb: Int) { + var hue by mutableFloatStateOf(0f) + var saturation by mutableFloatStateOf(0f) + var brightness by mutableFloatStateOf(0f) + var alpha by mutableFloatStateOf(1f) + + var lastEmitted: Int? = null + + init { + applyFrom(argb) + } + + fun applyFrom(argb: Int) { + val hsb = argbToHsb(argb) + hue = hsb[0] + saturation = hsb[1] + brightness = hsb[2] + alpha = ((argb ushr 24) and 0xFF) / 255f + } + + fun currentArgb(): Int = hsbToColor(hue, saturation, brightness).toArgb(alpha) +} + +@Composable +internal fun Checkerboard(modifier: Modifier, cell: Dp = 4.dp) { + Box( + modifier = modifier.drawBehind { + val step = cell.toPx() + val cols = ceil(size.width / step).toInt() + val rows = ceil(size.height / step).toInt() + for (x in 0..cols) { + for (y in 0..rows) { + drawRect( + color = if ((x + y) % 2 == 0) Color(0xFF5F6568) else Color(0xFF3D4245), + topLeft = Offset(x * step, y * step), + size = Size(step, step), + ) + } + } + }, + ) +} + +@Composable +internal fun AuraCustomPopover( + selectedColor: Int?, + onPreview: (Int) -> Unit, + onCommit: (Int?) -> Unit, + modifier: Modifier = Modifier, +) { + val state = remember { AuraPickerState(selectedColor ?: 0xFFFFFFFF.toInt()) } + var hexText by remember { mutableStateOf(argbHex(state.currentArgb())) } + var hexPending by remember { mutableStateOf(false) } + LaunchedEffect(selectedColor) { + if (selectedColor != null && selectedColor != state.lastEmitted) { + state.applyFrom(selectedColor) + hexText = argbHex(selectedColor) + } + } + + fun emitPreview() { + val argb = state.currentArgb() + state.lastEmitted = argb + hexText = argbHex(argb) + onPreview(argb) + } + + fun emitCommit() { + val argb = state.currentArgb() + state.lastEmitted = argb + hexPending = false + onCommit(argb) + } + + fun commitHex() { + if (hexPending) emitCommit() + } + + val latestCommitHex by rememberUpdatedState<() -> Unit> { commitHex() } + DisposableEffect(Unit) { onDispose { latestCommitHex() } } + + val hue = state.hue + val saturation = state.saturation + val brightness = state.brightness + val alpha = state.alpha + val current = hsbToColor(hue, saturation, brightness) + + Column( + modifier = modifier.width(190.dp) + .clip(ppShape(16.dp)) + .background(LocalTheme.current.popupBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(16.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SocialText("Aura Color", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + + var paneSize by remember { mutableStateOf(Size.Zero) } + Box( + modifier = Modifier.fillMaxWidth().height(120.dp) + .clip(ppShape(6.dp)) + .onSizeChanged { paneSize = Size(it.width.toFloat(), it.height.toFloat()) } + .drawWithCache { + val hueColor = hsbToColor(hue, 1f, 1f) + val horizontal = Brush.horizontalGradient(listOf(Color.White, hueColor)) + val vertical = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) + onDrawBehind { + drawRect(horizontal) + drawRect(vertical) + } + } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + fun update(x: Float, y: Float) { + state.saturation = (x / paneSize.width).coerceIn(0f, 1f) + state.brightness = 1f - (y / paneSize.height).coerceIn(0f, 1f) + emitPreview() + } + update(down.position.x, down.position.y) + do { + val event = awaitPointerEvent() + event.changes.forEach { ch -> + if (ch.pressed) { + update(ch.position.x, ch.position.y) + ch.consume() + } + } + } while (event.changes.any { it.pressed }) + emitCommit() + } + }, + ) { + val selX = (saturation * paneSize.width).coerceIn(0f, paneSize.width) + val selY = ((1f - brightness) * paneSize.height).coerceIn(0f, paneSize.height) + Box( + modifier = Modifier + .offset { IntOffset((selX - 7.dp.toPx()).roundToInt(), (selY - 7.dp.toPx()).roundToInt()) } + .size(14.dp) + .clip(ppShape(6.dp)) + .background(current) + .border(2.dp, Color.White, ppShape(6.dp)), + ) + } + + Box( + modifier = Modifier.fillMaxWidth().height(14.dp) + .clip(ppShape(6.dp)) + .drawWithCache { + val gradient = Brush.horizontalGradient( + listOf(Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red), + ) + onDrawBehind { drawRect(gradient) } + } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + fun update(x: Float) { + state.hue = ((x / size.width) * 360f).coerceIn(0f, 360f) + emitPreview() + } + update(down.position.x) + do { + val event = awaitPointerEvent() + event.changes.forEach { ch -> + if (ch.pressed) { + update(ch.position.x) + ch.consume() + } + } + } while (event.changes.any { it.pressed }) + emitCommit() + } + }, + ) { + var barWidth by remember { mutableStateOf(0f) } + Box(Modifier.fillMaxWidth().height(14.dp).onSizeChanged { barWidth = it.width.toFloat() }) + Box( + modifier = Modifier.align(Alignment.CenterStart) + .offset { IntOffset(((hue / 360f) * barWidth - 5.dp.toPx()).roundToInt().coerceAtLeast(0), 0) } + .size(10.dp) + .clip(ppShape(5.dp)) + .background(Color.White) + .border(1.dp, Color.White.copy(0.5f), ppShape(5.dp)), + ) + } + + Box( + modifier = Modifier.fillMaxWidth().height(14.dp) + .clip(ppShape(6.dp)) + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + fun update(x: Float) { + state.alpha = (x / size.width).coerceIn(0f, 1f) + emitPreview() + } + update(down.position.x) + do { + val event = awaitPointerEvent() + event.changes.forEach { ch -> + if (ch.pressed) { + update(ch.position.x) + ch.consume() + } + } + } while (event.changes.any { it.pressed }) + emitCommit() + } + }, + ) { + Checkerboard(Modifier.fillMaxWidth().height(14.dp)) + Box( + modifier = Modifier.fillMaxWidth().height(14.dp) + .drawWithCache { + val gradient = Brush.horizontalGradient(listOf(current.copy(alpha = 0f), current)) + onDrawBehind { drawRect(gradient) } + }, + ) + var alphaBarWidth by remember { mutableStateOf(0f) } + Box(Modifier.fillMaxWidth().height(14.dp).onSizeChanged { alphaBarWidth = it.width.toFloat() }) + Box( + modifier = Modifier.align(Alignment.CenterStart) + .offset { IntOffset((alpha * alphaBarWidth - 5.dp.toPx()).roundToInt().coerceAtLeast(0), 0) } + .size(10.dp) + .clip(ppShape(5.dp)) + .background(Color.White) + .border(1.dp, Color.White.copy(0.5f), ppShape(5.dp)), + ) + } + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(modifier = Modifier.size(28.dp).clip(ppShape(5.dp))) { + Checkerboard(Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().background(current.copy(alpha = alpha))) + Box(Modifier.fillMaxSize().border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp))) + } + BasicTextField( + value = hexText, + onValueChange = { input -> + val filtered = input.filter { it in '0'..'9' || it.lowercaseChar() in 'a'..'f' }.take(6) + hexText = filtered + hexToArgb(filtered, state.alpha)?.let { argb -> + state.applyFrom(argb) + state.lastEmitted = argb + hexPending = true + onPreview(argb) + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { commitHex() }), + singleLine = true, + textStyle = TextStyle( + color = LocalTheme.current.textColor, + fontSize = 12.sp, + fontFamily = LocalTheme.current.typography.family, + ), + cursorBrush = SolidColor(LocalTheme.current.textColor), + modifier = Modifier.weight(1f) + .clip(ppShape(5.dp)) + .background(LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp) + .onFocusChanged { if (!it.isFocused) commitHex() } + .trackTextInputFocus(), + decorationBox = { inner -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + SocialText("#", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + inner() + } + }, + ) + } + + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt new file mode 100644 index 00000000..ba6c6692 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeActions.kt @@ -0,0 +1,273 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.cosmetics.CosmeticGroupView +import org.polyfrost.polyplus.client.cosmetics.CosmeticService +import org.polyfrost.polyplus.client.cosmetics.MojangCape +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.PlayerSkinInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinModel +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType +import org.polyfrost.polyplus.client.utils.ClientPlatform +import java.util.concurrent.atomic.AtomicBoolean + +private val lookChangePending = AtomicBoolean(false) +private val equipLock = Mutex() + +private const val LOOK_CHANGE_BUSY = "Another change is still running. Try again in a moment." + +private fun beginLookChange(): Boolean = + !OutfitLibrary.busy && !SkinLibrary.busy && !MojangCapeService.busy && lookChangePending.compareAndSet(false, true) + +private fun beginLookChange(onStatus: (String) -> Unit): Boolean = + beginLookChange().also { if (!it) onStatus(LOOK_CHANGE_BUSY) } + +internal fun toggleMojangCape(cape: MojangCape, onStatus: (String) -> Unit) { + if (!beginLookChange(onStatus)) return + onStatus(if (cape.active) "Removing ${cape.name}..." else "Equipping ${cape.name}...") + PolyPlusClient.SCOPE.launch { + val result = try { + if (cape.active) MojangCapeService.clear() else MojangCapeService.select(cape.id) + } finally { + lookChangePending.set(false) + } + ClientPlatform.runOnMain { + onStatus( + result.fold( + onSuccess = { if (cape.active) "Removed ${cape.name}." else "Equipped ${cape.name}." }, + onFailure = MojangCapeService::failureMessage, + ), + ) + } + } +} + +private fun launchLibraryCall( + onStatus: (String) -> Unit, + pending: String?, + call: suspend () -> Result, + failure: (Throwable) -> String, + success: (T) -> String, +) { + pending?.let(onStatus) + PolyPlusClient.SCOPE.launch { + val result = call() + ClientPlatform.runOnMain { onStatus(result.fold(onSuccess = success, onFailure = failure)) } + } +} + +private suspend fun releasing(flag: AtomicBoolean, call: suspend () -> Result): Result = + try { + call() + } finally { + flag.set(false) + } + +internal fun wearOutfit(outfit: OutfitInfo, onStatus: (String) -> Unit) { + if (!beginLookChange(onStatus)) return + launchLibraryCall( + onStatus, + "Switching to ${outfit.name}...", + { releasing(lookChangePending) { OutfitLibrary.apply(outfit.id) } }, + OutfitLibrary::failureMessage, + ) { "Wearing ${it.name}." } +} + +internal fun createOutfitFromCurrent(onStatus: (String) -> Unit) { + if (!beginLookChange(onStatus)) return + launchLibraryCall( + onStatus, + "Saving your current look...", + { releasing(lookChangePending) { OutfitLibrary.createFromCurrent() } }, + OutfitLibrary::failureMessage, + ) { "Saved ${it.name}." } +} + +internal fun renameOutfit(outfit: OutfitInfo, name: String, onStatus: (String) -> Unit) { + launchLibraryCall( + onStatus, + null, + { OutfitLibrary.rename(outfit.id, name) }, + OutfitLibrary::failureMessage, + ) { "Renamed ${outfit.name} to ${it.name}." } +} + +internal fun toggleOutfitFavorite(outfit: OutfitInfo, onStatus: (String) -> Unit) { + val favorite = !outfit.favorite + launchLibraryCall( + onStatus, + null, + { OutfitLibrary.setFavorite(outfit.id, favorite) }, + OutfitLibrary::failureMessage, + ) { if (favorite) "Added ${it.name} to favourites." else "Removed ${it.name} from favourites." } +} + +internal fun deleteOutfit(outfit: OutfitInfo, onStatus: (String) -> Unit) { + launchLibraryCall( + onStatus, + "Deleting ${outfit.name}...", + { OutfitLibrary.delete(outfit.id) }, + OutfitLibrary::failureMessage, + ) { "Deleted ${outfit.name}." } +} + +internal fun beginSkinChange(): Boolean = beginLookChange() + +internal fun endSkinChange() = lookChangePending.set(false) + +internal fun wearSkin(skin: PlayerSkinInfo, onStatus: (String) -> Unit) { + if (!beginLookChange(onStatus)) return + launchLibraryCall( + onStatus, + "Changing your skin to ${skin.name}...", + { releasing(lookChangePending) { SkinLibrary.wear(skin.id) } }, + SkinLibrary::failureMessage, + ) { "Wearing ${skin.name}." } +} + +internal fun renameSkin(skin: PlayerSkinInfo, name: String, onStatus: (String) -> Unit) { + launchLibraryCall( + onStatus, + null, + { SkinLibrary.rename(skin.id, name) }, + SkinLibrary::failureMessage, + ) { "Renamed ${skin.name} to ${it.name}." } +} + +internal fun toggleSkinFavorite(skin: PlayerSkinInfo, onStatus: (String) -> Unit) { + val favorite = !skin.favorite + launchLibraryCall( + onStatus, + null, + { SkinLibrary.setFavorite(skin.id, favorite) }, + SkinLibrary::failureMessage, + ) { if (favorite) "Added ${it.name} to favourites." else "Removed ${it.name} from favourites." } +} + +internal fun switchSkinModel(skin: PlayerSkinInfo, onStatus: (String) -> Unit) { + val model = if (skin.model == SkinModel.Slim) SkinModel.Classic else SkinModel.Slim + if (!beginLookChange(onStatus)) return + launchLibraryCall( + onStatus, + null, + { releasing(lookChangePending) { SkinLibrary.setModel(skin.id, model) } }, + SkinLibrary::failureMessage, + ) { "${it.name} now uses ${if (it.model == SkinModel.Slim) "slim" else "classic"} arms." } +} + +internal fun deleteSkin(skin: PlayerSkinInfo, onStatus: (String) -> Unit) { + launchLibraryCall( + onStatus, + "Deleting ${skin.name}...", + { SkinLibrary.delete(skin.id) }, + SkinLibrary::failureMessage, + ) { "Deleted ${skin.name}." } +} + +@Composable +internal fun rememberCosmeticItems(refreshKey: Int): List = + remember(refreshKey) { + val ownedIds = CosmeticCatalog.ownedIds() + val equippedIds = CosmeticCatalog.localEquipped().equipped.values.toSet() + val selectedEmote = CosmeticCatalog.selectedEmoteId() + + val groupItems = CosmeticCatalog.cosmeticGroupViews() + .sortedWith(compareBy { it.type.ordinal }.thenBy { it.groupId }) + .map { group -> + val byLabel = LinkedHashMap() + for (variant in group.variants) { + byLabel.getOrPut(variant.variantName) { + CosmeticVariantUi(variant.id, variant.variantName) + } + } + val equippedVariant = group.variants.firstOrNull { it.id in equippedIds } + CosmeticUiItem( + groupId = group.groupId, + type = group.type, + name = group.name, + collection = "${group.type.displayName} Collection", + owned = group.variants.any { it.id in ownedIds }, + equipped = equippedVariant != null, + variants = byLabel.values.toList(), + equippedVariantId = equippedVariant?.let { byLabel[it.variantName]?.id ?: it.id }, + equippedCosmeticId = equippedVariant?.id, + ) + } + + val emoteItems = CosmeticCatalog.allEmoteDefinitions() + .sortedBy { it.id } + .map { emote -> + CosmeticUiItem( + groupId = emote.id, + type = CosmeticType.Emote, + name = emote.name, + collection = "${CosmeticType.Emote.displayName} Collection", + owned = emote.id in ownedIds, + equipped = selectedEmote == emote.id, + variants = listOf(CosmeticVariantUi(emote.id, emote.name)), + equippedVariantId = if (selectedEmote == emote.id) emote.id else null, + ) + } + + groupItems + emoteItems + } + +internal fun equip(item: CosmeticUiItem, variantId: Int, onComplete: (String) -> Unit) { + if (!item.owned) { + onComplete("${item.name} is not in your locker.") + return + } + + PolyPlusClient.SCOPE.launch { + val result = equipLock.withLock { + if (item.type == CosmeticType.Emote) { + CosmeticService.equipEmote(variantId) + } else { + CosmeticService.equip(variantId) + } + } + ClientPlatform.runOnMain { + onComplete( + result.fold( + onSuccess = { "Equipped ${item.name}." }, + onFailure = { "Failed to equip ${item.name}: ${it.message}" }, + ), + ) + } + } +} + +internal fun unequip(item: CosmeticUiItem, onComplete: (String) -> Unit) { + PolyPlusClient.SCOPE.launch { + val result = equipLock.withLock { + if (item.type == CosmeticType.Emote) { + CosmeticService.clearEmote() + } else { + val slot = CosmeticCatalog.localEquipped().equipped.entries + .firstOrNull { it.value == item.equippedCosmeticId }?.key + if (slot == null) { + Result.success(Unit) + } else { + CosmeticService.clearSlot(slot) + } + } + } + ClientPlatform.runOnMain { + onComplete( + result.fold( + onSuccess = { "Unequipped ${item.name}." }, + onFailure = { "Failed to unequip ${item.name}: ${it.message}" }, + ), + ) + } + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt new file mode 100644 index 00000000..f0fa0c73 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCapes.kt @@ -0,0 +1,137 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.PolyPlusCosmeticsConfig +import org.polyfrost.polyplus.client.cosmetics.CapeResolver +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.clickableWithSound +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun CapeGroupHeading(title: String, note: String, forceToggle: Boolean) { + Row( + modifier = Modifier.fillMaxWidth().height(28.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + SocialText(title, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + InfoTip(note) + Spacer(Modifier.weight(1f)) + if (forceToggle) ForceCapesToggle() + } +} + +@Composable +private fun ForceCapesToggle() { + var forced by remember { mutableStateOf(PolyPlusCosmeticsConfig.forcePolyPlusCapes) } + val modName = CapeResolver.localOverrideModName + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + val toggle = { + forced = !forced + PolyPlusCosmeticsConfig.forcePolyPlusCapes = forced + PolyPlusCosmeticsConfig.save() + } + Row( + modifier = Modifier.hoverable(interaction).clickableWithSound(toggle), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box { + SocialText( + if (modName != null) "Show over $modName" else "Show over other mods", + color = if (hovered) theme.textColor else theme.textColorSecondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 220.dp), + ) + if (hovered) { + TooltipPopup( + if (modName != null) { + "$modName sets your cape. Turn this on to show your PolyPlus cape instead, or your Minecraft cape if you have none." + } else { + "When another mod sets your cape, its cape is shown. Turn this on to show your PolyPlus cape instead, or your Minecraft cape if you have none." + }, + ) + } + } + CapeSwitch(forced) + } +} + +@Composable +private fun CapeSwitch(checked: Boolean) { + val theme = LocalTheme.current + val track by animateColorAsState(if (checked) Accent else theme.controlTrackColor) + val thumbOffset by animateDpAsState(if (checked) 19.dp else 3.dp, animationSpec = spring()) + Box( + modifier = Modifier.size(width = 34.dp, height = 18.dp) + .clip(theme.circleShape) + .background(track), + contentAlignment = Alignment.CenterStart, + ) { + Box( + Modifier.offset(x = thumbOffset) + .size(12.dp) + .background(theme.controlThumbColor, theme.circleShape), + ) + } +} + +@Composable +private fun InfoTip(text: String) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + Box(Modifier.size(16.dp).hoverable(interaction), contentAlignment = Alignment.Center) { + Icon("info", color = if (hovered) theme.textColor else theme.textColorSecondary, modifier = Modifier.size(14.dp)) + if (hovered) TooltipPopup(text) + } +} + +@Composable +internal fun CapeSectionNote(text: String) { + Box( + modifier = Modifier.fillMaxWidth().height(57.dp) + .clip(ppShape(16.dp)) + .border(1.dp, LocalTheme.current.borderColor, ppShape(16.dp)) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart, + ) { + SocialText(text, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt new file mode 100644 index 00000000..977166ae --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeCommon.kt @@ -0,0 +1,197 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun SmallButton( + label: String, + iconPath: String, + primary: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier.height(33.dp) + .clip(ppShape(8.dp)) + .background(if (primary) Accent else LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(8.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(iconPath, color = if (primary) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(8.dp)) + SocialText(label, color = if (primary) LocalTheme.current.accentTextColor else LocalTheme.current.textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +internal fun WardrobeTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + maxLength: Int = 64, + onSubmit: (() -> Unit)? = null, +) { + val theme = LocalTheme.current + BasicTextField( + value = value, + onValueChange = { if (it.length <= maxLength) onValueChange(it) }, + singleLine = true, + textStyle = TextStyle( + color = theme.textColor, + fontSize = 12.sp, + fontFamily = theme.typography.family, + ), + cursorBrush = SolidColor(theme.textColor), + keyboardActions = onSubmit?.let { submit -> KeyboardActions(onDone = { submit() }) } ?: KeyboardActions.Default, + keyboardOptions = if (onSubmit != null) KeyboardOptions(imeAction = ImeAction.Done) else KeyboardOptions.Default, + modifier = modifier + .clip(ppShape(5.dp)) + .background(theme.chipBackground) + .border(1.dp, theme.borderColor, ppShape(5.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp) + .trackTextInputFocus(), + decorationBox = { inner -> + Box(contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + SocialText(placeholder, color = theme.textColorSecondary, fontSize = 12.sp, maxLines = 1) + } + inner() + } + }, + ) +} + +@Composable +internal fun CenteredNote(text: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + SocialText(text, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, textAlign = TextAlign.Center) + } +} + +@Composable +internal fun CardLabel( + text: String, + color: Color, + fontSize: TextUnit, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Normal, +) { + var truncated by remember(text) { mutableStateOf(false) } + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + + Box(modifier.hoverable(interaction)) { + SocialText( + text, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + maxLines = 1, + softWrap = true, + overflow = TextOverflow.Ellipsis, + onTextLayout = { truncated = it.hasVisualOverflow }, + ) + if (truncated && hovered) { + TooltipPopup(text) + } + } +} + +@Composable +internal fun TooltipPopup(text: String) { + val gap = with(LocalDensity.current) { 6.dp.roundToPx() } + val positionProvider = remember(gap) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val x = anchorBounds.left.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) + val above = anchorBounds.top - gap - popupContentSize.height + val y = if (above >= 0) above else anchorBounds.bottom + gap + return IntOffset(x, y) + } + } + } + Popup( + popupPositionProvider = positionProvider, + properties = PopupProperties(focusable = false, clippingEnabled = false), + ) { + Box( + modifier = Modifier.widthIn(max = 240.dp) + .clip(ppShape(6.dp)) + .background(LocalTheme.current.popupBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 5.dp), + ) { + SocialText(text, color = LocalTheme.current.textColor, fontSize = 12.sp) + } + } +} + +@Composable +@ReadOnlyComposable +internal fun cardBrush(): Brush = SolidColor(LocalTheme.current.modCardBackground) + +@Composable +internal fun Modifier.wardrobePanel(): Modifier = + clip(ppShape(16.dp)) + .background(LocalTheme.current.modCardBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(16.dp)) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt new file mode 100644 index 00000000..e4b4933b --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeGrid.kt @@ -0,0 +1,796 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import org.polyfrost.oneconfig.api.ui.v1.OneConfigUI +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.navigation.graph.KeybindsGraph +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.cosmetics.CosmeticSwatches +import org.polyfrost.polyplus.client.cosmetics.MojangCape +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.SwatchArt +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.PlayerSkinInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinModel +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.gui.ppShapeOf +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.network.http.responses.BundleInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +@Composable +internal fun WardrobeGrid( + entries: List, + state: LazyGridState, + ownedIds: Set, + cartKeys: Set, + selection: WardrobeSelection?, + variantPicks: Map, + storeVariantPicks: Map, + mojangBusy: Boolean, + modifier: Modifier, + coverFor: (CosmeticUiItem) -> Int?, + onOwned: (CosmeticUiItem) -> Unit, + onPreviewOwned: (CosmeticUiItem) -> Unit, + onSelectVariant: (Int, Int) -> Unit, + onShop: (CosmeticStoreInfo) -> Unit, + onPickStoreVariant: (Int, Int) -> Unit, + onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + onMojang: (MojangCape) -> Unit, + onBundle: (BundleInfo) -> Unit, + onBuyBundle: (BundleInfo) -> Unit, + onLibraryAction: (WardrobeSection) -> Unit, + onSelectOutfit: (OutfitInfo) -> Unit, + onSelectSkin: (PlayerSkinInfo) -> Unit, + onStatus: (String) -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Fixed(4), + state = state, + modifier = modifier, + contentPadding = PaddingValues(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + entries, + key = { it.key }, + span = { if (it.fullSpan) GridItemSpan(maxLineSpan) else GridItemSpan(1) }, + ) { entry -> + when (entry) { + is WardrobeEntry.Divider -> WardrobeDivider(entry.label) + is WardrobeEntry.Group -> CapeGroupHeading(entry.title, entry.note, entry.forceToggle) + is WardrobeEntry.Note -> CapeSectionNote(entry.text) + is WardrobeEntry.Banner -> WardrobeBanner(entry.text, entry.action, onAction = { OneConfigUI.open(KeybindsGraph) }) + is WardrobeEntry.Owned -> { + val item = entry.item + val variantId = selectedVariantId(item, variantPicks) + WardrobeCard( + name = item.name, + detail = { CardDetailText(item.collection) }, + action = equipAction(item.equipped), + onAction = { onOwned(item) }, + equipped = item.equipped, + selected = selection == WardrobeSelection.Owned(item.groupId, item.type), + onClick = { onOwned(item) }, + variants = item.variants, + selectedVariantId = variantId, + onPickVariant = { onSelectVariant(item.groupId, it) }, + menu = listOf( + CardMenuAction("Preview") { onPreviewOwned(item) }, + CardMenuAction(if (item.equipped) "Unequip" else "Equip") { onOwned(item) }, + ), + ) { modifier -> + val (source, loadTick) = if (variantId != null) { + rememberCosmeticPreviewSource(variantId, item.type) + } else { + null to 0 + } + CosmeticThumbnail( + source, + item.type, + "card-${item.groupId}-$variantId-$loadTick", + modifier, + checker = false, + coverAssetId = coverFor(item), + ) + } + } + is WardrobeEntry.Shop -> { + val info = entry.info + val variant = selectedStoreVariant(info, storeVariantPicks, ownedIds) + val owned = info.fullyOwned(ownedIds) + val inCart = cosmeticCartKey(variant.id) in cartKeys + val variants = info.uiVariants() + val variantCount = variants.size + WardrobeCard( + name = info.name, + detail = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PriceLabel(info) + if (variantCount > 1) { + SocialText("$variantCount variants", color = LocalTheme.current.textColorSecondary, fontSize = 11.sp) + } + } + }, + action = when { + owned || variant.id in ownedIds -> CardAction("Owned", "tick", enabled = false) + inCart -> CardAction("In cart", "tick", enabled = false) + else -> CardAction("Add to cart", "plus") + }, + onAction = { onAddToCart(info, variant) }, + equipped = false, + selected = selection == WardrobeSelection.Shop(info.id), + badge = when { + owned -> null + info.discounted -> CardBadge("${info.discountRate}% OFF", CardBadgeStyle.Sale) + isNewItem(info.createdAt) -> CardBadge("NEW", CardBadgeStyle.New) + else -> null + }, + onClick = { onShop(info) }, + variants = variants, + selectedVariantId = variant.id, + onPickVariant = { onPickStoreVariant(info.id, it) }, + menu = listOf( + CardMenuAction("Preview") { onShop(info) }, + CardMenuAction("Add to cart", enabled = !inCart && !owned && variant.id !in ownedIds) { onAddToCart(info, variant) }, + CardMenuAction("Buy now", enabled = !owned && variant.id !in ownedIds) { onBuyNow(info, variant) }, + ), + ) { modifier -> + val (source, loadTick) = rememberCosmeticPreviewSource(variant.id, info.type) + CosmeticThumbnail( + source, + info.type, + "shop-${variant.id}-$loadTick", + modifier, + checker = false, + coverAssetId = info.coverAssetId, + ) + } + } + is WardrobeEntry.Mojang -> { + val cape = entry.cape + WardrobeCard( + name = cape.name, + detail = { CardDetailText(MOJANG_CAPE_COLLECTION) }, + action = equipAction(cape.active), + onAction = { onMojang(cape) }, + equipped = cape.active, + selected = selection == WardrobeSelection.Mojang(cape.id), + enabled = !mojangBusy, + onClick = { onMojang(cape) }, + menu = listOf( + CardMenuAction(if (cape.active) "Remove" else "Equip") { onMojang(cape) }, + ), + ) { modifier -> + val textureVersion = MojangCapeService.textureVersion + val texture = remember(cape.textureHash, textureVersion) { MojangCapeService.texture(cape) } + val source = remember(texture) { + texture?.let { PlayerPreviewSource.Override(CosmeticEquipment(), mojangCape = it) } + } + CosmeticThumbnail( + source, + CosmeticType.Cape, + "mojang-${cape.textureHash}-${texture != null}", + modifier, + checker = false, + ) + } + } + is WardrobeEntry.Bundle -> { + val bundle = entry.bundle + WardrobeCard( + name = bundle.name, + detail = { + Row(verticalAlignment = Alignment.CenterVertically) { + PriceLabel(bundle.basePrice, bundle.finalPrice, bundle.discounted) + } + }, + action = CardAction("View bundle", "right-arrow"), + onAction = { onBundle(bundle) }, + equipped = false, + selected = selection == WardrobeSelection.Bundle(bundle.id), + badge = if (bundle.discounted) { + CardBadge("SAVE ${bundle.discountRate}%", CardBadgeStyle.Sale) + } else { + CardBadge("BUNDLE", CardBadgeStyle.Accent) + }, + onClick = { onBundle(bundle) }, + menu = listOf( + CardMenuAction("View bundle") { onBundle(bundle) }, + CardMenuAction("Buy now") { onBuyBundle(bundle) }, + ), + ) { modifier -> + CoverThumbnail(bundle.assetId, modifier) + } + } + is WardrobeEntry.LibraryHeader -> LibraryIntro( + note = entry.note, + action = entry.action, + onAction = { onLibraryAction(entry.section) }, + ) + is WardrobeEntry.Outfit -> { + val outfit = entry.outfit + var renaming by remember { mutableStateOf(false) } + WardrobeCard( + name = outfit.name, + detail = { + CardDetailText(if (entry.active) "Active" else cosmeticCountLabel(outfit.equipped.size)) + if (renaming) { + RenamePopup( + current = outfit.name, + onSave = { renameOutfit(outfit, it, onStatus) }, + onDismiss = { renaming = false }, + ) + } + }, + action = wearAction(entry.active), + onAction = { wearOutfit(outfit, onStatus) }, + equipped = entry.active, + selected = selection == WardrobeSelection.Outfit(outfit.id), + enabled = !OutfitLibrary.busy, + badge = favoriteBadge(outfit.favorite), + onClick = { onSelectOutfit(outfit) }, + menu = listOf( + CardMenuAction("Wear", enabled = !entry.active) { wearOutfit(outfit, onStatus) }, + CardMenuAction("Rename") { renaming = true }, + CardMenuAction(favoriteMenuLabel(outfit.favorite)) { toggleOutfitFavorite(outfit, onStatus) }, + CardMenuAction("Delete") { deleteOutfit(outfit, onStatus) }, + ), + ) { modifier -> + OutfitThumbnail(outfit, modifier) + } + } + is WardrobeEntry.Skin -> { + val skin = entry.skin + var renaming by remember { mutableStateOf(false) } + WardrobeCard( + name = skin.name, + detail = { + CardDetailText(skin.model.label) + if (renaming) { + RenamePopup( + current = skin.name, + onSave = { renameSkin(skin, it, onStatus) }, + onDismiss = { renaming = false }, + ) + } + }, + action = wearAction(entry.worn), + onAction = { wearSkin(skin, onStatus) }, + equipped = entry.worn, + selected = selection == WardrobeSelection.Skin(skin.id), + enabled = !SkinLibrary.busy, + badge = favoriteBadge(skin.favorite), + onClick = { onSelectSkin(skin) }, + menu = listOf( + CardMenuAction("Wear", enabled = !entry.worn) { wearSkin(skin, onStatus) }, + CardMenuAction("Rename") { renaming = true }, + CardMenuAction(favoriteMenuLabel(skin.favorite)) { toggleSkinFavorite(skin, onStatus) }, + CardMenuAction( + if (skin.model == SkinModel.Slim) "Switch to classic arms" else "Switch to slim arms", + ) { switchSkinModel(skin, onStatus) }, + CardMenuAction("Delete") { deleteSkin(skin, onStatus) }, + ), + ) { modifier -> + SkinThumbnail(skin, modifier) + } + } + } + } + } +} + +@Composable +private fun WardrobeDivider(label: String) { + val theme = LocalTheme.current + Row( + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.weight(1f).height(1.dp).background(theme.borderColor)) + SocialText(label.uppercase(), color = theme.textColorSecondary, fontSize = 11.sp, fontWeight = FontWeight.Medium) + Box(Modifier.weight(1f).height(1.dp).background(theme.borderColor)) + } +} + +private data class CardAction(val label: String, val icon: String, val enabled: Boolean = true) + +private class CardMenuAction(val label: String, val enabled: Boolean = true, val onSelect: () -> Unit) + +private enum class CardBadgeStyle { New, Sale, Accent } + +private data class CardBadge(val label: String, val style: CardBadgeStyle, val icon: String? = null) + +private fun equipAction(equipped: Boolean): CardAction = + if (equipped) CardAction("Equipped", "tick") else CardAction("Equip", "profiles") + +private fun wearAction(wearing: Boolean): CardAction = + if (wearing) CardAction("Wearing", "tick", enabled = false) else CardAction("Wear", "profiles") + +private fun favoriteBadge(favorite: Boolean): CardBadge? = + if (favorite) CardBadge("", CardBadgeStyle.Accent, icon = "star-filled") else null + +private fun favoriteMenuLabel(favorite: Boolean): String = + if (favorite) "Remove from favourites" else "Add to favourites" + +private fun cosmeticCountLabel(count: Int): String = when (count) { + 0 -> "No cosmetics" + 1 -> "1 cosmetic" + else -> "$count cosmetics" +} + +private val CARD_SUCCESS = Color(0xFF239A60) +private val CARD_NEUTRAL_CHIP = Color(0xFFD9D9D9) +private val CARD_FRAME = Color(0xFF232D32) +private val CARD_HAIRLINE = Color(0x1AFFFFFF) +private val CARD_STRIP = Color(0xB3232D32) +private val CARD_HIGHLIGHT = Color(0x0DFFFFFF) + +private const val MAX_VISIBLE_SWATCHES = 4 + +@Composable +private fun CardDetailText(text: String) { + CardLabel(text, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, modifier = Modifier.fillMaxWidth()) +} + +@Composable +private fun WardrobeCard( + name: String, + detail: @Composable () -> Unit, + action: CardAction, + onAction: () -> Unit, + equipped: Boolean, + selected: Boolean, + onClick: () -> Unit, + badge: CardBadge? = null, + enabled: Boolean = true, + variants: List = emptyList(), + selectedVariantId: Int? = null, + onPickVariant: (Int) -> Unit = {}, + menu: List = emptyList(), + thumbnail: @Composable (Modifier) -> Unit, +) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + var menuOpen by remember { mutableStateOf(false) } + val outline = when { + equipped -> Accent + selected || (hovered && enabled) -> theme.textColorSecondary + else -> theme.borderColor + } + Box( + modifier = Modifier.fillMaxWidth() + .alpha(if (enabled) 1f else 0.6f) + .clip(ppShape(12.dp)) + .background(theme.modCardBackground) + .drawBehind { + drawRect( + Brush.radialGradient( + colors = listOf(Color.Transparent, CARD_HIGHLIGHT), + center = Offset(size.width / 2f, size.height * 0.8f), + radius = size.maxDimension * 0.75f, + ), + ) + } + .border(1.dp, outline, ppShape(12.dp)) + .hoverable(interaction) + .clickable(enabled = enabled, onClick = onClick), + ) { + Column(Modifier.fillMaxWidth()) { + BoxWithConstraints(Modifier.fillMaxWidth().padding(start = 18.dp, end = 18.dp, top = 18.dp)) { + Box( + modifier = Modifier.fillMaxWidth().height(maxWidth) + .clip(ppShape(5.dp)) + .background(CARD_FRAME) + .border(1.dp, CARD_HAIRLINE, ppShape(5.dp)), + ) { + thumbnail(Modifier.fillMaxSize()) + if (hovered && variants.size > 1) { + VariantSwatches( + variants = variants, + selectedVariantId = selectedVariantId, + onPick = onPickVariant, + modifier = Modifier.align(Alignment.TopEnd).padding(6.dp), + ) + } + } + } + Spacer(Modifier.height(8.dp)) + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + CardLabel(name, color = theme.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + if (menu.isNotEmpty() && (hovered || menuOpen)) { + Box( + modifier = Modifier.padding(start = 6.dp).size(width = 22.dp, height = 0.dp), + contentAlignment = Alignment.Center, + ) { + CardMenuButton( + actions = menu, + enabled = enabled, + open = menuOpen, + onOpenChange = { menuOpen = it }, + ) + } + } + } + detail() + } + Spacer(Modifier.height(12.dp)) + CardActionStrip(action, active = equipped, enabled = enabled && action.enabled, onAction = onAction) + } + if (badge != null) { + CardBadgeTab(badge, Modifier.align(Alignment.TopCenter)) + } + } +} + +@Composable +private fun VariantSwatches( + variants: List, + selectedVariantId: Int?, + onPick: (Int) -> Unit, + modifier: Modifier, +) { + val theme = LocalTheme.current + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + variants.take(MAX_VISIBLE_SWATCHES).forEach { variant -> + VariantSwatch(variant, selected = variant.id == selectedVariantId, onClick = { onPick(variant.id) }) + } + val hidden = variants.size - MAX_VISIBLE_SWATCHES + if (hidden > 0) { + Box( + modifier = Modifier.height(16.dp).widthIn(min = 16.dp) + .clip(ppShape(5.dp)) + .background(theme.chipBackground) + .border(1.dp, CARD_HAIRLINE, ppShape(5.dp)) + .padding(horizontal = 2.dp), + contentAlignment = Alignment.Center, + ) { + SocialText("+$hidden", color = theme.textColor, fontSize = 10.sp, fontWeight = FontWeight.Medium) + } + } + } +} + +@Composable +private fun VariantSwatch(variant: CosmeticVariantUi, selected: Boolean, onClick: () -> Unit) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val swatchVersion = CosmeticSwatches.version + val art = remember(variant.id, swatchVersion) { CosmeticSwatches.get(variant.id) } + Box( + modifier = Modifier.size(width = 14.dp, height = 22.dp) + .clip(ppShape(5.dp)) + .background(theme.chipBackground) + .hoverable(interaction) + .clickable(onClick = onClick), + ) { + when (art) { + is SwatchArt.Picture -> Image( + bitmap = art.bitmap, + contentDescription = variant.name, + contentScale = ContentScale.FillBounds, + filterQuality = FilterQuality.None, + modifier = Modifier.fillMaxSize(), + ) + is SwatchArt.Stripes -> Column(Modifier.fillMaxSize()) { + for (color in art.colors) { + Box(Modifier.fillMaxWidth().weight(1f).background(color)) + } + } + null -> Unit + } + Box( + Modifier.fillMaxSize().border( + 1.dp, + when { + selected -> Accent + hovered -> theme.textColorSecondary + else -> CARD_HAIRLINE + }, + ppShape(5.dp), + ), + ) + if (hovered) { + TooltipPopup(variant.name) + } + } +} + +@Composable +private fun CardMenuButton( + actions: List, + enabled: Boolean, + open: Boolean, + onOpenChange: (Boolean) -> Unit, +) { + val theme = LocalTheme.current + Box( + modifier = Modifier.requiredSize(22.dp) + .clip(ppShape(6.dp)) + .background(theme.chipBackground) + .clickable { onOpenChange(!open) }, + contentAlignment = Alignment.Center, + ) { + Icon("dots-vertical", color = theme.textColor, modifier = Modifier.size(14.dp)) + if (open) { + CardMenuPopup(actions, enabled = enabled, onDismiss = { onOpenChange(false) }) + } + } +} + +@Composable +private fun rememberDropdownPosition(): PopupPositionProvider { + val gap = with(LocalDensity.current) { 4.dp.roundToPx() } + return remember(gap) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val x = (anchorBounds.right - popupContentSize.width) + .coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) + val below = anchorBounds.bottom + gap + val y = if (below + popupContentSize.height <= windowSize.height) { + below + } else { + (anchorBounds.top - gap - popupContentSize.height).coerceAtLeast(0) + } + return IntOffset(x, y) + } + } + } +} + +@Composable +private fun CardMenuPopup(actions: List, enabled: Boolean, onDismiss: () -> Unit) { + val theme = LocalTheme.current + Popup( + popupPositionProvider = rememberDropdownPosition(), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = Modifier.widthIn(min = 160.dp).width(IntrinsicSize.Max) + .clip(ppShape(16.dp)) + .background(theme.popupBackground) + .border(1.dp, theme.borderColor, ppShape(16.dp)) + .padding(6.dp), + ) { + actions.forEach { action -> + CardMenuRow(action.label, enabled = enabled && action.enabled) { + onDismiss() + action.onSelect() + } + } + } + } +} + +@Composable +private fun CardMenuRow(label: String, enabled: Boolean, onClick: () -> Unit) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + Box( + modifier = Modifier.fillMaxWidth().height(32.dp) + .alpha(if (enabled) 1f else 0.4f) + .clip(ppShape(8.dp)) + .background(if (hovered && enabled) theme.chipBackground else Color.Transparent) + .hoverable(interaction) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.CenterStart, + ) { + SocialText(label, color = theme.textColor, fontSize = 12.sp, maxLines = 1) + } +} + +@Composable +private fun RenamePopup(current: String, onSave: (String) -> Unit, onDismiss: () -> Unit) { + val theme = LocalTheme.current + var value by remember { mutableStateOf(current) } + val focusRequester = remember { FocusRequester() } + val save = { + val name = value.trim() + if (name.isNotEmpty()) { + onDismiss() + if (name != current) onSave(name) + } + } + Popup( + popupPositionProvider = rememberDropdownPosition(), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = Modifier.width(240.dp) + .clip(ppShape(16.dp)) + .background(theme.popupBackground) + .border(1.dp, theme.borderColor, ppShape(16.dp)) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SocialText("Rename", color = theme.textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) + WardrobeTextField( + value = value, + onValueChange = { value = it }, + placeholder = current, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + maxLength = MAX_LIBRARY_NAME_LENGTH, + onSubmit = save, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + SmallButton("Cancel", iconPath = "close", primary = false, onClick = onDismiss) + SmallButton("Save", iconPath = "tick", primary = true, onClick = save) + } + } + LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } + } +} + +@Composable +private fun CardActionStrip(action: CardAction, active: Boolean, enabled: Boolean, onAction: () -> Unit) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val color = if (active) theme.accentTextColor else theme.textColor + Box(Modifier.fillMaxWidth().height(36.dp)) { + Row( + modifier = Modifier.fillMaxSize() + .background(if (active) Accent else CARD_STRIP) + .hoverable(interaction) + .clickable(enabled = enabled, onClick = onAction), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(action.icon, color = color, modifier = Modifier.size(16.dp)) + SocialText(action.label, color = color, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + if (!active) { + Box( + Modifier.fillMaxWidth().height(1.dp) + .background(if (hovered && enabled) theme.textColorSecondary else CARD_HAIRLINE), + ) + } + } +} + +@Composable +private fun CardBadgeTab(badge: CardBadge, modifier: Modifier) { + val theme = LocalTheme.current + val color = if (badge.style == CardBadgeStyle.New) theme.textColor else theme.accentTextColor + Row( + modifier = modifier.widthIn(min = if (badge.label.isEmpty()) 40.dp else 81.dp).height(21.dp) + .clip(ppShapeOf(bottomStart = 7.dp, bottomEnd = 7.dp)) + .background( + when (badge.style) { + CardBadgeStyle.New -> CARD_SUCCESS + CardBadgeStyle.Sale -> CARD_NEUTRAL_CHIP + CardBadgeStyle.Accent -> Accent + }, + ) + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (badge.icon != null) { + Icon(badge.icon, color = color, modifier = Modifier.size(12.dp)) + } + if (badge.label.isNotEmpty()) { + SocialText( + badge.label, + color = color, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + } +} + +@Composable +private fun WardrobeBanner(text: String, action: String, onAction: () -> Unit) { + val theme = LocalTheme.current + Row( + modifier = Modifier.fillMaxWidth().wardrobePanel().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon("alert-circle", color = theme.textColor, modifier = Modifier.size(16.dp)) + SocialText(text, color = theme.textColor, fontSize = 12.sp, modifier = Modifier.weight(1f)) + SmallButton(action, iconPath = "keyboard", primary = false, onClick = onAction) + } +} + +@Composable +private fun LibraryIntro(note: String, action: String?, onAction: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().height(33.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SocialText(note, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp, modifier = Modifier.weight(1f)) + if (action != null) { + SmallButton(action, iconPath = "plus", primary = false, onClick = onAction) + } + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt new file mode 100644 index 00000000..4716ad7a --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHeader.kt @@ -0,0 +1,202 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun WardrobeHeader( + sections: List, + selected: WardrobeSection, + anchors: List>, + activeAnchor: Int?, + sort: WardrobeSort, + cartSize: Int, + showingHistory: Boolean, + storeEnabled: Boolean, + onSelectSection: (WardrobeSection) -> Unit, + onAnchor: (Int) -> Unit, + onCycleSort: () -> Unit, + onToggleHistory: () -> Unit, + onRefresh: () -> Unit, + onCart: () -> Unit, +) { + val theme = LocalTheme.current + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth().height(33.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (section in sections) { + SectionChip(section, selected = section == selected && !showingHistory, onClick = { onSelectSection(section) }) + } + } + if (storeEnabled) { + SmallButton("Cart ($cartSize)", iconPath = "assets/polyplus/ico/shopping-cart/0.svg", primary = true, onClick = onCart) + } + } + Row( + modifier = Modifier.fillMaxWidth().height(33.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (showingHistory) { + SocialText("Purchase history", color = theme.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + } else { + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (anchors.size > 1) { + for ((label, index) in anchors) { + AnchorChip(label, active = index == activeAnchor, onClick = { onAnchor(index) }) + } + } + } + } + if (storeEnabled && !showingHistory) { + SmallButton(sort.label, iconPath = "align", primary = false, onClick = onCycleSort) + } + if (storeEnabled) { + HeaderIconButton( + iconPath = if (showingHistory) "left-arrow" else "clipboard", + tooltip = if (showingHistory) "Back to the wardrobe" else "Purchase history", + onClick = onToggleHistory, + ) + } + HeaderIconButton(iconPath = "refresh", tooltip = "Refresh", onClick = onRefresh) + } + } +} + +@Composable +private fun SectionChip(section: WardrobeSection, selected: Boolean, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + val color = if (selected) theme.accentTextColor else theme.textColor + Row( + modifier = Modifier.height(33.dp) + .clip(ppShape(8.dp)) + .background(if (selected) Accent else theme.chipBackground) + .border(1.dp, if (hovered && !selected) theme.textColorSecondary else theme.borderColor, ppShape(8.dp)) + .hoverable(interaction) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(section.icon, color = color, modifier = Modifier.size(14.dp)) + SocialText(section.label, color = color, fontSize = 14.sp, fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal) + } +} + +@Composable +private fun AnchorChip(label: String, active: Boolean, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + Box( + modifier = Modifier.height(26.dp) + .clip(ppShape(6.dp)) + .background(if (active) theme.chipBackground else Color.Transparent) + .hoverable(interaction) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.Center, + ) { + SocialText( + label, + color = if (active || hovered) theme.textColor else theme.textColorSecondary, + fontSize = 12.sp, + fontWeight = if (active) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + ) + } +} + +@Composable +private fun HeaderIconButton(iconPath: String, tooltip: String, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + Box( + modifier = Modifier.size(33.dp) + .clip(ppShape(8.dp)) + .background(theme.chipBackground) + .border(1.dp, if (hovered) theme.textColorSecondary else theme.borderColor, ppShape(8.dp)) + .hoverable(interaction) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(iconPath, color = theme.textColor, modifier = Modifier.size(14.dp)) + if (hovered) TooltipPopup(tooltip) + } +} + +@Composable +internal fun PreviewModeToggle(wheelMode: Boolean, onChange: (Boolean) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().height(33.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PreviewModeOption("Character", "profiles", selected = !wheelMode, modifier = Modifier.weight(1f), onClick = { onChange(false) }) + PreviewModeOption("Emote wheel", "dots-grid", selected = wheelMode, modifier = Modifier.weight(1f), onClick = { onChange(true) }) + } +} + +@Composable +internal fun PreviewModeOption(label: String, iconPath: String?, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val theme = LocalTheme.current + val color = if (selected) theme.accentTextColor else theme.textColor + Row( + modifier = modifier.fillMaxHeight() + .clip(ppShape(8.dp)) + .background(if (selected) Accent else theme.chipBackground) + .border(1.dp, if (hovered && !selected) theme.textColorSecondary else theme.borderColor, ppShape(8.dp)) + .hoverable(interaction) + .clickable(onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (iconPath != null) Icon(iconPath, color = color, modifier = Modifier.size(14.dp)) + SocialText(label, color = color, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt new file mode 100644 index 00000000..c1cefbb6 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeHistory.kt @@ -0,0 +1,100 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.BillingService +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.network.http.responses.TransactionInfo +import org.polyfrost.polyplus.client.network.http.responses.TransactionStatus + +@Composable +internal fun HistoryScreen(ownedIds: Set, reloadKey: Int) { + var transactions by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var loadError by remember { mutableStateOf(null) } + + // keyed on ownership rather than the catalog poll so transactions only refetch when a purchase lands + LaunchedEffect(ownedIds, reloadKey) { + loading = true + loadError = null + BillingService.fetchTransactions() + .onSuccess { transactions = it } + .onFailure { loadError = "Couldn't load transactions: ${it.message}" } + loading = false + } + + Box( + modifier = Modifier.fillMaxSize().clip(ppShape(12.dp)) + .background(cardBrush()) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) + .padding(18.dp), + ) { + when { + loading && transactions.isEmpty() -> CenteredNote("Loading transactions...") + loadError != null && transactions.isEmpty() -> CenteredNote(loadError!!) + transactions.isEmpty() -> CenteredNote("No transactions yet.") + else -> Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + loadError?.let { SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) } + LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) { + items(transactions, key = { it.id }) { tx -> TransactionRow(tx) } + } + } + } + } +} + +@Composable +private fun TransactionRow(tx: TransactionInfo) { + Row( + modifier = Modifier.fillMaxWidth() + .clip(ppShape(8.dp)) + .border(1.dp, LocalTheme.current.borderColor, ppShape(8.dp)) + .padding(horizontal = 13.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { + Column(Modifier.weight(1f)) { + SocialText("Order #${tx.id}", color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + SocialText(tx.provider.displayName, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + tx.amount?.let { SocialText(money(it, tx.currency), color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) } + SocialText( + tx.status.displayName, + color = statusColor(tx.status), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +private fun statusColor(status: TransactionStatus): Color = when (status) { + TransactionStatus.Completed -> Color(0xFF239A60) + TransactionStatus.Pending -> Color(0xFFE0A030) + TransactionStatus.Failed -> Color(0xFFFF4444) + TransactionStatus.Refunded, TransactionStatus.PartiallyRefunded, TransactionStatus.Chargeback -> Color(0xFF8A9296) + TransactionStatus.Unknown -> Color(0xFF8A9296) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt new file mode 100644 index 00000000..a1e0926e --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLibrary.kt @@ -0,0 +1,489 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.minecraft.resources.Identifier +import org.polyfrost.oneconfig.api.ui.v1.api.TinyFdApi +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache +import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.MojangCapeState +import org.polyfrost.polyplus.client.cosmetics.PetDefinition +import org.polyfrost.polyplus.client.cosmetics.outfits.LibraryState +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitException +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.PlayerSkinInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinModel +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.gui.preview.PlayerPreview +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.network.http.responses.BodySlot +import org.polyfrost.polyplus.client.utils.ClientPlatform + + +internal const val MAX_LIBRARY_NAME_LENGTH = 64 + +private const val MAX_USERNAME_LENGTH = 16 + +private const val MAX_URL_LENGTH = 512 + +private const val SKIN_FIGURE_FILL = 0.85f + +internal val SkinModel.label: String + get() = when (this) { + SkinModel.Classic -> "Classic" + SkinModel.Slim -> "Slim" + } + +@Composable +internal fun OutfitThumbnail(outfit: OutfitInfo, modifier: Modifier) { + val installs = CosmeticAssetCache.installs + val textureVersion = MojangCapeService.textureVersion + val mojangState = MojangCapeService.state + LaunchedEffect(outfit.equipped) { + for ((slot, id) in outfit.equipped) { + if (!isOutfitAssetLoaded(slot, id)) CosmeticAssetCache.ensureCosmeticLoaded(id) + } + } + val mojangCape = remember(outfit.mojangCapeId, mojangState, textureVersion) { + val capeId = outfit.mojangCapeId ?: return@remember null + (mojangState as? MojangCapeState.Loaded)?.capes?.firstOrNull { it.id == capeId }?.let(MojangCapeService::texture) + } + val source = remember(outfit.equipped, installs, mojangCape) { outfitPreviewSource(outfit.equipped, mojangCape) } + val loaded = outfit.equipped.count { (slot, id) -> isOutfitAssetLoaded(slot, id) } + val capeId = outfit.equipped[BodySlot.Cape] + PlayerPreview( + modifier, + source = source, + autoSpin = false, + allowDrag = false, + modelScale = 0.42f, + verticalAnchor = 0.52f, + initialYaw = FRONT_YAW_DEG, + previewKey = "outfit-${outfit.id}-${outfit.updatedAt}-$loaded-${mojangCape != null}", + live = BodySlot.Aura in outfit.equipped || (capeId != null && CosmeticAssetCache.isCapeAnimated(capeId)), + ) +} + +private fun isOutfitAssetLoaded(slot: BodySlot, id: Int): Boolean = when (slot) { + BodySlot.Cape -> CosmeticAssetCache.isCapeLoaded(id) + BodySlot.Pet -> CosmeticAssetCache.getAttachedCosmetic(id) != null || CosmeticAssetCache.getPetDefinition(id) != null + else -> CosmeticAssetCache.getAttachedCosmetic(id) != null +} + +private fun outfitPreviewSource(equipped: Map, mojangCape: Identifier?): PlayerPreviewSource { + val equipment = CosmeticEquipment() + var pet: PetDefinition? = null + for ((slot, id) in equipped) { + if (slot == BodySlot.Cape) continue + val attached = CosmeticAssetCache.getAttachedCosmetic(id) + if (attached != null) { + equipment.equip(attached) + } else if (slot == BodySlot.Pet) { + pet = CosmeticAssetCache.getPetDefinition(id) + } + } + val capeId = equipped[BodySlot.Cape] + return PlayerPreviewSource.Override( + equipment, + pet = pet, + capeCosmeticId = capeId, + mojangCape = if (capeId == null) mojangCape else null, + ) +} + +@Composable +internal fun SkinThumbnail(skin: PlayerSkinInfo, modifier: Modifier) { + val version = SkinLibrary.bitmapVersion + val bitmap = remember(skin.hash, version) { SkinLibrary.bitmap(skin) } + val slim = skin.model == SkinModel.Slim + if (bitmap == null) { + Box(modifier) + } else { + Canvas(modifier) { drawSkinFront(bitmap, slim) } + } +} + +private fun DrawScope.drawSkinFront(skin: ImageBitmap, slim: Boolean) { + val legacy = skin.height <= 32 + val unit = floor(min(size.width / 16f, size.height / 32f) * SKIN_FIGURE_FILL).toInt().coerceAtLeast(1) + val originX = ((size.width - 16 * unit) / 2f).roundToInt() + val originY = ((size.height - 32 * unit) / 2f).roundToInt() + + fun part(u: Int, v: Int, w: Int, h: Int, x: Int, y: Int, mirror: Boolean = false) { + val dstOffset = IntOffset(originX + x * unit, originY + y * unit) + val dstSize = IntSize(w * unit, h * unit) + if (mirror) { + val pivot = Offset(dstOffset.x + dstSize.width / 2f, dstOffset.y + dstSize.height / 2f) + scale(-1f, 1f, pivot) { + drawImage(skin, IntOffset(u, v), IntSize(w, h), dstOffset, dstSize, filterQuality = FilterQuality.None) + } + } else { + drawImage(skin, IntOffset(u, v), IntSize(w, h), dstOffset, dstSize, filterQuality = FilterQuality.None) + } + } + + val arm = if (slim) 3 else 4 + part(8, 8, 8, 8, 4, 0) + part(20, 20, 8, 12, 4, 8) + part(44, 20, arm, 12, 4 - arm, 8) + if (legacy) part(44, 20, arm, 12, 12, 8, mirror = true) else part(36, 52, arm, 12, 12, 8) + part(4, 20, 4, 12, 4, 20) + if (legacy) part(4, 20, 4, 12, 8, 20, mirror = true) else part(20, 52, 4, 12, 8, 20) + if (!legacy) { + part(20, 36, 8, 12, 4, 8) + part(44, 36, arm, 12, 4 - arm, 8) + part(52, 52, arm, 12, 12, 8) + part(4, 36, 4, 12, 4, 20) + part(4, 52, 4, 12, 8, 20) + } + part(40, 8, 8, 8, 4, 0) +} + +@Composable +internal fun OutfitSwitcher(onStatus: (String) -> Unit) { + LaunchedEffect(Unit) { OutfitLibrary.refreshIfNeeded() } + val state = OutfitLibrary.state + if (state is LibraryState.Unavailable) return + val theme = LocalTheme.current + val outfits = OutfitLibrary.outfits.favoritesFirst() + val activeId = OutfitLibrary.activeOutfitId + val busy = OutfitLibrary.busy + val loaded = state == LibraryState.Loaded + val active = outfits.firstOrNull { it.id == activeId } + val canCycle = !busy && loaded && outfits.isNotEmpty() && (active == null || outfits.size > 1) + val label = when { + active != null -> active.name + !loaded -> "Loading outfits..." + outfits.isEmpty() -> "No saved outfits" + else -> "No outfit selected" + } + + fun cycle(step: Int) { + val index = outfits.indexOfFirst { it.id == activeId } + val next = if (index < 0) { + if (step > 0) outfits.first() else outfits.last() + } else { + outfits[(index + step).mod(outfits.size)] + } + wearOutfit(next, onStatus) + } + + Row( + modifier = Modifier.fillMaxWidth().height(28.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + WheelIconButton("left-arrow", enabled = canCycle) { cycle(-1) } + Box( + modifier = Modifier.weight(1f).fillMaxHeight() + .clip(ppShape(8.dp)) + .background(theme.chipBackground) + .border(1.dp, theme.borderColor, ppShape(8.dp)) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.Center, + ) { + CardLabel( + label, + color = if (active != null) theme.textColor else theme.textColorSecondary, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + WheelIconButton("right-arrow", enabled = canCycle) { cycle(1) } + WheelIconButton("plus", enabled = !busy && loaded, tooltip = "New outfit from current look") { + createOutfitFromCurrent(onStatus) + } + } +} + +private enum class SkinSource(val label: String, val icon: String) { + Username("Username", "profiles"), + Url("URL", "cloud"), + File("File", "folder"), +} + +@Composable +internal fun AddSkinPanel(onClose: () -> Unit, onStatus: (String) -> Unit, modifier: Modifier) { + val theme = LocalTheme.current + val sources = SkinSource.entries + var source by remember { mutableStateOf(SkinSource.Username) } + var username by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var file by remember { mutableStateOf(null) } + var name by remember { mutableStateOf("") } + var model by remember { mutableStateOf(SkinModel.Classic) } + var picking by remember { mutableStateOf(false) } + var working by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + val shown = remember { AtomicBoolean(true) } + DisposableEffect(Unit) { onDispose { shown.set(false) } } + + fun chooseFile() { + if (picking) return + picking = true + PolyPlusClient.SCOPE.launch { + val picked = withContext(Dispatchers.IO) { runCatching { pickSkinFile() }.getOrNull() } + ClientPlatform.runOnMain { + picking = false + if (picked != null) { + file = picked + message = null + } + } + } + } + + fun submit() { + if (working) return + val chosen = source + val player = username.trim() + val link = url.trim() + val path = file + val label = name.trim().ifEmpty { null } + val arms = model + val problem = when (chosen) { + SkinSource.Username -> if (player.isEmpty()) "Enter a Minecraft username." else null + SkinSource.Url -> if (link.isEmpty()) "Enter the link to a skin image." else null + SkinSource.File -> if (path == null) "Choose a PNG file first." else null + } + if (problem != null) { + message = problem + return + } + if (!beginSkinChange()) { + message = "Another skin change is still running." + return + } + working = true + message = "Adding skin..." + PolyPlusClient.SCOPE.launch { + val result = try { + when (chosen) { + SkinSource.Username -> SkinLibrary.addFromUsername(player) + SkinSource.Url -> SkinLibrary.addFromUrl(link, label, arms) + SkinSource.File -> { + val bytes = (path?.let { readSkinFile(it) } ?: Result.failure(OutfitException(UNREADABLE_SKIN_FILE))) + bytes.fold( + onSuccess = { SkinLibrary.addFromBytes(it, label, arms) }, + onFailure = { Result.failure(it) }, + ) + } + } + } finally { + endSkinChange() + } + ClientPlatform.runOnMain { + working = false + result.fold( + onSuccess = { skin -> + message = null + onStatus("Added ${skin.name} to your skins.") + if (shown.get()) onClose() + }, + onFailure = { message = SkinLibrary.failureMessage(it) }, + ) + } + } + } + + Column( + modifier = modifier.wardrobePanel().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SocialText("Add skin", color = theme.textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + WheelIconButton("close", enabled = true, tooltip = "Close", onClick = onClose) + } + SegmentedControl( + options = sources.map { it.label to it.icon }, + selectedIndex = sources.indexOf(source), + onSelect = { index -> + source = sources[index] + message = null + }, + ) + when (source) { + SkinSource.Username -> FieldGroup("Minecraft username") { + WardrobeTextField( + value = username, + onValueChange = { username = it }, + placeholder = "Username", + modifier = Modifier.fillMaxWidth(), + maxLength = MAX_USERNAME_LENGTH, + onSubmit = { submit() }, + ) + SocialText("Saves this player's current skin to your library without changing yours.", color = theme.textColorSecondary, fontSize = 12.sp) + } + SkinSource.Url -> FieldGroup("Image URL") { + WardrobeTextField( + value = url, + onValueChange = { url = it }, + placeholder = "https://", + modifier = Modifier.fillMaxWidth(), + maxLength = MAX_URL_LENGTH, + onSubmit = { submit() }, + ) + SocialText("Link to a 64×64 or 64×32 PNG skin. Adding it also puts it on.", color = theme.textColorSecondary, fontSize = 12.sp) + } + SkinSource.File -> FieldGroup("Skin file") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SmallButton(if (picking) "Choosing..." else "Choose file", iconPath = "folder", primary = false, onClick = { chooseFile() }) + CardLabel( + file?.fileName?.toString() ?: "No file chosen", + color = if (file != null) theme.textColor else theme.textColorSecondary, + fontSize = 12.sp, + modifier = Modifier.weight(1f), + ) + } + SocialText("A 64×64 or 64×32 PNG skin. Adding it also puts it on.", color = theme.textColorSecondary, fontSize = 12.sp) + } + } + if (source != SkinSource.Username) { + FieldGroup("Name") { + WardrobeTextField( + value = name, + onValueChange = { name = it }, + placeholder = "Optional", + modifier = Modifier.fillMaxWidth(), + maxLength = MAX_LIBRARY_NAME_LENGTH, + ) + } + FieldGroup("Arms") { + SegmentedControl( + options = SkinModel.entries.map { it.label to null }, + selectedIndex = SkinModel.entries.indexOf(model), + onSelect = { model = SkinModel.entries[it] }, + ) + } + } + message?.let { SocialText(it, color = theme.textColorSecondary, fontSize = 12.sp) } + PrimaryWideButton(if (working) "Adding..." else "Add skin", iconPath = "plus", enabled = !working, onClick = { submit() }) + } +} + +@Composable +private fun SegmentedControl( + options: List>, + selectedIndex: Int, + onSelect: (Int) -> Unit, +) { + val theme = LocalTheme.current + Row( + modifier = Modifier.fillMaxWidth().height(33.dp) + .clip(ppShape(8.dp)) + .background(theme.chipBackground) + .border(1.dp, theme.borderColor, ppShape(8.dp)) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + options.forEachIndexed { index, (label, icon) -> + val selected = index == selectedIndex + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val color = if (selected) theme.accentTextColor else if (hovered) theme.textColor else theme.textColorSecondary + Row( + modifier = Modifier.weight(1f).fillMaxHeight() + .clip(ppShape(6.dp)) + .background(if (selected) Accent else Color.Transparent) + .hoverable(interaction) + .clickable { onSelect(index) }, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) Icon(icon, color = color, modifier = Modifier.size(12.dp)) + SocialText(label, color = color, fontSize = 12.sp, fontWeight = FontWeight.Medium, maxLines = 1) + } + } + } +} + +@Composable +private fun PrimaryWideButton(label: String, iconPath: String, enabled: Boolean, onClick: () -> Unit) { + val theme = LocalTheme.current + Row( + modifier = Modifier.fillMaxWidth().height(33.dp) + .alpha(if (enabled) 1f else 0.6f) + .clip(ppShape(8.dp)) + .background(Accent) + .clickable(enabled = enabled, onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(iconPath, color = theme.accentTextColor, modifier = Modifier.size(14.dp)) + SocialText(label, color = theme.accentTextColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun FieldGroup(label: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SocialText(label, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + content() + } +} + +private suspend fun readSkinFile(path: Path): Result = + withContext(Dispatchers.IO) { + val size = runCatching { Files.size(path) } + .getOrElse { return@withContext Result.failure(OutfitException(UNREADABLE_SKIN_FILE)) } + if (size > SkinLibrary.MAX_FILE_BYTES) return@withContext Result.failure(OutfitException(SkinLibrary.TOO_LARGE)) + runCatching { Files.readAllBytes(path) }.recoverCatching { throw OutfitException(UNREADABLE_SKIN_FILE) } + } + +private const val UNREADABLE_SKIN_FILE = "Couldn't read that file." + +private fun pickSkinFile(): Path? = + TinyFdApi.getInstance().openFileSelector("Choose a skin", null, arrayOf("*.png"), "PNG images") diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt new file mode 100644 index 00000000..d2486ec2 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeLoading.kt @@ -0,0 +1,125 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.CosmeticLoadProgress +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape + +@Composable +internal fun CosmeticsLoadingScreen( + state: CosmeticLoadProgress.Snapshot, + onRetry: () -> Unit, +) { + val failed = state.failure != null + val outdatedRuntime = state.failure == CosmeticLoadProgress.OUTDATED_RUNTIME + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), + ) { + SocialText( + if (failed) "Couldn't load cosmetics" else "Loading cosmetics", + color = LocalTheme.current.textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + SocialText( + when { + outdatedRuntime -> "PolyPlus needs a newer Fabric Language Kotlin. Update it and restart the game." + failed -> "PolyPlus couldn't reach its servers (${state.failure}). Check your connection and try again." + else -> state.label + }, + color = LocalTheme.current.textColorSecondary, + fontSize = 12.sp, + modifier = Modifier.width(360.dp), + textAlign = TextAlign.Center, + ) + if (failed) { + if (!outdatedRuntime) { + Spacer(Modifier.height(2.dp)) + SmallButton("Retry", iconPath = "refresh", primary = true, onClick = onRetry) + } + } else { + ProgressBar(state.fraction, Modifier.width(360.dp)) + } + } +} + +private const val SWEEP_WIDTH_FRACTION = 0.35f + +@Composable +private fun ProgressBar(fraction: Float?, modifier: Modifier) { + val track = LocalTheme.current.chipBackground + val shape = ppShape(4.dp) + Box( + modifier + .height(8.dp) + .clip(shape) + .background(track) + .border(1.dp, LocalTheme.current.borderColor, shape), + ) { + if (fraction != null) { + val animated by animateFloatAsState(fraction.coerceIn(0f, 1f), animationSpec = tween(220)) + Box( + Modifier + .fillMaxHeight() + .fillMaxWidth(animated) + .clip(shape) + .background(Accent), + ) + } else { + val transition = rememberInfiniteTransition(label = "cosmeticsLoadingSweep") + val head by transition.animateFloat( + initialValue = -SWEEP_WIDTH_FRACTION, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1_100, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "cosmeticsLoadingSweepHead", + ) + BoxWithConstraints(Modifier.fillMaxSize()) { + val width = maxWidth + Box( + Modifier + .offset(x = width * head) + .fillMaxHeight() + .width(width * SWEEP_WIDTH_FRACTION) + .clip(shape) + .background(Accent), + ) + } + } + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt new file mode 100644 index 00000000..8a464a02 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeModels.kt @@ -0,0 +1,192 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import org.polyfrost.polyplus.client.cosmetics.BillingService +import org.polyfrost.polyplus.client.cosmetics.MojangCape +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitInfo +import org.polyfrost.polyplus.client.cosmetics.outfits.PlayerSkinInfo +import org.polyfrost.polyplus.client.network.http.responses.BundleInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +internal data class CartEntry( + val key: String, + val name: String, + val description: String?, + val basePrice: Float?, + val finalPrice: Float?, + val discountRate: Int?, + val storeProductId: String?, + val coverAssetId: Int? = null, +) { + val free: Boolean get() = (finalPrice ?: 0f) <= 0f + + val discounted: Boolean get() = (discountRate ?: 0) > 0 && !free +} + +internal fun CosmeticStoreInfo.toCartEntry(variant: CosmeticVariantUi, storeProductId: String?): CartEntry = + CartEntry( + cosmeticCartKey(variant.id), + cartName(variant), + description, + basePrice, + finalPrice, + discountRate, + storeProductId, + coverAssetId, + ) + +internal fun cosmeticCartKey(variantId: Int): String = "cosmetic-$variantId" + +internal fun CosmeticStoreInfo.cartName(variant: CosmeticVariantUi): String = + if (uiVariants().size > 1 && !variant.name.equals(name, ignoreCase = true)) "$name - ${variant.name}" else name + +internal data class CosmeticVariantUi(val id: Int, val name: String) + +internal fun CosmeticStoreInfo.uiVariants(): List { + val byLabel = LinkedHashMap() + for (variant in variantList.sortedBy { it.variantOrder }) { + val label = variant.variantName ?: name + byLabel.getOrPut(label) { CosmeticVariantUi(variant.id, label) } + } + return byLabel.values.toList() +} + +internal fun CosmeticStoreInfo.fullyOwned(ownedIds: Set): Boolean = + uiVariants().all { it.id in ownedIds } + +internal fun selectedStoreVariant( + info: CosmeticStoreInfo, + picks: Map, + ownedIds: Set, +): CosmeticVariantUi { + val variants = info.uiVariants() + val picked = picks[info.id] + return variants.firstOrNull { it.id == picked } + ?: variants.firstOrNull { it.id !in ownedIds } + ?: variants.first() +} + +internal fun checkoutFailureMessage(error: Throwable): String = + if (error is BillingService.AlreadyOwnedException) { + error.message ?: "You already own this item." + } else { + "Checkout failed: ${error.message}" + } + +internal data class CosmeticUiItem( + val groupId: Int, + val type: CosmeticType, + val name: String, + val collection: String, + val owned: Boolean, + val equipped: Boolean, + val variants: List, + val equippedVariantId: Int?, + val equippedCosmeticId: Int? = equippedVariantId, +) { + val id: Int get() = groupId + val hasVariants: Boolean get() = variants.size > 1 +} + +internal const val MOJANG_CAPE_COLLECTION = "Minecraft cape" + +internal fun selectedVariantId(item: CosmeticUiItem, picks: Map): Int? = + picks[item.groupId]?.takeIf { id -> item.variants.any { it.id == id } } ?: item.equippedVariantId ?: item.variants.firstOrNull()?.id + +internal enum class WardrobeSection(val label: String, val icon: String) { + Featured("Featured", "star-filled"), + Outfits("Outfits", "profiles"), + Skins("Skins", "paintbrush"), + Emotes("Emotes", "lightning-01"), + Cosmetics("Cosmetics", "assets/polyplus/ico/stars.svg"), +} + +internal enum class WardrobeSort(val label: String) { + Popular("Popular"), + Newest("Newest"), + PriceLow("Lowest price"), + PriceHigh("Highest price"), + Name("Name"), +} + +internal sealed interface WardrobeEntry { + val key: String + val fullSpan: Boolean get() = false + + data class Divider(val label: String, val anchor: Boolean) : WardrobeEntry { + override val key: String get() = "divider-$label" + override val fullSpan: Boolean get() = true + } + + data class Group(val id: String, val title: String, val note: String, val forceToggle: Boolean = false) : WardrobeEntry { + override val key: String get() = "group-$id" + override val fullSpan: Boolean get() = true + } + + data class Note(val id: String, val text: String) : WardrobeEntry { + override val key: String get() = "note-$id" + override val fullSpan: Boolean get() = true + } + + data class Banner(val id: String, val text: String, val action: String) : WardrobeEntry { + override val key: String get() = "banner-$id" + override val fullSpan: Boolean get() = true + } + + data class Owned(val item: CosmeticUiItem) : WardrobeEntry { + override val key: String get() = "owned-${item.type.serializedName}-${item.groupId}" + } + + data class Shop(val info: CosmeticStoreInfo, val group: String) : WardrobeEntry { + override val key: String get() = "shop-$group-${info.id}" + } + + data class Mojang(val cape: MojangCape) : WardrobeEntry { + override val key: String get() = "mojang-${cape.id}" + } + + data class Bundle(val bundle: BundleInfo) : WardrobeEntry { + override val key: String get() = "bundle-${bundle.id}" + } + + data class LibraryHeader(val section: WardrobeSection, val note: String, val action: String? = null) : WardrobeEntry { + override val key: String get() = "library-${section.name}" + override val fullSpan: Boolean get() = true + } + + data class Outfit(val outfit: OutfitInfo, val active: Boolean) : WardrobeEntry { + override val key: String get() = "outfit-${outfit.id}" + } + + data class Skin(val skin: PlayerSkinInfo, val worn: Boolean) : WardrobeEntry { + override val key: String get() = "skin-${skin.id}" + } +} + +internal sealed interface WardrobeSelection { + data class Owned(val groupId: Int, val type: CosmeticType) : WardrobeSelection + data class Shop(val infoId: Int) : WardrobeSelection + data class Mojang(val capeId: String) : WardrobeSelection + data class Bundle(val bundleId: Int) : WardrobeSelection + data class Outfit(val outfitId: Int) : WardrobeSelection + data class Skin(val skinId: Int) : WardrobeSelection +} + +internal fun List.favoritesFirst(): List = sortedByDescending { it.favorite } + +internal fun List.favoriteSkinsFirst(): List = sortedByDescending { it.favorite } + +internal val WARDROBE_COSMETIC_TYPES: List = + CosmeticType.entries.filter { it != CosmeticType.Unknown && it != CosmeticType.Emote } + +internal fun CosmeticStoreInfo.anyOwned(ownedIds: Set): Boolean = variantList.any { it.id in ownedIds } + +internal fun List.sortedFor(sort: WardrobeSort): List = when (sort) { + WardrobeSort.Popular -> this + WardrobeSort.Newest -> sortedByDescending { it.createdAt } + WardrobeSort.PriceLow -> sortedBy { it.finalPrice ?: 0f } + WardrobeSort.PriceHigh -> sortedByDescending { it.finalPrice ?: 0f } + WardrobeSort.Name -> sortedBy { it.name.lowercase() } +} + +internal val BACK_VIEW_TYPES = setOf(CosmeticType.Cape, CosmeticType.Wings, CosmeticType.Backpack) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt new file mode 100644 index 00000000..bd3f6de8 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobePreview.kt @@ -0,0 +1,277 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.cosmetics.CosmeticSwatches +import org.polyfrost.polyplus.client.cosmetics.SwatchArt +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.gui.preview.PlayerPreview +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.gui.preview.PreviewRotation +import org.polyfrost.polyplus.client.network.http.responses.BodySlot + +@Composable +internal fun PreviewPanel( + selected: CosmeticUiItem?, + status: String?, + modifier: Modifier, + heading: Pair? = null, + selectedVariantId: Int? = null, + onSelectVariant: (Int) -> Unit = {}, + rotation: PreviewRotation? = null, + showAuraColor: Boolean = false, + auraColor: Int? = null, + onPreviewAuraColor: (Int) -> Unit = {}, + onSelectAuraColor: (Int?) -> Unit = {}, +) { + Box( + modifier = modifier.clip(ppShape(12.dp)) + .background(cardBrush()) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), + ) { + val hasHeadCosmetic = CosmeticCatalog.localEquipped().equipped + .containsKey(BodySlot.Hat) + PlayerPreview( + Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), + source = PlayerPreviewSource.LocalLive, + autoSpin = false, + verticalAnchor = if (hasHeadCosmetic) 0.64f else 0.5f, + initialYaw = FRONT_YAW_DEG, + live = true, + rotation = rotation, + ) + Column(Modifier.align(Alignment.TopStart).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val (title, subtitle) = heading ?: selected?.let { it.name to it.collection } ?: (null to null) + if (title != null) { + Column { + SocialText(title, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + subtitle?.let { SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) } + } + } + if (status != null) { + SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + } + val variants = if (selected != null && selected.hasVariants) selected.variants else emptyList() + if (variants.isNotEmpty() || showAuraColor) { + Column( + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (variants.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth() + .clip(ppShape(12.dp)) + .background(LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SocialText("Variants", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + VariantChips(variants, selectedVariantId, onSelectVariant) + } + } + if (showAuraColor) { + PreviewPill( + auraColor = auraColor, + onPreviewAuraColor = onPreviewAuraColor, + onSelectAuraColor = onSelectAuraColor, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } + } + } +} + +@Composable +private fun PreviewPill( + auraColor: Int?, + onPreviewAuraColor: (Int) -> Unit, + onSelectAuraColor: (Int?) -> Unit, + modifier: Modifier = Modifier, +) { + var customOpen by remember { mutableStateOf(false) } + + var pillHeight by remember { mutableIntStateOf(0) } + val popoverGapPx = with(LocalDensity.current) { 8.dp.roundToPx() } + + Box(modifier) { + Row( + modifier = Modifier + .onSizeChanged { pillHeight = it.height } + .clip(ppShape(PILL_RADIUS)) + .background(LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(PILL_RADIUS)) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AuraSwatchRow( + selectedColor = auraColor, + customOpen = customOpen, + onToggleCustom = { customOpen = !customOpen }, + onCommit = onSelectAuraColor, + ) + } + if (customOpen) { + Popup( + alignment = Alignment.TopEnd, + offset = IntOffset(0, pillHeight + popoverGapPx), + onDismissRequest = { customOpen = false }, + properties = PopupProperties(focusable = true), + ) { + AuraCustomPopover( + selectedColor = auraColor, + onPreview = onPreviewAuraColor, + onCommit = onSelectAuraColor, + ) + } + } + } +} + +@Composable +internal fun VariantPicker( + variants: List, + selectedVariantId: Int?, + onSelect: (Int) -> Unit, + ownedVariantIds: Set = emptySet(), +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + SocialText("Variant", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + VariantChips(variants, selectedVariantId, onSelect, ownedVariantIds) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun VariantChips( + variants: List, + selectedVariantId: Int?, + onSelect: (Int) -> Unit, + ownedVariantIds: Set = emptySet(), +) { + FlowRow( + modifier = Modifier.fillMaxWidth().heightIn(max = MAX_CHIP_HEIGHT).verticalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + for (variant in variants) { + VariantChip( + variant = variant, + selected = variant.id == selectedVariantId, + dimmed = variant.id in ownedVariantIds && variant.id != selectedVariantId, + onClick = { onSelect(variant.id) }, + ) + } + } +} + +@Composable +private fun VariantChip(variant: CosmeticVariantUi, selected: Boolean, dimmed: Boolean, onClick: () -> Unit) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val swatchVersion = CosmeticSwatches.version + val art = remember(variant.id, swatchVersion) { CosmeticSwatches.get(variant.id) } + Row( + modifier = Modifier.height(28.dp) + .alpha(if (dimmed) 0.55f else 1f) + .clip(ppShape(6.dp)) + .background(if (selected) Accent else theme.componentBackground) + .border( + 1.dp, + when { + selected -> Accent + hovered -> theme.textColorSecondary + else -> theme.borderColor + }, + ppShape(6.dp), + ) + .hoverable(interaction) + .clickable(onClick = onClick) + .padding(start = 4.dp, end = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(width = 13.dp, height = 20.dp).clip(ppShape(4.dp)).background(theme.chipBackground)) { + when (art) { + is SwatchArt.Picture -> Image( + bitmap = art.bitmap, + contentDescription = null, + contentScale = ContentScale.FillBounds, + filterQuality = FilterQuality.None, + modifier = Modifier.fillMaxSize(), + ) + is SwatchArt.Stripes -> Column(Modifier.fillMaxSize()) { + for (color in art.colors) { + Box(Modifier.fillMaxWidth().weight(1f).background(color)) + } + } + null -> Unit + } + } + SocialText( + variant.name, + color = if (selected) theme.accentTextColor else theme.textColor, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = MAX_CHIP_LABEL_WIDTH), + ) + } +} + +private val MAX_CHIP_HEIGHT = 96.dp + +private val MAX_CHIP_LABEL_WIDTH = 120.dp + +private val PILL_RADIUS = 18.dp diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt new file mode 100644 index 00000000..7c7ec644 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeShell.kt @@ -0,0 +1,409 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.polyfrost.oneconfig.internal.ui.shell.ShellState +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.cosmetics.MojangCapeService +import org.polyfrost.polyplus.client.cosmetics.MojangCapeState +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary +import org.polyfrost.polyplus.client.emotes.EmoteWheelKeybind +import org.polyfrost.polyplus.client.emotes.EmoteWheels +import org.polyfrost.polyplus.client.gui.preview.PreviewRotation +import org.polyfrost.polyplus.client.network.http.responses.BundleInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +@Composable +internal fun WardrobeShell( + items: List, + ownedIds: Set, + cart: List, + status: String?, + variantPicks: Map, + auraColor: Int?, + historyReloadKey: Int, + onStatus: (String) -> Unit, + onRefresh: () -> Unit, + onPreviewAuraColor: (Int) -> Unit, + onSelectAuraColor: (Int?) -> Unit, + onSelectVariant: (Int, Int) -> Unit, + onEquip: (CosmeticUiItem) -> Unit, + onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + onBuyBundle: (BundleInfo) -> Unit, + onRemoveFromCart: (String) -> Unit, + onCheckout: () -> Unit, + polyPlusAvailable: Boolean = true, +) { + val store = remember { WardrobeStoreData() } + var storeReloadKey by remember { mutableIntStateOf(0) } + LaunchedEffect(storeReloadKey) { + if (polyPlusAvailable) { + store.load() + } else { + store.loading = false + store.featuredLoading = false + } + } + LaunchedEffect(Unit) { MojangCapeService.refreshIfNeeded() } + + val sections = if (polyPlusAvailable) WardrobeSection.entries else listOf(WardrobeSection.Cosmetics) + var section by remember { mutableStateOf(sections.first()) } + val query = ShellState.searchQuery + var sort by remember { mutableStateOf(WardrobeSort.Popular) } + var selection by remember { mutableStateOf(null) } + var showCart by remember { mutableStateOf(false) } + var showHistory by remember { mutableStateOf(false) } + var addingSkin by remember { mutableStateOf(false) } + var wheelMode by remember { mutableStateOf(false) } + var wheelSlot by remember { mutableStateOf(null) } + var returnPoint by remember { mutableStateOf(null) } + var pendingScroll by remember { mutableStateOf?>(null) } + val storeVariantPicks = remember { mutableStateMapOf() } + val gridState = rememberLazyGridState() + val scope = rememberCoroutineScope() + val mojangState = MojangCapeService.state + + val emoteKeybindWarning = remember(section) { + when { + section != WardrobeSection.Emotes || !polyPlusAvailable -> null + !EmoteWheelKeybind.isBound() -> "The emote wheel has no keybind yet, so it can't be opened in game." + else -> EmoteWheelKeybind.conflictingKeybindNames().takeIf { it.isNotEmpty() } + ?.let { "The emote wheel keybind is also used by ${it.joinToString()}." } + } + } + LaunchedEffect(section) { + if (!polyPlusAvailable) return@LaunchedEffect + when (section) { + WardrobeSection.Outfits -> OutfitLibrary.refreshIfNeeded(reload = true) + WardrobeSection.Skins -> { + SkinLibrary.refreshIfNeeded() + OutfitLibrary.refreshIfNeeded() + } + else -> Unit + } + } + val outfitKey = listOf(OutfitLibrary.outfits, OutfitLibrary.activeOutfitId, OutfitLibrary.state, OutfitLibrary.limit) + val skinKey = listOf(SkinLibrary.skins, SkinLibrary.state, SkinLibrary.wornSkinId(), SkinLibrary.limit) + val entries = remember(section, items, ownedIds, store.byType, store.popular, store.newest, store.bundles, store.loading, store.featuredLoading, store.failedTypes, store.featuredFailed, mojangState, query, sort, emoteKeybindWarning, outfitKey, skinKey) { + buildWardrobeEntries(section, items, ownedIds, store, mojangState, query, sort, polyPlusAvailable, emoteKeybindWarning) + } + val coverByVariant = remember(store.byType, store.popular, store.newest) { + val covers = HashMap() + for (info in store.all) { + val cover = info.coverAssetId ?: continue + for (variant in info.variantList) covers.putIfAbsent(variant.id, cover) + } + covers + } + val anchors = remember(entries) { + entries.mapIndexedNotNull { index, entry -> + (entry as? WardrobeEntry.Divider)?.takeIf { it.anchor }?.let { it.label to index } + } + } + val activeAnchor by remember(anchors) { + derivedStateOf { anchors.lastOrNull { it.second <= gridState.firstVisibleItemIndex }?.second ?: anchors.firstOrNull()?.second } + } + LaunchedEffect(section) { + val target = pendingScroll + pendingScroll = null + if (target != null) gridState.scrollToItem(target.first, target.second) else gridState.scrollToItem(0) + } + + val selectedOwned = (selection as? WardrobeSelection.Owned)?.let { sel -> items.firstOrNull { it.groupId == sel.groupId && it.type == sel.type } } + val selectedShop = (selection as? WardrobeSelection.Shop)?.let { sel -> store.all.firstOrNull { it.id == sel.infoId } } + val selectedMojang = (selection as? WardrobeSelection.Mojang)?.let { sel -> + (mojangState as? MojangCapeState.Loaded)?.capes?.firstOrNull { it.id == sel.capeId } + } + val selectedBundle = (selection as? WardrobeSelection.Bundle)?.let { sel -> store.bundles.firstOrNull { it.id == sel.bundleId } } + val selectedOutfit = (selection as? WardrobeSelection.Outfit)?.let { sel -> OutfitLibrary.outfits.firstOrNull { it.id == sel.outfitId } } + val selectedSkin = (selection as? WardrobeSelection.Skin)?.let { sel -> SkinLibrary.skins.firstOrNull { it.id == sel.skinId } } + val previewHeading = selectedMojang?.let { it.name to MOJANG_CAPE_COLLECTION } + ?: selectedOutfit?.let { it.name to "Outfit" } + ?: selectedSkin?.let { it.name to "${it.model.label} skin" } + val previewRotation = remember { PreviewRotation(FRONT_YAW_DEG) } + val showBack = selectedMojang != null || + selectedOwned?.type?.let { it in BACK_VIEW_TYPES } == true || + selectedShop?.type?.let { it in BACK_VIEW_TYPES } == true || + (selection == null && section == WardrobeSection.Cosmetics) + LaunchedEffect(showBack) { + if (previewRotation.userRotated) return@LaunchedEffect + val target = if (showBack) BACK_YAW_DEG else FRONT_YAW_DEG + val delta = ((target - previewRotation.yaw) % 360f + 540f) % 360f - 180f + animate(previewRotation.yaw, previewRotation.yaw + delta, animationSpec = tween(300)) { value, _ -> + if (!previewRotation.userRotated) previewRotation.yaw = value + } + } + + Row(modifier = Modifier.fillMaxSize().padding(horizontal = 25.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + WardrobeHeader( + sections = sections, + selected = section, + anchors = anchors, + activeAnchor = activeAnchor, + onSelectSection = { + section = it + showHistory = false + addingSkin = false + }, + onAnchor = { index -> scope.launch { gridState.scrollToItem(index) } }, + sort = sort, + cartSize = cart.size, + showingHistory = showHistory, + storeEnabled = polyPlusAvailable, + onCycleSort = { sort = WardrobeSort.entries[(sort.ordinal + 1) % WardrobeSort.entries.size] }, + onToggleHistory = { showHistory = !showHistory }, + onRefresh = { + onRefresh() + storeReloadKey++ + if (polyPlusAvailable && section == WardrobeSection.Outfits) { + scope.launch { OutfitLibrary.refresh() } + } + if (polyPlusAvailable && section == WardrobeSection.Skins) { + scope.launch { + SkinLibrary.refresh() + OutfitLibrary.refresh() + } + } + }, + onCart = { showCart = !showCart }, + ) + if (showHistory) { + HistoryScreen(ownedIds = ownedIds, reloadKey = historyReloadKey) + } else { + WardrobeGrid( + entries = entries, + state = gridState, + ownedIds = ownedIds, + cartKeys = cart.map { it.key }.toSet(), + selection = selection, + variantPicks = variantPicks, + storeVariantPicks = storeVariantPicks, + mojangBusy = MojangCapeService.busy, + modifier = Modifier.weight(1f).fillMaxWidth(), + coverFor = { item -> item.variants.firstNotNullOfOrNull { coverByVariant[it.id] } }, + onOwned = { item -> + if (wheelMode && item.type == CosmeticType.Emote) { + val wheel = EmoteWheels.activeIndex + val target = wheelSlot + val placed = if (target != null) { + EmoteWheels.assign(wheel, target, item.groupId) + target + } else { + EmoteWheels.addToFirstEmpty(wheel, item.groupId) + } + wheelSlot = null + onStatus( + if (placed == null) { + "Wheel ${wheel + 1} is full. Pick a slot to replace." + } else { + "Added ${item.name} to wheel ${wheel + 1}." + }, + ) + } else { + selection = WardrobeSelection.Owned(item.groupId, item.type) + showCart = false + onEquip(item) + } + }, + onPreviewOwned = { item -> + selection = WardrobeSelection.Owned(item.groupId, item.type) + showCart = false + }, + onSelectVariant = onSelectVariant, + onShop = { info -> + val ownedItem = items.firstOrNull { item -> item.owned && info.variantList.any { v -> item.variants.any { it.id == v.id } } } + ?.takeIf { info.fullyOwned(ownedIds) } + selection = if (ownedItem != null) WardrobeSelection.Owned(ownedItem.groupId, ownedItem.type) else WardrobeSelection.Shop(info.id) + showCart = false + }, + onPickStoreVariant = { infoId, variantId -> storeVariantPicks[infoId] = variantId }, + onAddToCart = onAddToCart, + onBuyNow = onBuyNow, + onMojang = { cape -> + selection = WardrobeSelection.Mojang(cape.id) + showCart = false + toggleMojangCape(cape, onStatus) + }, + onBundle = { bundle -> + selection = WardrobeSelection.Bundle(bundle.id) + showCart = false + }, + onBuyBundle = onBuyBundle, + onLibraryAction = { target -> + if (target == WardrobeSection.Skins) { + addingSkin = true + showCart = false + } + }, + onSelectOutfit = { outfit -> + selection = WardrobeSelection.Outfit(outfit.id) + showCart = false + }, + onSelectSkin = { skin -> + selection = WardrobeSelection.Skin(skin.id) + showCart = false + }, + onStatus = onStatus, + ) + } + } + + Column(modifier = Modifier.width(320.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (polyPlusAvailable) { + PreviewModeToggle( + wheelMode = wheelMode, + onChange = { wheel -> + if (wheel == wheelMode) return@PreviewModeToggle + wheelMode = wheel + wheelSlot = null + if (wheel) { + returnPoint = WardrobeReturnPoint( + section = section, + showHistory = showHistory, + scrollIndex = gridState.firstVisibleItemIndex, + scrollOffset = gridState.firstVisibleItemScrollOffset, + ) + section = WardrobeSection.Emotes + showHistory = false + showCart = false + addingSkin = false + } else { + val back = returnPoint + returnPoint = null + if (back != null && section == WardrobeSection.Emotes) { + showHistory = back.showHistory + if (back.section == section) { + scope.launch { gridState.scrollToItem(back.scrollIndex, back.scrollOffset) } + } else { + pendingScroll = back.scrollIndex to back.scrollOffset + section = back.section + } + } + } + }, + ) + if (!wheelMode) { + OutfitSwitcher(onStatus = onStatus) + } + } + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + when { + showCart -> WardrobeCartPane( + cart = cart, + status = status, + onClose = { showCart = false }, + onRemove = onRemoveFromCart, + onCheckout = onCheckout, + ) + addingSkin && polyPlusAvailable -> AddSkinPanel( + onClose = { addingSkin = false }, + onStatus = onStatus, + modifier = Modifier.fillMaxSize(), + ) + wheelMode -> EmoteWheelEditor( + selectedSlot = wheelSlot, + coverForEmote = { id -> coverByVariant[id] }, + onSelectSlot = { wheelSlot = it }, + modifier = Modifier.fillMaxSize(), + ) + selectedShop != null -> { + val variant = selectedStoreVariant(selectedShop, storeVariantPicks, ownedIds) + StoreDetailPanel( + info = selectedShop, + variant = variant, + status = status, + ownedIds = ownedIds, + inCart = cosmeticCartKey(variant.id) in cart.map { it.key }, + onSelectVariant = { storeVariantPicks[selectedShop.id] = it }, + onAddToCart = onAddToCart, + onBuyNow = onBuyNow, + modifier = Modifier.fillMaxSize(), + rotation = previewRotation, + ) + } + selectedBundle != null -> BundleDetailPanel( + bundle = selectedBundle, + status = status, + onBuy = { onBuyBundle(selectedBundle) }, + modifier = Modifier.fillMaxSize(), + ) + else -> { + PreviewPanel( + selected = selectedOwned?.takeIf { previewHeading == null }, + heading = previewHeading, + status = status, + selectedVariantId = selectedOwned?.let { selectedVariantId(it, variantPicks) }, + onSelectVariant = { variantId -> selectedOwned?.let { onSelectVariant(it.groupId, variantId) } }, + rotation = previewRotation, + showAuraColor = selectedOwned?.type == CosmeticType.Aura, + auraColor = auraColor, + onPreviewAuraColor = onPreviewAuraColor, + onSelectAuraColor = onSelectAuraColor, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + } +} + +@Composable +internal fun MinecraftCapesOnlyScreen() { + var status by remember { mutableStateOf(null) } + WardrobeShell( + items = emptyList(), + ownedIds = emptySet(), + cart = emptyList(), + status = status, + variantPicks = emptyMap(), + auraColor = null, + historyReloadKey = 0, + onStatus = { status = it }, + onRefresh = { PolyPlusClient.SCOPE.launch { MojangCapeService.refresh() } }, + onPreviewAuraColor = {}, + onSelectAuraColor = {}, + onSelectVariant = { _, _ -> }, + onEquip = {}, + onAddToCart = { _, _ -> }, + onBuyNow = { _, _ -> }, + onBuyBundle = {}, + onRemoveFromCart = {}, + onCheckout = {}, + polyPlusAvailable = false, + ) +} + +private data class WardrobeReturnPoint( + val section: WardrobeSection, + val showHistory: Boolean, + val scrollIndex: Int, + val scrollOffset: Int, +) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt new file mode 100644 index 00000000..3a56f39c --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStoreData.kt @@ -0,0 +1,281 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.polyfrost.polyplus.client.cosmetics.BundleCatalog +import org.polyfrost.polyplus.client.cosmetics.CosmeticStore +import org.polyfrost.polyplus.client.cosmetics.MojangCapeState +import org.polyfrost.polyplus.client.cosmetics.outfits.LibraryState +import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary +import org.polyfrost.polyplus.client.network.http.responses.BundleInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +internal class WardrobeStoreData { + var byType by mutableStateOf>>(emptyMap()) + var popular by mutableStateOf>(emptyList()) + var newest by mutableStateOf>(emptyList()) + var bundles by mutableStateOf>(emptyList()) + var loading by mutableStateOf(true) + var featuredLoading by mutableStateOf(true) + var failedTypes by mutableStateOf>(emptySet()) + var featuredFailed by mutableStateOf(false) + + val all: List get() = byType.values.flatten() + popular + newest + + suspend fun load() { + loading = true + featuredLoading = true + val types = CosmeticType.entries.filter { it != CosmeticType.Unknown } + coroutineScope { + launch { + val popularFetched = async { + CosmeticStore.search(perPage = FEATURED_ROW_SIZE, sort = CosmeticStore.Sort.Popularity).getOrNull() + ?.also { response -> popular = response.results.filter { it.type != CosmeticType.Unknown } } + } + val newestFetched = async { + CosmeticStore.search(perPage = FEATURED_ROW_SIZE, sort = CosmeticStore.Sort.Newest).getOrNull() + ?.also { response -> newest = response.results.filter { it.type != CosmeticType.Unknown } } + } + val bundlesFetched = async { + BundleCatalog.search(perPage = FEATURED_ROW_SIZE).getOrNull() + ?.also { response -> bundles = response.bundles.filter { it.purchasable } } + } + featuredFailed = popularFetched.await() == null && newestFetched.await() == null && bundlesFetched.await() == null + featuredLoading = false + } + for (type in types) { + launch { + val fetched = searchAll(type) + val previous = byType[type] + val results = when { + fetched == null -> previous + fetched.partial && previous != null -> previous + else -> fetched.items + } + if (results != null) byType = byType + (type to results) + failedTypes = if (results == null) failedTypes + type else failedTypes - type + } + } + } + featuredLoading = false + loading = false + } + + private class StoreResults(val items: List, val partial: Boolean) + + private suspend fun searchAll(type: CosmeticType): StoreResults? { + val results = LinkedHashMap() + var page = 1 + do { + val response = CosmeticStore.search( + page = page, + perPage = CosmeticStore.MAX_PAGE_SIZE, + sort = CosmeticStore.Sort.Popularity, + types = listOf(type.serializedName), + reportFailures = false, + ).getOrNull() ?: return if (page == 1) null else StoreResults(results.values.toList(), partial = true) + response.results.forEach { results.putIfAbsent(it.id, it) } + page++ + } while (page <= response.pagination.totalPages && page <= MAX_STORE_PAGES) + return StoreResults(results.values.toList(), partial = false) + } +} + +private const val FEATURED_ROW_SIZE = 6 + +private const val MAX_STORE_PAGES = 10 + +private const val STORE_LOADING = "Loading the store..." + +private const val STORE_FAILED = "Couldn't reach the PolyPlus store." + +internal fun buildWardrobeEntries( + section: WardrobeSection, + items: List, + ownedIds: Set, + store: WardrobeStoreData, + mojangState: MojangCapeState, + query: String, + sort: WardrobeSort, + polyPlusAvailable: Boolean, + emoteKeybindWarning: String? = null, +): List { + val needle = query.trim() + fun matches(name: String) = needle.isEmpty() || name.contains(needle, ignoreCase = true) + fun owned(type: CosmeticType) = items.filter { it.owned && it.type == type && matches(it.name) } + fun shop(type: CosmeticType) = store.byType[type].orEmpty() + .filter { !it.fullyOwned(ownedIds) && matches(it.name) } + .sortedFor(sort) + + val entries = mutableListOf() + when (section) { + WardrobeSection.Featured -> { + val popular = store.popular.filter { matches(it.name) } + val newest = store.newest.filter { matches(it.name) } + val bundles = store.bundles.filter { matches(it.name) } + if (popular.isNotEmpty()) { + entries += WardrobeEntry.Divider("Popular", anchor = true) + popular.mapTo(entries) { WardrobeEntry.Shop(it, "popular") } + } + if (newest.isNotEmpty()) { + entries += WardrobeEntry.Divider("New", anchor = true) + newest.mapTo(entries) { WardrobeEntry.Shop(it, "new") } + } + if (bundles.isNotEmpty()) { + entries += WardrobeEntry.Divider("Bundles", anchor = true) + bundles.mapTo(entries) { WardrobeEntry.Bundle(it) } + } + if (entries.isEmpty()) { + entries += WardrobeEntry.Note( + "featured", + when { + store.featuredLoading -> STORE_LOADING + store.featuredFailed -> STORE_FAILED + else -> "Nothing featured right now." + }, + ) + } + } + + WardrobeSection.Outfits -> { + val library = OutfitLibrary.outfits + entries += WardrobeEntry.LibraryHeader( + WardrobeSection.Outfits, + "${library.size} of ${OutfitLibrary.limit} saved. Save your look and switch between outfits.", + ) + when (val state = OutfitLibrary.state) { + LibraryState.Idle, LibraryState.Loading -> + entries += WardrobeEntry.Note("outfits", "Loading your outfits...") + is LibraryState.Unavailable -> + entries += WardrobeEntry.Note("outfits", state.reason) + LibraryState.Loaded -> { + val activeId = OutfitLibrary.activeOutfitId + val outfits = library.filter { matches(it.name) }.favoritesFirst() + when { + library.isEmpty() -> entries += WardrobeEntry.Note( + "outfits", + "You have no saved outfits yet. Use + above the preview to save your current look.", + ) + outfits.isEmpty() -> entries += WardrobeEntry.Note("outfits", "No outfits match your search.") + else -> outfits.mapTo(entries) { WardrobeEntry.Outfit(it, it.id == activeId) } + } + } + } + } + + WardrobeSection.Skins -> { + val library = SkinLibrary.skins + entries += WardrobeEntry.LibraryHeader( + WardrobeSection.Skins, + "${library.size} of ${SkinLibrary.limit} saved. Every skin you wear is saved here automatically.", + action = "Add skin", + ) + when (val state = SkinLibrary.state) { + LibraryState.Idle, LibraryState.Loading -> + entries += WardrobeEntry.Note("skins", "Loading your skins...") + is LibraryState.Unavailable -> + entries += WardrobeEntry.Note("skins", state.reason) + LibraryState.Loaded -> { + val wornId = SkinLibrary.wornSkinId() + val skins = library.filter { matches(it.name) }.favoriteSkinsFirst() + when { + library.isEmpty() -> entries += WardrobeEntry.Note( + "skins", + "You have no saved skins yet. Use Add skin to save one.", + ) + skins.isEmpty() -> entries += WardrobeEntry.Note("skins", "No skins match your search.") + else -> skins.mapTo(entries) { WardrobeEntry.Skin(it, it.id == wornId) } + } + } + } + } + + WardrobeSection.Emotes -> { + if (emoteKeybindWarning != null) { + entries += WardrobeEntry.Banner("emote-keybind", emoteKeybindWarning, "View keybinds") + } + val ownedEmotes = owned(CosmeticType.Emote) + if (ownedEmotes.isNotEmpty()) { + entries += WardrobeEntry.Divider("Your emotes", anchor = true) + ownedEmotes.mapTo(entries) { WardrobeEntry.Owned(it) } + } + shop(CosmeticType.Emote) + .groupBy { info -> info.tags.category.firstOrNull()?.replaceFirstChar { it.uppercase() } ?: "More emotes" } + .forEach { (category, infos) -> + entries += WardrobeEntry.Divider(category, anchor = true) + infos.mapTo(entries) { WardrobeEntry.Shop(it, "emote") } + } + if (entries.all { it is WardrobeEntry.Banner }) { + entries += WardrobeEntry.Note( + "emotes", + when { + CosmeticType.Emote in store.failedTypes -> STORE_FAILED + store.loading -> "Loading emotes..." + else -> "No emotes found." + }, + ) + } + } + + WardrobeSection.Cosmetics -> for (type in WARDROBE_COSMETIC_TYPES) { + val ownedItems = owned(type) + val shopItems = shop(type) + if (type == CosmeticType.Cape) { + entries += WardrobeEntry.Divider(type.displayName, anchor = true) + entries += WardrobeEntry.Group( + "minecraft", + "Minecraft capes", + "Everyone sees this cape, including players without PolyPlus.", + ) + when (mojangState) { + MojangCapeState.Idle, MojangCapeState.Loading -> + entries += WardrobeEntry.Note("minecraft", "Loading your Minecraft capes...") + is MojangCapeState.Unavailable -> + entries += WardrobeEntry.Note("minecraft", mojangState.reason) + is MojangCapeState.Loaded -> { + val capes = mojangState.capes.filter { matches(it.name) } + if (capes.isEmpty()) { + entries += WardrobeEntry.Note("minecraft", "You don't own any Minecraft capes.") + } else { + capes.mapTo(entries) { WardrobeEntry.Mojang(it) } + } + } + } + entries += WardrobeEntry.Group( + "polyplus", + "PolyPlus capes", + "Players with PolyPlus see this cape instead of your Minecraft cape.", + forceToggle = polyPlusAvailable, + ) + if (!polyPlusAvailable) { + entries += WardrobeEntry.Note( + "polyplus", + "PolyPlus capes need the Terms of Service and Privacy Policy. Accept them under Privacy in the PolyPlus settings.", + ) + } else if (ownedItems.isEmpty() && shopItems.isEmpty()) { + entries += WardrobeEntry.Note( + "polyplus", + when { + CosmeticType.Cape in store.failedTypes -> STORE_FAILED + store.loading -> STORE_LOADING + else -> "No PolyPlus capes found." + }, + ) + } + } else if (ownedItems.isEmpty() && shopItems.isEmpty()) { + continue + } else { + entries += WardrobeEntry.Divider(type.displayName, anchor = true) + } + ownedItems.mapTo(entries) { WardrobeEntry.Owned(it) } + shopItems.mapTo(entries) { WardrobeEntry.Shop(it, type.serializedName) } + } + } + return entries +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt new file mode 100644 index 00000000..ba28dc62 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeStorePanels.kt @@ -0,0 +1,300 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.Locale +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.gui.preview.PlayerPreview +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.gui.preview.PreviewRotation +import org.polyfrost.polyplus.client.network.http.responses.BundleInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticStoreInfo +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +@Composable +internal fun PriceLabel(basePrice: Float?, finalPrice: Float?, discounted: Boolean) { + when { + finalPrice == null -> SocialText("—", color = LocalTheme.current.textColorSecondary, fontSize = 14.sp) + finalPrice <= 0f -> SocialText("FREE", color = Color(0xFF239A60), fontSize = 14.sp, fontWeight = FontWeight.Medium) + discounted && basePrice != null -> { + SocialText(money(basePrice), color = Color(0xFFFF4444), fontSize = 10.sp, textDecoration = TextDecoration.LineThrough) + Spacer(Modifier.width(4.dp)) + SocialText(money(finalPrice), color = Color(0xFF239A60), fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + else -> SocialText(money(finalPrice), color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +internal fun PriceLabel(info: CosmeticStoreInfo) = PriceLabel(info.basePrice, info.finalPrice, info.discounted) + +@Composable +internal fun PriceLabel(entry: CartEntry) = PriceLabel(entry.basePrice, entry.finalPrice, entry.discounted) + +@Composable +private fun CartPanel( + items: List, + status: String?, + modifier: Modifier, + onRemove: (String) -> Unit, + onCheckout: () -> Unit, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(11.dp)) { + if (items.isEmpty()) { + CenteredNote("Your cart is empty.") + return@Column + } + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(11.dp), + ) { + items(items, key = { it.key }) { item -> + CartRow(item = item, onRemove = { onRemove(item.key) }) + } + } + + val subtotal = items.sumOf { (it.basePrice ?: it.finalPrice ?: 0f).toDouble() }.toFloat() + val total = items.sumOf { (it.finalPrice ?: it.basePrice ?: 0f).toDouble() }.toFloat() + val discount = (subtotal - total).coerceAtLeast(0f) + RowTotals("Subtotal", money(subtotal), LocalTheme.current.textColor) + if (discount > 0f) { + RowTotals("Discounts", "-${money(discount)}", Color(0xFF239A60)) + } + RowTotals("Total", if (total <= 0f) "FREE" else money(total), Color.White, large = true) + if (status != null) { + SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + SmallButton( + label = "Checkout ${items.size} item${if (items.size == 1) "" else "s"}", + iconPath = "assets/polyplus/ico/shopping-cart/0.svg", + primary = true, + onClick = onCheckout, + ) + } +} + +@Composable +private fun CartRow(item: CartEntry, onRemove: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().height(81.dp) + .clip(ppShape(12.dp)) + .background(cardBrush()) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)) + .padding(horizontal = 13.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { + CoverThumbnail(item.coverAssetId, Modifier.size(58.dp)) + Column(Modifier.weight(1f)) { + SocialText(item.name, color = LocalTheme.current.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) + item.description?.takeIf { it.isNotBlank() }?.let { + SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + } + Column(horizontalAlignment = Alignment.End) { + Row(verticalAlignment = Alignment.CenterVertically) { PriceLabel(item) } + if (item.discounted) SocialText("SAVE ${item.discountRate}%", color = Color(0xFF239A60), fontSize = 12.sp) + SocialText("Remove", color = Color(0xFFFF4444), fontSize = 12.sp, modifier = Modifier.clickable(onClick = onRemove)) + } + } +} + +@Composable +private fun RowTotals(label: String, value: String, color: Color, large: Boolean = false) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + SocialText(label, color = LocalTheme.current.textColor, fontSize = if (large) 18.sp else 12.sp, modifier = Modifier.weight(1f)) + SocialText(value, color = color, fontSize = if (large) 22.sp else 12.sp, fontWeight = if (large) FontWeight.Medium else FontWeight.Normal) + } +} + +@Composable +internal fun StoreDetailPanel( + info: CosmeticStoreInfo?, + variant: CosmeticVariantUi?, + status: String?, + ownedIds: Set, + inCart: Boolean, + onSelectVariant: (Int) -> Unit, + onAddToCart: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + onBuyNow: (CosmeticStoreInfo, CosmeticVariantUi) -> Unit, + modifier: Modifier, + rotation: PreviewRotation? = null, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(14.dp)) { + Box( + modifier = Modifier.fillMaxWidth().height(330.dp) + .clip(ppShape(12.dp)) + .background(cardBrush()) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), + ) { + if (info == null || variant == null) { + CenteredNote("Select a cosmetic to preview it.") + } else { + val (source, _) = rememberCosmeticPreviewSource(variant.id, info.type) + val fades = info.type != CosmeticType.Boots + PlayerPreview( + Modifier.align(Alignment.Center).fillMaxWidth().height(330.dp), + source = source ?: PlayerPreviewSource.LocalLive, + autoSpin = false, + bottomFadeFraction = if (fades) STORE_PREVIEW_FADE_FRACTION else 0f, + verticalAnchor = when (info.type) { + CosmeticType.Hat -> 0.70f + CosmeticType.Boots -> 0.44f + else -> 0.56f + }, + initialYaw = FRONT_YAW_DEG, + previewKey = "store-detail-${variant.id}", + live = true, + rotation = rotation, + ) + OwnershipBadge(variant.id in ownedIds, info.createdAt, Modifier.padding(12.dp)) + } + } + + Box( + modifier = Modifier.weight(1f).fillMaxWidth() + .clip(ppShape(12.dp)) + .background(cardBrush()) + .border(1.dp, LocalTheme.current.borderColor, ppShape(12.dp)), + ) { + if (info == null || variant == null) { + CenteredNote("Browse the store to buy individual cosmetics.") + } else { + val variants = info.uiVariants() + Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column( + modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + SocialText(info.type.displayName, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + SocialText(info.name, color = LocalTheme.current.textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium) + Row(verticalAlignment = Alignment.CenterVertically) { PriceLabel(info) } + if (variants.size > 1) { + VariantPicker( + variants = variants, + selectedVariantId = variant.id, + onSelect = onSelectVariant, + ownedVariantIds = ownedIds, + ) + } + if (info.tags.all.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + for (tag in info.tags.all.take(4)) { + Box( + modifier = Modifier.clip(ppShape(5.dp)) + .background(LocalTheme.current.chipBackground) + .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + SocialText(tag.replaceFirstChar { it.uppercase() }, color = LocalTheme.current.textColor, fontSize = 11.sp) + } + } + } + } + info.description?.takeIf { it.isNotBlank() }?.let { + SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + } + if (status != null) { + SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + if (variant.id in ownedIds) { + SocialText( + "You already own this cosmetic.", + color = LocalTheme.current.textColorSecondary, + fontSize = 12.sp, + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SmallButton( + label = if (inCart) "In cart" else "Add to cart", + iconPath = "assets/polyplus/ico/shopping-cart/0.svg", + primary = false, + onClick = { if (!inCart) onAddToCart(info, variant) }, + ) + SmallButton( + label = "Buy now", + iconPath = "assets/polyplus/ico/shopping-bag.svg", + primary = true, + onClick = { onBuyNow(info, variant) }, + ) + } + } + } + } + } + } +} + +internal fun money(amount: Float, currency: String? = null): String { + val formatted = String.format(Locale.ROOT, "%.2f", amount) + return if (currency == null || currency.equals("usd", ignoreCase = true)) "$$formatted" else "$formatted ${currency.uppercase()}" +} + +@Composable +internal fun WardrobeCartPane( + cart: List, + status: String?, + onClose: () -> Unit, + onRemove: (String) -> Unit, + onCheckout: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize().wardrobePanel().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SocialText("Cart", color = LocalTheme.current.textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + SmallButton("Close", iconPath = "close", primary = false, onClick = onClose) + } + CartPanel(items = cart, status = status, modifier = Modifier.weight(1f).fillMaxWidth(), onRemove = onRemove, onCheckout = onCheckout) + } +} + +@Composable +internal fun BundleDetailPanel(bundle: BundleInfo, status: String?, onBuy: () -> Unit, modifier: Modifier) { + Column( + modifier = modifier.wardrobePanel().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + CoverThumbnail(bundle.assetId, Modifier.fillMaxWidth().height(200.dp)) + SocialText("Bundle", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + SocialText(bundle.name, color = LocalTheme.current.textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium) + Row(verticalAlignment = Alignment.CenterVertically) { PriceLabel(bundle.basePrice, bundle.finalPrice, bundle.discounted) } + bundle.description?.takeIf { it.isNotBlank() }?.let { + SocialText(it, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + Spacer(Modifier.weight(1f)) + if (status != null) { + SocialText(status, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } + SmallButton("Buy now", iconPath = "assets/polyplus/ico/shopping-bag.svg", primary = true, onClick = onBuy) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt new file mode 100644 index 00000000..3f116be4 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeThumbnails.kt @@ -0,0 +1,195 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.time.Duration +import java.time.Instant +import java.time.OffsetDateTime +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.gui.preview.PlayerPreview +import org.polyfrost.polyplus.client.gui.preview.PlayerPreviewSource +import org.polyfrost.polyplus.client.gui.rememberCoverImage +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType +import org.polyfrost.polyplus.client.utils.ClientPlatform + +@Composable +internal fun CosmeticThumbnail( + source: PlayerPreviewSource?, + type: CosmeticType, + previewKey: String, + modifier: Modifier, + checker: Boolean = true, + coverAssetId: Int? = null, +) { + val framing = cosmeticPreviewFraming(type) + Box(modifier) { + if (checker) CheckerThumbnail(Modifier.fillMaxSize()) + if (source == null && coverAssetId != null) { + CoverThumbnail(coverAssetId, Modifier.fillMaxSize()) + } + if (source != null) { + PlayerPreview( + Modifier.fillMaxSize(), + source = source, + autoSpin = false, + allowDrag = false, + modelScale = framing.modelScale, + verticalAnchor = framing.verticalAnchor, + initialYaw = framing.yawDeg, + previewKey = previewKey, + live = needsLivePreview(type, source), + ) + } + } +} + +private fun needsLivePreview(type: CosmeticType, source: PlayerPreviewSource) = when (type) { + CosmeticType.Aura -> true + CosmeticType.Cape -> + (source as? PlayerPreviewSource.Override)?.capeCosmeticId?.let(CosmeticAssetCache::isCapeAnimated) == true + else -> false +} + +private data class PreviewFraming(val yawDeg: Float, val modelScale: Float, val verticalAnchor: Float) + +internal const val FRONT_YAW_DEG = 180f + 22.9f +internal const val BACK_YAW_DEG = 22.9f +internal const val STORE_PREVIEW_FADE_FRACTION = 0.22f + +private fun cosmeticPreviewFraming(type: CosmeticType): PreviewFraming = when (type) { + CosmeticType.Hat, CosmeticType.Glasses -> PreviewFraming(FRONT_YAW_DEG, 1.0f, 1.5f) + CosmeticType.Boots -> PreviewFraming(FRONT_YAW_DEG, 0.85f, 0.26f) + CosmeticType.Shoulder -> PreviewFraming(FRONT_YAW_DEG, 0.58f, 0.62f) + CosmeticType.Wings -> PreviewFraming(BACK_YAW_DEG, 0.5f, 0.5f) + CosmeticType.Backpack -> PreviewFraming(BACK_YAW_DEG, 0.55f, 0.55f) + CosmeticType.Cape -> PreviewFraming(BACK_YAW_DEG, 0.68f, 0.5f) + CosmeticType.Aura -> PreviewFraming(FRONT_YAW_DEG, 0.5f, 0.5f) + CosmeticType.Glove -> PreviewFraming(FRONT_YAW_DEG, 0.7f, 0.42f) + else -> PreviewFraming(FRONT_YAW_DEG, 0.42f, 0.52f) +} + +@Composable +internal fun rememberCosmeticPreviewSource(cosmeticId: Int, type: CosmeticType): Pair { + val isCape = type == CosmeticType.Cape + val isPet = type == CosmeticType.Pet + + var previewId by remember(cosmeticId) { mutableIntStateOf(cosmeticId) } + val loaded = CosmeticAssetCache.installs.let { isPreviewAssetLoaded(previewId, isCape, isPet) } + // a trim can evict a preview's assets while the screen is closed, so it reloads them when shown again + LaunchedEffect(cosmeticId, loaded) { + val slim = ClientPlatform.runOnMainSync { ClientPlatform.localSkinSlim() } + val id = CosmeticCatalog.resolveVariantForSkin(cosmeticId, slim) + previewId = id + if (!isPreviewAssetLoaded(id, isCape, isPet)) CosmeticAssetCache.ensureCosmeticLoaded(id) + } + + val loadTick = (if (loaded) 1 else 0) + (if (isCape && CosmeticAssetCache.isCapeAnimated(previewId)) 2 else 0) + + val source = remember(previewId, loadTick, isCape, isPet) { + when { + isCape -> PlayerPreviewSource.Override(CosmeticEquipment(), capeCosmeticId = previewId) + isPet -> { + val attached = CosmeticAssetCache.getAttachedCosmetic(previewId) + if (attached != null) { + val equipment = CosmeticEquipment() + equipment.equip(attached) + PlayerPreviewSource.Override(equipment) + } else { + val pet = CosmeticAssetCache.getPetDefinition(previewId) ?: return@remember null + PlayerPreviewSource.Override(CosmeticEquipment(), pet = pet) + } + } + else -> { + val attached = CosmeticAssetCache.getAttachedCosmetic(previewId) ?: return@remember null + val equipment = CosmeticEquipment() + equipment.equip(attached) + PlayerPreviewSource.Override(equipment) + } + } + } + + return source to loadTick +} + +@Composable +internal fun BoxScope.OwnershipBadge(owned: Boolean, createdAt: String, modifier: Modifier) { + val label = when { + owned -> "OWNED" + isNewItem(createdAt) -> "NEW" + else -> return + } + Box( + modifier = modifier.align(Alignment.TopStart) + .clip(ppShape(5.dp)) + .background(if (owned) Color(0xFF239A60) else Accent) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) { + SocialText(label, color = LocalTheme.current.accentTextColor, fontSize = 10.sp, fontWeight = FontWeight.Medium) + } +} + +private fun isPreviewAssetLoaded(id: Int, isCape: Boolean, isPet: Boolean): Boolean = when { + isCape -> CosmeticAssetCache.isCapeLoaded(id) + isPet -> CosmeticAssetCache.getAttachedCosmetic(id) != null || CosmeticAssetCache.getPetDefinition(id) != null + else -> CosmeticAssetCache.getAttachedCosmetic(id) != null +} + +internal fun isNewItem(createdAt: String): Boolean { + if (createdAt.isBlank()) return false + return runCatching { + val created = OffsetDateTime.parse(createdAt).toInstant() + created.isAfter(Instant.now().minus(Duration.ofDays(7))) + }.getOrDefault(false) +} + +@Composable +internal fun CoverThumbnail(coverAssetId: Int?, modifier: Modifier) { + val cover = rememberCoverImage(coverAssetId) + Box(modifier) { + CheckerThumbnail(Modifier.fillMaxSize()) + if (cover != null) { + Image( + bitmap = cover, + contentDescription = null, + filterQuality = FilterQuality.None, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().clip(ppShape(5.dp)), + ) + } + } +} + +@Composable +private fun CheckerThumbnail(modifier: Modifier) { + Checkerboard( + modifier = modifier.clip(ppShape(5.dp)) + .border(1.dp, LocalTheme.current.borderColor, ppShape(5.dp)), + cell = 9.dp, + ) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt new file mode 100644 index 00000000..08d3e936 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/wardrobe/WardrobeWheelEditor.kt @@ -0,0 +1,215 @@ +package org.polyfrost.polyplus.client.gui.wardrobe + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.polyplus.client.cosmetics.CosmeticCatalog +import org.polyfrost.polyplus.client.emotes.EmoteWheels +import org.polyfrost.polyplus.client.gui.SocialText +import org.polyfrost.polyplus.client.gui.ppShape +import org.polyfrost.polyplus.client.network.http.responses.CosmeticType + +private val WHEEL_GRID_ORDER: List> = listOf( + 0 to 1, 0 to 2, 1 to 2, 2 to 2, 2 to 1, 2 to 0, 1 to 0, 0 to 0, +) + +@Composable +internal fun EmoteWheelEditor( + selectedSlot: Int?, + coverForEmote: (Int) -> Int?, + onSelectSlot: (Int?) -> Unit, + modifier: Modifier, +) { + val theme = LocalTheme.current + val wheelIndex = EmoteWheels.activeIndex + val wheelCount = EmoteWheels.wheelCount + val slots = EmoteWheels.slots(wheelIndex) + + Column( + modifier = modifier.wardrobePanel().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + WheelIconButton("left-arrow", enabled = wheelCount > 1, onClick = { + EmoteWheels.cycle(-1) + onSelectSlot(null) + }) + SocialText( + "Wheel ${wheelIndex + 1} of $wheelCount", + color = theme.textColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + WheelIconButton("right-arrow", enabled = wheelCount > 1, onClick = { + EmoteWheels.cycle(1) + onSelectSlot(null) + }) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (wheelCount < EmoteWheels.MAX_WHEELS) { + SmallButton("New wheel", iconPath = "plus", primary = false, onClick = { + if (EmoteWheels.addWheel()) onSelectSlot(null) + }) + } + if (wheelCount > 1) { + SmallButton("Remove", iconPath = "trash", primary = false, onClick = { + EmoteWheels.removeWheel(wheelIndex) + onSelectSlot(null) + }) + } + } + + BoxWithConstraints(Modifier.fillMaxWidth()) { + val cell = (maxWidth - 16.dp) / 3 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (row in 0..2) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (col in 0..2) { + val slot = WHEEL_GRID_ORDER.indexOf(row to col) + if (slot < 0) { + Spacer(Modifier.size(cell)) + } else { + WheelSlotCell( + emoteId = slots.getOrNull(slot), + selected = selectedSlot == slot, + coverForEmote = coverForEmote, + modifier = Modifier.size(cell), + onClick = { onSelectSlot(if (selectedSlot == slot) null else slot) }, + onClear = { EmoteWheels.assign(wheelIndex, slot, null) }, + ) + } + } + } + } + } + } + + SocialText( + if (selectedSlot == null) { + "Click an emote to add it to the wheel, or pick a slot first to choose where it goes." + } else { + "Click an emote to put it in the selected slot." + }, + color = theme.textColorSecondary, + fontSize = 12.sp, + ) + } +} + +@Composable +private fun WheelSlotCell( + emoteId: Int?, + selected: Boolean, + coverForEmote: (Int) -> Int?, + modifier: Modifier, + onClick: () -> Unit, + onClear: () -> Unit, +) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val name = emoteId?.let { CosmeticCatalog.getEmoteDefinition(it)?.name } + Box( + modifier = modifier + .clip(ppShape(8.dp)) + .background(theme.chipBackground) + .border( + 1.dp, + when { + selected -> Accent + hovered -> theme.textColorSecondary + else -> theme.borderColor + }, + ppShape(8.dp), + ) + .hoverable(interaction) + .clickable(onClick = onClick), + ) { + if (emoteId != null) { + CosmeticThumbnail( + source = null, + type = CosmeticType.Emote, + previewKey = "wheel-$emoteId", + modifier = Modifier.fillMaxSize().padding(bottom = 16.dp), + checker = false, + coverAssetId = coverForEmote(emoteId), + ) + if (name != null) { + CardLabel( + name, + color = theme.textColor, + fontSize = 10.sp, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + if (hovered) { + Box( + modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(18.dp) + .clip(ppShape(6.dp)) + .background(theme.popupBackground) + .clickable(onClick = onClear), + contentAlignment = Alignment.Center, + ) { + Icon("close", color = theme.textColor, modifier = Modifier.size(12.dp)) + } + } + } else { + SocialText( + "Empty", + color = theme.textColorSecondary, + fontSize = 10.sp, + modifier = Modifier.align(Alignment.Center), + ) + } + } +} + +@Composable +internal fun WheelIconButton(iconPath: String, enabled: Boolean, tooltip: String? = null, onClick: () -> Unit) { + val theme = LocalTheme.current + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + Box( + modifier = Modifier.size(28.dp) + .alpha(if (enabled) 1f else 0.4f) + .clip(ppShape(8.dp)) + .background(theme.chipBackground) + .border(1.dp, theme.borderColor, ppShape(8.dp)) + .hoverable(interaction) + .clickable(enabled = enabled, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(iconPath, color = theme.textColor, modifier = Modifier.size(14.dp)) + if (tooltip != null && hovered) TooltipPopup(tooltip) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/PolyAuthorization.kt b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/PolyAuthorization.kt index 8b2b326d..a07a47e3 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/PolyAuthorization.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/PolyAuthorization.kt @@ -19,6 +19,7 @@ import org.polyfrost.polyplus.BackendUrl import org.polyfrost.polyplus.PolyPlusConstants import org.polyfrost.polyplus.client.PolyPlusClient import org.polyfrost.polyplus.client.PolyPlusConfig +import org.polyfrost.polyplus.client.cosmetics.outfits.SkinLibrary import org.polyfrost.polyplus.client.host.HostWorldManager import org.polyfrost.polyplus.client.network.http.responses.AuthResponse import org.polyfrost.polyplus.client.privacy.OnlineServicesDisabledException @@ -196,6 +197,7 @@ object PolyAuthorization { if (modsReported.compareAndSet(false, true)) { PolyPlusClient.SCOPE.launch(Dispatchers.IO) { reportMods(response.token) } } + SkinLibrary.onSignedIn(profileId) return Authorized(profileId, response) } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/Bundle.kt b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/Bundle.kt index a89d5a01..1c54203b 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/Bundle.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/Bundle.kt @@ -11,7 +11,7 @@ data class BundleInfo( @SerialName("created_at") val createdAt: String = "", @SerialName("asset_id") val assetId: Int? = null, @SerialName("base_price") val basePrice: Float? = null, - @SerialName("discount_rate") val discountRate: Int? = null, + val sale: SaleInfo? = null, @SerialName("store_product_id") val storeProductId: String? = null, ) { val purchasable: Boolean get() = !storeProductId.isNullOrBlank() @@ -20,11 +20,9 @@ data class BundleInfo( val discounted: Boolean get() = (discountRate ?: 0) > 0 && !free - val finalPrice: Float? - get() = basePrice?.let { base -> - val rate = (discountRate ?: 0).coerceIn(0, 100) - base * (1f - rate / 100f) - } + val discountRate: Int? get() = sale?.percent?.takeIf { it > 0 } + + val finalPrice: Float? get() = sale?.price ?: basePrice } @Serializable diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/CosmeticStore.kt b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/CosmeticStore.kt index c9472f5b..5dbd4735 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/CosmeticStore.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/network/http/responses/CosmeticStore.kt @@ -3,6 +3,15 @@ package org.polyfrost.polyplus.client.network.http.responses import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +data class SaleInfo( + val price: Float = 0f, + val discount: Float = 0f, + val percent: Int = 0, + val name: String = "", + @SerialName("ends_at") val endsAt: String? = null, +) + @Serializable data class CosmeticTags( val colors: List = emptyList(), @@ -30,7 +39,7 @@ data class CosmeticStoreInfo( val collection: Int? = null, val type: CosmeticType = CosmeticType.Unknown, @SerialName("base_price") val basePrice: Float? = null, - @SerialName("discount_rate") val discountRate: Int? = null, + val sale: SaleInfo? = null, @SerialName("asset_id") val assetId: Int? = null, @SerialName("cover_asset_id") val coverAssetId: Int? = null, @SerialName("created_at") val createdAt: String = "", @@ -44,11 +53,9 @@ data class CosmeticStoreInfo( val discounted: Boolean get() = (discountRate ?: 0) > 0 && !free - val finalPrice: Float? - get() = basePrice?.let { base -> - val rate = (discountRate ?: 0).coerceIn(0, 100) - base * (1f - rate / 100f) - } + val discountRate: Int? get() = sale?.percent?.takeIf { it > 0 } + + val finalPrice: Float? get() = sale?.price ?: basePrice } @Serializable @@ -66,7 +73,7 @@ data class CosmeticStoreView( val collection: Int? = null, val type: CosmeticType = CosmeticType.Unknown, @SerialName("base_price") val basePrice: Float? = null, - @SerialName("discount_rate") val discountRate: Int? = null, + val sale: SaleInfo? = null, @SerialName("asset_id") val assetId: Int? = null, @SerialName("cover_asset_id") val coverAssetId: Int? = null, @SerialName("created_at") val createdAt: String = "", @@ -75,6 +82,8 @@ data class CosmeticStoreView( ) { val purchasable: Boolean get() = !storeProductId.isNullOrBlank() + val discountRate: Int? get() = sale?.percent?.takeIf { it > 0 } + val variantList: List get() = variants?.takeIf { it.isNotEmpty() } ?: listOf(StoreVariant(id, name)) } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/utils/ClientPlatform.kt b/src/main/kotlin/org/polyfrost/polyplus/client/utils/ClientPlatform.kt index c75d8be5..282b37a2 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/utils/ClientPlatform.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/utils/ClientPlatform.kt @@ -90,7 +90,20 @@ object ClientPlatform { Multithreading.submit { DesktopHelper.browse(URI(uri)) } } - fun localPlayerUuid(): UUID = Minecraft.getInstance().user.profileId + @Volatile + private var offlineUuid: Pair? = null + + fun localPlayerUuid(): UUID { + val user = Minecraft.getInstance().user + val id: UUID? = user.profileId + return id ?: offlineUuidFor(user.name) + } + + private fun offlineUuidFor(name: String): UUID { + offlineUuid?.let { (cachedName, uuid) -> if (cachedName == name) return uuid } + return UUID.nameUUIDFromBytes("OfflinePlayer:$name".toByteArray(Charsets.UTF_8)) + .also { offlineUuid = name to it } + } fun localPlayerName(): String = Minecraft.getInstance().user.name diff --git a/stonecutter.properties.toml b/stonecutter.properties.toml index 1ec09a82..f53ddbdf 100644 --- a/stonecutter.properties.toml +++ b/stonecutter.properties.toml @@ -5,7 +5,7 @@ mod.group = "org.polyfrost" deps.fabric_loader = "0.19.5" deps.fabric_language_kotlin = "1.13.13+kotlin.2.4.10" -deps.oneconfig = "1.4.0" +deps.oneconfig = "1.4.3" deps.polydium = "1.1.5" deps.kotlin = "2.4.10" deps.ktor = "3.5.2"