+import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
+import { computed, onUnmounted, ref, watch } from 'vue'
+import { useRouter } from 'vue-router'
+
+import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
+import {
+ openSkinSiteLogin,
+ requestSkinSitePlayers,
+ skinSitePlayers,
+ skinSiteStatus,
+ skinSiteUser,
+} from '@/composables/skin-site-session'
+import { users } from '@/helpers/auth'
+import { getInstanceMode } from '@/helpers/hosted-packs'
+import {
+ registerInstancePlayerPicker,
+ saveInstancePlayer,
+ waitForSkinSiteSession,
+ type InstancePlayer,
+ type PlayerChoice,
+} from '@/helpers/instance-player'
+import { playerMessages as messages } from './instance-player-messages'
+
+const { formatMessage } = useVIntl()
+
+const modal = ref
>()
+const microsoftLogin = ref>()
+const router = useRouter()
+const accounts = ref([])
+const active = ref(false)
+const loading = ref(false)
+const saving = ref(false)
+const error = ref('')
+const instanceId = ref('')
+const locked = ref(null)
+const awaitingSkinLogin = ref(false)
+let hideForLogin = false
+let generation = 0
+let resolveSelection: ((player: InstancePlayer) => void) | undefined
+let rejectSelection: ((reason: Error) => void) | undefined
+const choices = computed(() => {
+ const skin: PlayerChoice[] =
+ skinSiteStatus.value === 'signed-in'
+ ? skinSitePlayers.value
+ .filter((player) => !player.isMojang)
+ .map((player) => ({
+ id: player.uuid,
+ name: player.name,
+ account_type: 'yggdrasil',
+ skin_site_user: skinSiteUser.value?.uuid,
+ head: player.headDataUrl,
+ }))
+ : []
+ return [...skin, ...accounts.value].filter(
+ (player) =>
+ !locked.value ||
+ (player.id.replaceAll('-', '') === locked.value.id.replaceAll('-', '') &&
+ player.account_type === locked.value.account_type &&
+ (player.skin_site_user ?? null) === (locked.value.skin_site_user ?? null)),
+ )
+})
+
+async function refresh() {
+ const revision = ++generation
+ loading.value = true
+ error.value = ''
+ try {
+ const [available, mode] = await Promise.all([users(), getInstanceMode(instanceId.value)])
+ if (revision !== generation) return
+ accounts.value = available
+ .filter(
+ (account) =>
+ account.account_type === 'microsoft' ||
+ (mode === 'local' && account.account_type === 'offline'),
+ )
+ .map((account) => ({
+ id: account.profile.id,
+ name: account.profile.name,
+ account_type: account.account_type,
+ }))
+ await waitForSkinSiteSession()
+ if (revision !== generation) return
+ if (skinSiteStatus.value === 'signed-in') await requestSkinSitePlayers()
+ if (skinSiteStatus.value === 'error') throw new Error('无法确认皮肤站登录状态,请重试。')
+ } catch (cause) {
+ if (revision === generation)
+ error.value = cause instanceof Error ? cause.message : String(cause)
+ } finally {
+ if (revision === generation) loading.value = false
+ }
+}
+
+function cancelled() {
+ if (hideForLogin) {
+ hideForLogin = false
+ return
+ }
+ if (!active.value) return
+ active.value = false
+ awaitingSkinLogin.value = false
+ generation++
+ rejectSelection?.(new Error('已取消选择实例玩家'))
+ resolveSelection = undefined
+ rejectSelection = undefined
+}
+
+async function select(player: PlayerChoice) {
+ if (loading.value || saving.value || !active.value) return
+ saving.value = true
+ error.value = ''
+ try {
+ const { head: _, ...binding } = player
+ await saveInstancePlayer(instanceId.value, binding)
+ active.value = false
+ resolveSelection?.(binding)
+ resolveSelection = undefined
+ rejectSelection = undefined
+ modal.value?.hide()
+ } catch (cause) {
+ error.value = cause instanceof Error ? cause.message : String(cause)
+ } finally {
+ saving.value = false
+ }
+}
+
+function signInSkinSite() {
+ awaitingSkinLogin.value = true
+ hideForLogin = true
+ openSkinSiteLogin()
+ modal.value?.hide()
+ void router.push('/starlight-skin')
+}
+
+const unregister = registerInstancePlayerPicker((id, saved) => {
+ if (active.value) return Promise.reject(new Error('请先完成当前实例的玩家选择。'))
+ instanceId.value = id
+ locked.value = saved
+ accounts.value = []
+ awaitingSkinLogin.value = false
+ active.value = true
+ modal.value?.show()
+ void refresh()
+ return new Promise((resolve, reject) => {
+ resolveSelection = resolve
+ rejectSelection = reject
+ })
+})
+watch([skinSiteStatus, () => skinSiteUser.value?.uuid], () => {
+ if (!active.value || saving.value) return
+ if (
+ awaitingSkinLogin.value &&
+ skinSiteStatus.value === 'signed-in' &&
+ (!locked.value?.skin_site_user || locked.value.skin_site_user === skinSiteUser.value?.uuid)
+ ) {
+ awaitingSkinLogin.value = false
+ modal.value?.show()
+ }
+ void refresh()
+})
+onUnmounted(() => {
+ unregister()
+ cancelled()
+})
+
+
+
+
+
+
+ {{
+ locked
+ ? formatMessage(messages.restore, { name: locked.name })
+ : formatMessage(messages.remember)
+ }}
+
+
{{ formatMessage(messages.loading) }}
+
+
+
+
+
+ {{ formatMessage(skinSiteStatus === 'signed-in' ? messages.empty : messages.signIn) }}
+
+
+
{{ error }}
+
+
+
+ {{ formatMessage(messages.or) }}
+
+
+
{{ formatMessage(messages.saving) }}
+
+
+
+
diff --git a/apps/app-frontend/src/components/instance/InstancePlayerSettings.vue b/apps/app-frontend/src/components/instance/InstancePlayerSettings.vue
new file mode 100644
index 0000000..7c79c2d
--- /dev/null
+++ b/apps/app-frontend/src/components/instance/InstancePlayerSettings.vue
@@ -0,0 +1,79 @@
+
+
+
+
{{ formatMessage(messages.setting) }}
+
+ {{ player ? player.name : formatMessage(messages.firstLaunch) }}
+
+
+
{{ error }}
+
+
diff --git a/apps/app-frontend/src/components/instance/TaggedModDownloadsModal.vue b/apps/app-frontend/src/components/instance/TaggedModDownloadsModal.vue
index 8c4877a..02e74f8 100644
--- a/apps/app-frontend/src/components/instance/TaggedModDownloadsModal.vue
+++ b/apps/app-frontend/src/components/instance/TaggedModDownloadsModal.vue
@@ -133,14 +133,18 @@ function openDownloads() {