Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 60 additions & 13 deletions src/composables/useModel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { PhysicalPosition } from '@tauri-apps/api/dpi'

import { LogicalSize } from '@tauri-apps/api/dpi'
import { PhysicalSize } from '@tauri-apps/api/dpi'
import { resolveResource, sep } from '@tauri-apps/api/path'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { message } from 'ant-design-vue'
Expand All @@ -18,6 +18,8 @@ import live2d from '../utils/live2d'
const appWindow = getCurrentWebviewWindow()
const digitKeys = '1234567890'.split('') as readonly string[]
const letterKeys = 'QWERTYUIOPASDFGHJKLZXCVBNM'.split('') as readonly string[]
let suppressScaleWriteback = false
let hasCompletedInitialWindowSizeSync = false

export interface ModelSize {
width: number
Expand Down Expand Up @@ -65,11 +67,16 @@ export function useModel() {
return `${modelId}:expression:${index}`
}

async function handleLoad() {
async function handleLoad(
model = modelStore.currentModel,
options: { showError?: boolean } = {},
) {
try {
if (!modelStore.currentModel) return
if (!model) return false

const { path } = modelStore.currentModel
hasCompletedInitialWindowSizeSync = false

const { path } = model

await resolveResource(path)

Expand All @@ -83,7 +90,7 @@ export function useModel() {

handleResize()

const modelId = modelStore.currentModel.id
const modelId = model.id

const behaviorIds: string[] = []

Expand All @@ -106,8 +113,15 @@ export function useModel() {

modelStore.shortcuts[id] = shortcut
}
return true
} catch (error) {
message.error(String(error))
modelSize.value = void 0

if (options.showError ?? true) {
message.error(String(error))
}

return false
}
}

Expand All @@ -120,20 +134,52 @@ export function useModel() {

live2d.resizeModel(modelSize.value)

if (!hasCompletedInitialWindowSizeSync || suppressScaleWriteback) {
suppressScaleWriteback = false
return
}

const { width } = modelSize.value

const size = await appWindow.size()

catStore.window.scale = round((size.width / width) * 100)
}

async function syncWindowSize() {
if (!modelSize.value) return false

const { width, height } = modelSize.value
const nextWidth = Math.round(width * (catStore.window.scale / 100))
const nextHeight = Math.round(height * (catStore.window.scale / 100))
const size = await appWindow.size()

if (size.width === nextWidth && size.height === nextHeight) {
hasCompletedInitialWindowSizeSync = true
return true
}

suppressScaleWriteback = true

if (round(innerWidth / innerHeight, 1) !== round(width / height, 1)) {
try {
await appWindow.setSize(
new LogicalSize({
width: innerWidth,
height: Math.ceil(innerWidth * (height / width)),
new PhysicalSize({
width: nextWidth,
height: nextHeight,
}),
)
}

const size = await appWindow.size()
hasCompletedInitialWindowSizeSync = true

catStore.window.scale = round((size.width / width) * 100)
return true
} catch (error) {
suppressScaleWriteback = false
hasCompletedInitialWindowSizeSync = false

message.error(String(error))

return false
}
}

const handlePress = (key: string) => {
Expand Down Expand Up @@ -238,6 +284,7 @@ export function useModel() {
handleLoad,
handleDestroy,
handleResize,
syncWindowSize,
handleKeyChange,
handleMouseChange,
handleMouseMove,
Expand Down
167 changes: 134 additions & 33 deletions src/pages/main/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@
import type { MotionInfo } from 'easy-live2d'

import { convertFileSrc } from '@tauri-apps/api/core'
import { PhysicalSize } from '@tauri-apps/api/dpi'
import { Menu, PredefinedMenuItem } from '@tauri-apps/api/menu'
import { sep } from '@tauri-apps/api/path'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { exists, readDir } from '@tauri-apps/plugin-fs'
import { useDebounceFn, useEventListener } from '@vueuse/core'
import { round } from 'es-toolkit'
import { nth } from 'es-toolkit/compat'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'

import { useAppMenu } from '@/composables/useAppMenu'
import { useDevice } from '@/composables/useDevice'
Expand All @@ -29,16 +28,19 @@ import { clearObject } from '@/utils/shared'

const { startListening } = useDevice()
const appWindow = getCurrentWebviewWindow()
const { modelSize, handleLoad, handleDestroy, handleResize, handleKeyChange } = useModel()
const { modelSize, handleLoad, handleDestroy, handleResize, syncWindowSize, handleKeyChange } = useModel()
const catStore = useCatStore()
const { getBaseMenu, getExitMenu } = useAppMenu()
const modelStore = useModelStore()
const generalStore = useGeneralStore()
const resizing = ref(false)
const backgroundImagePath = ref<string>()
const { stickActive } = useGamepad()

onMounted(startListening)
const isCanvasReady = ref(false)
const INITIAL_MODEL_LOAD_RETRY_DELAY_MS = 100
let loadedModelId: string | undefined
let modelLoadVersion = 0
let modelLoadBarrier = Promise.resolve()

onUnmounted(handleDestroy)

Expand All @@ -54,49 +56,148 @@ useEventListener('resize', () => {
debouncedResize()
})

watch(() => modelStore.currentModel, async (model) => {
if (!model) return
function waitForAnimationFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
}

function waitForDelay(delayMs: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, delayMs)
})
}

async function ensureCanvasReady() {
await nextTick()

let canvas = document.getElementById('live2dCanvas')

if (!(canvas instanceof HTMLCanvasElement)) {
await waitForAnimationFrame()
canvas = document.getElementById('live2dCanvas')
}

if (!(canvas instanceof HTMLCanvasElement)) {
throw new TypeError('[main] #live2dCanvas is not ready')
}

isCanvasReady.value = true
}

async function loadCurrentModel(
model = modelStore.currentModel,
options: { retryOnFailure?: boolean } = {},
) {
if (!model) return false

const version = ++modelLoadVersion
let completed = false
const previousLoad = modelLoadBarrier
let releaseCurrentLoad!: () => void
const currentLoad = new Promise<void>((resolve) => {
releaseCurrentLoad = resolve
})

modelLoadBarrier = currentLoad

await previousLoad

const isActiveLoad = () => {
return version === modelLoadVersion && modelStore.currentModel?.id === model.id
}

try {
if (!isActiveLoad()) {
return false
}

modelStore.modelReady = false

await handleLoad()
let didLoad = await handleLoad(model, { showError: !options.retryOnFailure })

const path = join(model.path, 'resources', 'background.png')
if (!didLoad && options.retryOnFailure) {
await waitForDelay(INITIAL_MODEL_LOAD_RETRY_DELAY_MS)

const existed = await exists(path)
if (!isActiveLoad()) {
return false
}

backgroundImagePath.value = existed ? convertFileSrc(path) : void 0
didLoad = await handleLoad(model)
}

if (!didLoad || !isActiveLoad()) {
return false
}

const didSyncWindowSize = await syncWindowSize()

if (!didSyncWindowSize) {
return false
}

clearObject([modelStore.supportKeys, modelStore.pressedKeys])
if (!isActiveLoad()) {
return false
}

const path = join(model.path, 'resources', 'background.png')

const existed = await exists(path)

const resourcePath = join(model.path, 'resources')
const groups = ['left-keys', 'right-keys']
backgroundImagePath.value = existed ? convertFileSrc(path) : void 0

for await (const groupName of groups) {
const groupDir = join(resourcePath, groupName)
const files = await readDir(groupDir).catch(() => [])
const imageFiles = files.filter(file => isImage(file.name))
clearObject([modelStore.supportKeys, modelStore.pressedKeys])

for (const file of imageFiles) {
const fileName = file.name.split('.')[0]
const resourcePath = join(model.path, 'resources')
const groups = ['left-keys', 'right-keys']

modelStore.supportKeys[fileName] = join(groupDir, file.name)
for await (const groupName of groups) {
const groupDir = join(resourcePath, groupName)
const files = await readDir(groupDir).catch(() => [])
const imageFiles = files.filter(file => isImage(file.name))

for (const file of imageFiles) {
const fileName = file.name.split('.')[0]

modelStore.supportKeys[fileName] = join(groupDir, file.name)
}
}

if (!isActiveLoad()) {
return false
}

loadedModelId = model.id
modelStore.modelReady = true
completed = true

return true
} finally {
if (!completed && isActiveLoad()) {
modelStore.modelReady = true
}

releaseCurrentLoad()
}
}

modelStore.modelReady = true
}, { deep: true, immediate: true })
onMounted(async () => {
startListening()
await ensureCanvasReady()
await loadCurrentModel(modelStore.currentModel, { retryOnFailure: true })
})

watch([() => catStore.window.scale, modelSize], async ([scale, modelSize]) => {
if (!modelSize) return
watch(() => modelStore.currentModel?.id, async (modelId) => {
if (!isCanvasReady.value || !modelId || modelId === loadedModelId) return

const { width, height } = modelSize
await loadCurrentModel()
})

appWindow.setSize(
new PhysicalSize({
width: Math.round(width * (scale / 100)),
height: Math.round(height * (scale / 100)),
}),
)
}, { immediate: true })
watch(() => catStore.window.scale, async () => {
if (!modelSize.value) return

await syncWindowSize()
})

watch([modelStore.pressedKeys, stickActive], ([keys, stickActive]) => {
const dirs = Object.values(keys).map((path) => {
Expand Down