-
Notifications
You must be signed in to change notification settings - Fork 56
[Draft] Feature: Add animation sound playback selector demo #3218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: ui
Are you sure you want to change the base?
Changes from all commits
d8f9bbb
4599b78
50eb7b0
03c9517
0119185
162ef7e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| import { mount } from '@vue/test-utils' | ||
| import { defineComponent, nextTick } from 'vue' | ||
| import { describe, expect, it, vi } from 'vitest' | ||
|
|
||
| import { fromText } from '@/models/common/file' | ||
| import { Animation } from '@/models/spx/animation' | ||
| import { SpxProject } from '@/models/spx/project' | ||
| import { Sound } from '@/models/spx/sound' | ||
| import { provideLocalEditorCtx } from '@/components/editor/EditorContextProvider.vue' | ||
| import SoundEditor from './SoundEditor.vue' | ||
|
|
||
| vi.mock('@/components/asset', () => ({ | ||
| useAddAssetFromLibrary: () => vi.fn(), | ||
| useAddSoundByRecording: () => vi.fn(), | ||
| useAddSoundFromLocalFile: () => vi.fn() | ||
| })) | ||
|
|
||
| vi.mock('@/utils/exception', async (importOriginal) => ({ | ||
| ...(await importOriginal<typeof import('@/utils/exception')>()), | ||
| useMessageHandle: (fn: unknown) => ({ fn }) | ||
| })) | ||
|
|
||
| function mockFile(name = 'mocked') { | ||
| return fromText(name, Math.random() + '') | ||
| } | ||
|
|
||
| function mountSoundEditor(animationInits?: { sound?: string | null; soundLoop?: boolean }) { | ||
| const project = new SpxProject() | ||
| const sound1 = new Sound('Sound01', mockFile()) | ||
| const sound2 = new Sound('Sound02', mockFile()) | ||
| project.addSound(sound1) | ||
| project.addSound(sound2) | ||
|
|
||
| const animation = new Animation('walk', { | ||
| sound: animationInits?.sound ?? undefined, | ||
| soundLoop: animationInits?.soundLoop ?? false | ||
| }) | ||
| const doAction = vi.fn(async (_action, fn: () => void) => fn()) | ||
| const state = { history: { doAction } } | ||
|
|
||
| const Harness = defineComponent({ | ||
| name: 'SoundEditorHarness', | ||
| components: { SoundEditor }, | ||
| setup() { | ||
| provideLocalEditorCtx({ project, state: state as any }) | ||
| return { animation, closed: 0 } | ||
| }, | ||
| template: '<SoundEditor :animation="animation" @close="closed += 1" />' | ||
| }) | ||
|
|
||
| const wrapper = mount(Harness, { | ||
| global: { | ||
| directives: { | ||
| radar: () => {} | ||
| }, | ||
| mocks: { | ||
| $t: (message?: { en?: string } | string) => (typeof message === 'string' ? message : message?.en ?? '') | ||
| }, | ||
| stubs: { | ||
| SoundItem: { | ||
| props: ['sound', 'selectable'], | ||
| emits: ['click'], | ||
| template: | ||
| '<button class="sound-item" :data-selected="selectable && selectable.selected" @click="$emit(\'click\')">{{ sound.name }}</button>' | ||
| }, | ||
| UIDropdown: { | ||
| template: '<div><slot name="trigger"></slot><slot></slot></div>' | ||
| }, | ||
| UITooltip: { | ||
| template: '<div class="tooltip"><slot name="trigger"></slot><slot></slot></div>' | ||
| }, | ||
| UIMenu: { | ||
| template: '<div><slot></slot></div>' | ||
| }, | ||
| UIMenuItem: { | ||
| emits: ['click'], | ||
| template: '<button type="button" @click="$emit(\'click\')"><slot></slot></button>' | ||
| } | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| return { wrapper, animation, sounds: [sound1, sound2], doAction } | ||
| } | ||
|
|
||
| describe('SoundEditor', () => { | ||
| it('hides playback selector until a sound is selected', () => { | ||
| const { wrapper } = mountSoundEditor() | ||
|
|
||
| expect(wrapper.text()).not.toContain('Playback') | ||
| }) | ||
|
|
||
| it('closes without saving on cancel', async () => { | ||
| const { wrapper, animation, doAction } = mountSoundEditor() | ||
| const originalSound = animation.sound | ||
|
|
||
| await wrapper.findAll('.sound-item')[0].trigger('click') | ||
| const cancelButton = wrapper.findAll('button').find((button) => button.text() === 'Cancel') | ||
|
|
||
| expect(cancelButton).toBeTruthy() | ||
| await cancelButton!.trigger('click') | ||
|
|
||
| expect(doAction).not.toHaveBeenCalled() | ||
| expect(animation.sound).toBe(originalSound) | ||
| expect((wrapper.vm as any).closed).toBe(1) | ||
| }) | ||
|
|
||
| it('shows playback selector after selecting a sound', async () => { | ||
| const { wrapper } = mountSoundEditor() | ||
|
|
||
| await wrapper.findAll('.sound-item')[0].trigger('click') | ||
| await nextTick() | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This assertion couples the test to |
||
| expect(wrapper.text()).toContain('Playback') | ||
| expect(wrapper.get('select').text()).toContain('One') | ||
| expect(wrapper.get('select').text()).toContain('Loop') | ||
| expect(wrapper.text()).toContain('Play Once') | ||
|
|
||
| await wrapper.get('select').setValue('follow-animation') | ||
|
|
||
| expect(wrapper.text()).toContain('Loop with Animation') | ||
| }) | ||
|
|
||
| it('saves selected sound and one-shot playback on confirm', async () => { | ||
| const { wrapper, animation, sounds, doAction } = mountSoundEditor() | ||
|
|
||
| await wrapper.findAll('.sound-item')[0].trigger('click') | ||
| await wrapper.get('select').setValue('once') | ||
| await wrapper.get('form').trigger('submit') | ||
|
|
||
| expect(doAction).toHaveBeenCalledTimes(1) | ||
| expect(animation.sound).toBe(sounds[0].id) | ||
| expect(animation.soundLoop).toBe(false) | ||
| expect((wrapper.vm as any).closed).toBe(1) | ||
| }) | ||
|
|
||
| it('saves selected sound and follow-animation playback on confirm', async () => { | ||
| const { wrapper, animation, sounds, doAction } = mountSoundEditor() | ||
|
|
||
| await wrapper.findAll('.sound-item')[1].trigger('click') | ||
| await wrapper.get('select').setValue('follow-animation') | ||
| await wrapper.get('form').trigger('submit') | ||
|
|
||
| expect(doAction).toHaveBeenCalledTimes(1) | ||
| expect(animation.sound).toBe(sounds[1].id) | ||
| expect(animation.soundLoop).toBe(true) | ||
| expect((wrapper.vm as any).closed).toBe(1) | ||
| }) | ||
|
|
||
| it('resets playback to one-shot when switching sounds', async () => { | ||
| const { wrapper, animation, sounds } = mountSoundEditor() | ||
|
|
||
| await wrapper.findAll('.sound-item')[0].trigger('click') | ||
| await wrapper.get('select').setValue('follow-animation') | ||
| await wrapper.findAll('.sound-item')[1].trigger('click') | ||
| await wrapper.get('form').trigger('submit') | ||
|
|
||
| expect(animation.sound).toBe(sounds[1].id) | ||
| expect(animation.soundLoop).toBe(false) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,11 +2,11 @@ | |||||||||||
| <UIDropdownForm | ||||||||||||
| v-radar="{ name: 'Sound editor dropdown form', desc: 'Dropdown form for selecting animation sound' }" | ||||||||||||
| :title="$t(actionName)" | ||||||||||||
| style="width: 320px; max-height: 400px" | ||||||||||||
| style="width: 408px; max-height: 400px" | ||||||||||||
| @cancel="emit('close')" | ||||||||||||
| @confirm="handleConfirm" | ||||||||||||
| > | ||||||||||||
| <ul class="flex-[1_1_0] flex flex-wrap content-start gap-3"> | ||||||||||||
| <ul class="flex-[1_1_0] flex flex-wrap content-start gap-2"> | ||||||||||||
|
nighca marked this conversation as resolved.
|
||||||||||||
| <SoundItem | ||||||||||||
| v-for="sound in editorCtx.project.sounds" | ||||||||||||
| :key="sound.id" | ||||||||||||
|
|
@@ -42,13 +42,51 @@ | |||||||||||
| </UIMenu> | ||||||||||||
| </UIDropdown> | ||||||||||||
| </ul> | ||||||||||||
| <template #footer-left> | ||||||||||||
| <div v-if="selected != null" class="flex h-8 items-center gap-2"> | ||||||||||||
| <span class="text-base text-grey-900"> | ||||||||||||
| {{ $t({ en: 'Playback', zh: '播放' }) }} | ||||||||||||
| </span> | ||||||||||||
| <UITooltip> | ||||||||||||
| {{ $t(playbackTooltip) }} | ||||||||||||
| <template #trigger> | ||||||||||||
| <UISelect | ||||||||||||
| v-radar="{ | ||||||||||||
| name: 'Animation sound playback selector', | ||||||||||||
| desc: 'Select how the selected sound plays with the animation' | ||||||||||||
| }" | ||||||||||||
| :value="selectedPlayback" | ||||||||||||
| class="min-w-[80px]" | ||||||||||||
| @update:value="handlePlaybackUpdate" | ||||||||||||
| > | ||||||||||||
| <UISelectOption value="once"> | ||||||||||||
| {{ $t({ en: 'One', zh: '一次' }) }} | ||||||||||||
| </UISelectOption> | ||||||||||||
| <UISelectOption value="follow-animation"> | ||||||||||||
| {{ $t({ en: 'Loop', zh: '循环' }) }} | ||||||||||||
| </UISelectOption> | ||||||||||||
| </UISelect> | ||||||||||||
| </template> | ||||||||||||
| </UITooltip> | ||||||||||||
| </div> | ||||||||||||
| </template> | ||||||||||||
| </UIDropdownForm> | ||||||||||||
| </template> | ||||||||||||
|
|
||||||||||||
| <script setup lang="ts"> | ||||||||||||
| import { ref } from 'vue' | ||||||||||||
| import { computed, ref } from 'vue' | ||||||||||||
| import type { Animation } from '@/models/spx/animation' | ||||||||||||
| import { UIDropdownForm, UIDropdown, UIMenu, UIMenuItem, UIBlockItem, UIIcon } from '@/components/ui' | ||||||||||||
| import { | ||||||||||||
| UIDropdownForm, | ||||||||||||
| UIDropdown, | ||||||||||||
| UIMenu, | ||||||||||||
| UIMenuItem, | ||||||||||||
| UIBlockItem, | ||||||||||||
| UIIcon, | ||||||||||||
| UISelect, | ||||||||||||
| UISelectOption, | ||||||||||||
| UITooltip | ||||||||||||
| } from '@/components/ui' | ||||||||||||
|
Comment on lines
+86
to
+89
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||
| import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue' | ||||||||||||
| import SoundItem from '@/components/editor/stage/sound/SoundItem.vue' | ||||||||||||
| import { useAddAssetFromLibrary, useAddSoundFromLocalFile, useAddSoundByRecording } from '@/components/asset' | ||||||||||||
|
|
@@ -67,15 +105,34 @@ const editorCtx = useEditorCtx() | |||||||||||
|
|
||||||||||||
| const actionName = { en: 'Select sound', zh: '选择声音' } | ||||||||||||
| const selected = ref(props.animation.sound) | ||||||||||||
| type Playback = 'once' | 'follow-animation' | ||||||||||||
| const selectedPlayback = ref<Playback>(props.animation.soundLoop ? 'follow-animation' : 'once') | ||||||||||||
| const playbackTooltip = computed(() => | ||||||||||||
| selectedPlayback.value === 'follow-animation' | ||||||||||||
| ? { en: 'Loop with Animation', zh: '随动画循环' } | ||||||||||||
| : { en: 'Play Once', zh: '播放一次' } | ||||||||||||
| ) | ||||||||||||
|
Comment on lines
+110
to
+114
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||
|
|
||||||||||||
| function selectSound(sound: string) { | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Playback mode resets to
Suggested change
|
||||||||||||
| if (selected.value !== sound) selectedPlayback.value = 'once' | ||||||||||||
| selected.value = sound | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function handlePlaybackUpdate(playback: string | null) { | ||||||||||||
| if (playback !== 'once' && playback !== 'follow-animation') return | ||||||||||||
| selectedPlayback.value = playback | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| async function handleSoundClick(sound: string) { | ||||||||||||
| selected.value = selected.value === sound ? null : sound | ||||||||||||
| if (selected.value === sound) selected.value = null | ||||||||||||
| else selectSound(sound) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const addFromLocalFile = useAddSoundFromLocalFile() | ||||||||||||
| const handleAddFromLocalFile = useMessageHandle( | ||||||||||||
| async () => { | ||||||||||||
| const sound = await addFromLocalFile(editorCtx.project) | ||||||||||||
| selected.value = sound.id | ||||||||||||
| selectSound(sound.id) | ||||||||||||
| }, | ||||||||||||
| { | ||||||||||||
| en: 'Failed to add sound from local file', | ||||||||||||
|
|
@@ -87,7 +144,7 @@ const addAssetFromLibrary = useAddAssetFromLibrary() | |||||||||||
| const handleAddFromAssetLibrary = useMessageHandle( | ||||||||||||
| async () => { | ||||||||||||
| const sounds = await addAssetFromLibrary(editorCtx.project, AssetType.Sound) | ||||||||||||
| selected.value = sounds[0].id | ||||||||||||
| selectSound(sounds[0].id) | ||||||||||||
| }, | ||||||||||||
| { en: 'Failed to add sound from asset library', zh: '从素材库添加失败' } | ||||||||||||
| ).fn | ||||||||||||
|
|
@@ -96,13 +153,16 @@ const addSoundFromRecording = useAddSoundByRecording() | |||||||||||
| const handleRecord = useMessageHandle( | ||||||||||||
| async () => { | ||||||||||||
| const sound = await addSoundFromRecording(editorCtx.project) | ||||||||||||
| selected.value = sound.id | ||||||||||||
| selectSound(sound.id) | ||||||||||||
| }, | ||||||||||||
| { en: 'Failed to record sound', zh: '录音失败' } | ||||||||||||
| ).fn | ||||||||||||
|
|
||||||||||||
| async function handleConfirm() { | ||||||||||||
| await editorCtx.state.history.doAction({ name: actionName }, () => props.animation.setSound(selected.value)) | ||||||||||||
| await editorCtx.state.history.doAction({ name: actionName }, () => { | ||||||||||||
| props.animation.setSound(selected.value) | ||||||||||||
| props.animation.setSoundLoop(selectedPlayback.value === 'follow-animation') | ||||||||||||
| }) | ||||||||||||
| emit('close') | ||||||||||||
| } | ||||||||||||
| </script> | ||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
animationInits?.sound ?? undefinedconverts a potentialnulltoundefinedunnecessarily. Per project conventions,nullrepresents absence — use?? nullhere: