fix: use optimistic approach when changing skins/capes.

This commit is contained in:
Calum H.
2025-06-03 15:34:25 +01:00
committed by Alejandro González
parent 34e49d40f4
commit 006611bd23
8 changed files with 164 additions and 104 deletions

View File

@@ -24,6 +24,7 @@
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10",

View File

@@ -69,12 +69,7 @@ import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import {
get_available_capes,
get_available_skins,
get_normalized_skin_texture,
normalize_skin_texture,
} from './helpers/skins'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
const formatRelativeTime = useRelativeTime()

View File

@@ -77,13 +77,15 @@
</div>
<div class="flex gap-2 mt-12">
<ButtonStyled color="brand" :disabled="disableSave">
<Button v-tooltip="saveTooltip" :disabled="disableSave" @click="save">
<CheckIcon v-if="mode === 'new'" /><SaveIcon v-else />
<ButtonStyled color="brand" :disabled="disableSave || isSaving">
<Button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
</Button>
</ButtonStyled>
<Button @click="hide"><XIcon />Cancel</Button>
<Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button>
</div>
</NewModal>
@@ -117,14 +119,22 @@ import {
get_normalized_skin_texture,
} from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import { UploadIcon, CheckIcon, SaveIcon, XIcon, ChevronRightIcon } from '@modrinth/assets'
import {computedAsync} from "@vueuse/core";
import {
UploadIcon,
CheckIcon,
SaveIcon,
XIcon,
ChevronRightIcon,
SpinnerIcon,
} from '@modrinth/assets'
import { computedAsync } from '@vueuse/core'
const modal = useTemplateRef('modal')
const selectCapeModal = useTemplateRef('selectCapeModal')
const mode = ref<'new' | 'edit'>('new')
const currentSkin = ref<Skin | null>(null)
const shouldRestoreModal = ref(false)
const isSaving = ref(false) // Add loading state
const uploadedTextureUrl = ref<string | null>(null)
@@ -192,6 +202,7 @@ const disableSave = computed(
)
const saveTooltip = computed(() => {
if (isSaving.value) return 'Saving...'
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
return undefined
@@ -205,6 +216,7 @@ function resetState() {
selectedCape.value = undefined
visibleCapeList.value = []
shouldRestoreModal.value = false
isSaving.value = false
}
function show(e: MouseEvent, skin?: Skin) {
@@ -310,6 +322,8 @@ function openUploadSkinModal(e: MouseEvent) {
}
async function save() {
isSaving.value = true
try {
let textureUrl: string
@@ -335,6 +349,8 @@ async function save() {
hide()
} catch (err) {
handleError(err)
} finally {
isSaving.value = false
}
}

View File

@@ -1,6 +1,6 @@
import * as THREE from 'three'
import {Skin, Cape, normalize_skin_texture, get_normalized_skin_texture} from '../skins'
import { determineModelType } from '../skins'
import type { Skin, Cape } from '../skins'
import { get_normalized_skin_texture, determineModelType } from '../skins'
import { reactive } from 'vue'
import { setupSkinModel, disposeCaches } from '@modrinth/utils'
import { skinPreviewStorage } from '../storage/skin-preview-storage'

View File

@@ -1,6 +1,6 @@
import { invoke } from '@tauri-apps/api/core'
import { handleError } from '@/store/notifications'
import {arrayBufferToBase64} from "@modrinth/utils";
import { arrayBufferToBase64 } from '@modrinth/utils'
export interface Cape {
id: string
@@ -144,9 +144,9 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
}
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
const data = await normalize_skin_texture(skin.texture);
const base64 = arrayBufferToBase64(data);
return `data:image/png;base64,${base64}`;
const data = await normalize_skin_texture(skin.texture)
const base64 = arrayBufferToBase64(data)
return `data:image/png;base64,${base64}`
}
export async function normalize_skin_texture(texture: Uint8Array | string): Promise<Uint8Array> {

View File

@@ -16,15 +16,16 @@ import {
SkinLikeTextButton,
SkinPreviewRenderer,
} from '@modrinth/ui'
import {computedAsync} from '@vueuse/core'
import type {Ref} from 'vue'
import {computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch} from 'vue'
import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import {handleError} from '@/store/notifications'
import {Cape, normalize_skin_texture, Skin} from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import type { Cape, Skin } from '@/helpers/skins.ts'
import {
normalize_skin_texture,
equip_skin,
filterDefaultSkins,
filterSavedSkins,
@@ -34,14 +35,14 @@ import {
remove_custom_skin,
set_default_cape,
} from '@/helpers/skins.ts'
import {get as getSettings} from '@/helpers/settings.ts'
import {get_default_user, login as login_flow, users} from '@/helpers/auth'
import type {RenderResult} from '@/helpers/rendering/batch-skin-renderer.ts'
import {generateSkinPreviews, map} from '@/helpers/rendering/batch-skin-renderer.ts'
import {handleSevereError} from '@/store/error'
import {trackEvent} from '@/helpers/analytics'
import { get as getSettings } from '@/helpers/settings.ts'
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import {arrayBufferToBase64} from "@modrinth/utils";
import { arrayBufferToBase64 } from '@modrinth/utils'
const editSkinModal = useTemplateRef('editSkinModal')
const selectCapeModal = useTemplateRef('selectCapeModal')
@@ -59,6 +60,9 @@ const username = computed(() => currentUser.value?.profile?.name ?? undefined)
const selectedSkin = ref<Skin | null>(null)
const defaultCape = ref<Cape>()
const originalSelectedSkin = ref<Skin | null>(null)
const originalDefaultCape = ref<Cape>()
const savedSkins = computed(() => filterSavedSkins(skins.value))
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
@@ -74,9 +78,9 @@ const currentCape = computed(() => {
const skinTexture = computedAsync(async () => {
if (selectedSkin.value?.texture) {
return await get_normalized_skin_texture(selectedSkin.value);
return await get_normalized_skin_texture(selectedSkin.value)
} else {
return '';
return ''
}
})
const capeTexture = computed(() => currentCape.value?.texture)
@@ -106,6 +110,7 @@ async function loadCapes() {
try {
capes.value = (await get_available_capes()) ?? []
defaultCape.value = capes.value.find((c) => c.is_equipped)
originalDefaultCape.value = defaultCape.value
} catch (error) {
if (currentUser.value) {
handleError(error)
@@ -118,6 +123,7 @@ async function loadSkins() {
skins.value = (await get_available_skins()) ?? []
generateSkinPreviews(skins.value, capes.value)
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
originalSelectedSkin.value = selectedSkin.value
} catch (error) {
if (currentUser.value) {
handleError(error)
@@ -126,14 +132,45 @@ async function loadSkins() {
}
async function changeSkin(newSkin: Skin) {
await equip_skin(newSkin).catch(handleError)
await loadSkins()
const previousSkin = selectedSkin.value
const previousSkinsList = [...skins.value]
skins.value = skins.value.map((skin) => {
return {
...skin,
is_equipped: skin.texture_key === newSkin.texture_key,
}
})
selectedSkin.value = skins.value.find((s) => s.texture_key === newSkin.texture_key) || null
try {
await equip_skin(newSkin)
} catch (error) {
selectedSkin.value = previousSkin
skins.value = previousSkinsList
handleError(error)
}
}
async function handleCapeSelected(cape: Cape | undefined) {
await set_default_cape(cape).catch(handleError)
await loadSkins()
await loadCapes()
const previousDefaultCape = defaultCape.value
const previousCapesList = [...capes.value]
capes.value = capes.value.map((c) => ({
...c,
is_equipped: cape ? c.id === cape.id : false,
}))
defaultCape.value = cape ? capes.value.find((c) => c.id === cape.id) : undefined
try {
await set_default_cape(cape)
} catch (error) {
defaultCape.value = previousDefaultCape
capes.value = previousCapesList
handleError(error)
}
}
async function onSkinSaved() {
@@ -178,8 +215,10 @@ function openUploadSkinModal(e: MouseEvent) {
function onSkinFileUploaded(file: File) {
const fakeEvent = new MouseEvent('click')
file.arrayBuffer().then(async (buf) => {
const skinTextureNormalized: Uint8Array = await normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buf))
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized);
const skinTextureNormalized: Uint8Array = await normalize_skin_texture(
`data:image/png;base64,` + arrayBufferToBase64(buf),
)
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
editSkinModal.value.restoreWithNewTexture(skinTexUrl)

View File

@@ -364,12 +364,6 @@ export function getPingLevel(ping: number) {
}
export function arrayBufferToBase64(buffer: Uint8Array | ArrayBuffer): string {
const bytes = buffer instanceof Uint8Array
? buffer
: new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
return btoa(String.fromCharCode(...bytes))
}

129
pnpm-lock.yaml generated
View File

@@ -77,15 +77,21 @@ importers:
'@tauri-apps/plugin-window-state':
specifier: ^2.2.2
version: 2.2.2
'@types/three':
specifier: ^0.172.0
version: 0.172.0
'@vintl/vintl':
specifier: ^4.4.1
version: 4.4.1(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
'@vueuse/core':
specifier: ^11.1.0
version: 11.1.0(vue@3.5.13(typescript@5.5.4))
dayjs:
specifier: ^1.11.10
version: 1.11.11
floating-vue:
specifier: ^5.2.2
version: 5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4))
version: 5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9))(vue@3.5.13(typescript@5.5.4))
ofetch:
specifier: ^1.3.4
version: 1.4.1
@@ -9489,34 +9495,6 @@ snapshots:
- supports-color
- typescript
'@nuxt/kit@3.14.1592(magicast@0.3.5)':
dependencies:
'@nuxt/schema': 3.14.1592(magicast@0.3.5)
c12: 2.0.1(magicast@0.3.5)
consola: 3.2.3
defu: 6.1.4
destr: 2.0.3
globby: 14.0.2
hash-sum: 2.0.0
ignore: 6.0.2
jiti: 2.4.1
klona: 2.0.6
knitwork: 1.1.0
mlly: 1.7.3
pathe: 1.1.2
pkg-types: 1.2.1
scule: 1.3.0
semver: 7.7.1
ufo: 1.5.4
unctx: 2.3.1
unimport: 3.14.4
untyped: 1.5.1
transitivePeerDependencies:
- magicast
- rollup
- supports-color
optional: true
'@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@3.29.4)':
dependencies:
'@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@3.29.4)
@@ -9572,20 +9550,27 @@ snapshots:
- rollup
- supports-color
'@nuxt/schema@3.14.1592(magicast@0.3.5)':
'@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9)':
dependencies:
'@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.34.9)
c12: 2.0.1(magicast@0.3.5)
compatx: 0.1.8
consola: 3.2.3
defu: 6.1.4
hookable: 5.5.3
destr: 2.0.3
globby: 14.0.2
hash-sum: 2.0.0
ignore: 6.0.2
jiti: 2.4.1
klona: 2.0.6
knitwork: 1.1.0
mlly: 1.7.3
pathe: 1.1.2
pkg-types: 1.2.1
scule: 1.3.0
std-env: 3.8.0
semver: 7.7.1
ufo: 1.5.4
uncrypto: 0.1.3
unimport: 3.14.4
unctx: 2.3.1
unimport: 3.14.4(rollup@4.34.9)
untyped: 1.5.1
transitivePeerDependencies:
- magicast
@@ -9634,6 +9619,27 @@ snapshots:
- rollup
- supports-color
'@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.34.9)':
dependencies:
c12: 2.0.1(magicast@0.3.5)
compatx: 0.1.8
consola: 3.2.3
defu: 6.1.4
hookable: 5.5.3
pathe: 1.1.2
pkg-types: 1.2.1
scule: 1.3.0
std-env: 3.8.0
ufo: 1.5.4
uncrypto: 0.1.3
unimport: 3.14.4(rollup@4.34.9)
untyped: 1.5.1
transitivePeerDependencies:
- magicast
- rollup
- supports-color
optional: true
'@nuxt/telemetry@2.6.0(magicast@0.3.5)(rollup@4.28.1)':
dependencies:
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1)
@@ -9976,6 +9982,15 @@ snapshots:
optionalDependencies:
rollup: 4.28.1
'@rollup/pluginutils@5.1.3(rollup@4.34.9)':
dependencies:
'@types/estree': 1.0.6
estree-walker: 2.0.2
picomatch: 4.0.2
optionalDependencies:
rollup: 4.34.9
optional: true
'@rollup/pluginutils@5.1.4(rollup@4.34.9)':
dependencies:
'@types/estree': 1.0.6
@@ -13081,13 +13096,13 @@ snapshots:
optionalDependencies:
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1)
floating-vue@5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4)):
floating-vue@5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9))(vue@3.5.13(typescript@5.5.4)):
dependencies:
'@floating-ui/dom': 1.1.1
vue: 3.5.13(typescript@5.5.4)
vue-resize: 2.0.0-alpha.1(vue@3.5.13(typescript@5.5.4))
optionalDependencies:
'@nuxt/kit': 3.14.1592(magicast@0.3.5)
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.34.9)
for-each@0.3.3:
dependencies:
@@ -16704,26 +16719,6 @@ snapshots:
trough: 2.2.0
vfile: 6.0.3
unimport@3.14.4:
dependencies:
'@rollup/pluginutils': 5.1.3(rollup@4.28.1)
acorn: 8.14.0
escape-string-regexp: 5.0.0
estree-walker: 3.0.3
local-pkg: 0.5.1
magic-string: 0.30.14
mlly: 1.7.3
pathe: 1.1.2
picomatch: 4.0.2
pkg-types: 1.2.1
scule: 1.3.0
strip-literal: 2.1.1
tinyglobby: 0.2.10
unplugin: 1.16.0
transitivePeerDependencies:
- rollup
optional: true
unimport@3.14.4(rollup@3.29.4):
dependencies:
'@rollup/pluginutils': 5.1.3(rollup@3.29.4)
@@ -16763,6 +16758,26 @@ snapshots:
transitivePeerDependencies:
- rollup
unimport@3.14.4(rollup@4.34.9):
dependencies:
'@rollup/pluginutils': 5.1.3(rollup@4.34.9)
acorn: 8.14.0
escape-string-regexp: 5.0.0
estree-walker: 3.0.3
local-pkg: 0.5.1
magic-string: 0.30.14
mlly: 1.7.3
pathe: 1.1.2
picomatch: 4.0.2
pkg-types: 1.2.1
scule: 1.3.0
strip-literal: 2.1.1
tinyglobby: 0.2.10
unplugin: 1.16.0
transitivePeerDependencies:
- rollup
optional: true
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3