1.2.2: timeline duration handle + frame-snapped script frames + cleaner time display
- Duration grip at the right edge of the scrub ruler. Drag to resize the active timeline (clip duration in clip-edit, scene duration otherwise); maps clientX → seconds and snaps to the scene's frame grid before calling setDuration. Hidden in button- asset edit (state grid is fixed). - Script-frame times go through a quantizeToFrame helper at the store level (addScriptFrame / setScriptFrameTime / duplicateScriptFrame), so drag input no longer produces values like 1.4181818… and the playhead lands exactly on the frame during tick(). - Header time and DocumentDrawer duration input now show the duration rounded to 2 decimals — frame-aligned floats like 4.733333… read as 4.73. Setter still writes the user-typed full- precision value through unchanged.
This commit is contained in:
1 parent
3bc329813c
commit
c17e2e8a9f
8 files changed
+261
-7
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+1
-1
@@ -631,7 +631,7 @@ onBeforeUnmount(() => {
|
|||||||
:title="t('app.stop')"
|
:title="t('app.stop')"
|
||||||
@click="stop"
|
@click="stop"
|
||||||
/>
|
/>
|
||||||
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
|
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration.toFixed(2) }}s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
|
|||||||
@@ -273,7 +273,10 @@ const bgTransparent = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const duration = computed({
|
const duration = computed({
|
||||||
get: () => store.scene.duration,
|
// Round display to 2 decimals so a frame-aligned duration like
|
||||||
|
// 4.733333… shows as 4.73 in the input, matching the header
|
||||||
|
// format. Setter passes the user-typed value through unchanged.
|
||||||
|
get: () => Math.round(store.scene.duration * 100) / 100,
|
||||||
set: (v: number) => store.setDuration(v),
|
set: (v: number) => store.setDuration(v),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -464,6 +464,53 @@ function onScrubPointerDown(e: PointerEvent) {
|
|||||||
seekFromEvent(e, el)
|
seekFromEvent(e, el)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Duration resize handle ---------------------------------------
|
||||||
|
// Vertical grip pinned to the right edge of the scrub ruler. Dragging
|
||||||
|
// it stretches or shrinks the active timeline (clip duration when in
|
||||||
|
// clip-edit, scene duration otherwise) by mapping clientX back to a
|
||||||
|
// time in the lane and snapping to the scene frame grid.
|
||||||
|
|
||||||
|
interface DurationDrag {
|
||||||
|
pointerId: number
|
||||||
|
laneEl: HTMLElement
|
||||||
|
}
|
||||||
|
const durationDrag = ref<DurationDrag | null>(null)
|
||||||
|
|
||||||
|
function onDurationHandlePointerDown(e: PointerEvent) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
const handle = e.currentTarget as HTMLElement
|
||||||
|
const lane = handle.parentElement
|
||||||
|
if (!lane) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
handle.setPointerCapture(e.pointerId)
|
||||||
|
durationDrag.value = { pointerId: e.pointerId, laneEl: lane }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDurationHandlePointerMove(e: PointerEvent) {
|
||||||
|
const d = durationDrag.value
|
||||||
|
if (!d || d.pointerId !== e.pointerId) return
|
||||||
|
const rect = d.laneEl.getBoundingClientRect()
|
||||||
|
if (rect.width <= 0) return
|
||||||
|
const pps = effectivePxPerSec.value
|
||||||
|
if (pps <= 0) return
|
||||||
|
const rawSeconds = (e.clientX - rect.left) / pps
|
||||||
|
// Snap to the frame grid so the resulting duration is a clean
|
||||||
|
// multiple of 1/fps (otherwise dragging produces float garbage
|
||||||
|
// like 4.732891s).
|
||||||
|
const f = fps.value
|
||||||
|
const snapped = Math.max(1 / f, Math.round(rawSeconds * f) / f)
|
||||||
|
store.setDuration(snapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDurationHandlePointerUp(e: PointerEvent) {
|
||||||
|
const d = durationDrag.value
|
||||||
|
if (!d || d.pointerId !== e.pointerId) return
|
||||||
|
const handle = e.currentTarget as HTMLElement
|
||||||
|
if (handle.hasPointerCapture(e.pointerId)) handle.releasePointerCapture(e.pointerId)
|
||||||
|
durationDrag.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function onScrubPointerMove(e: PointerEvent) {
|
function onScrubPointerMove(e: PointerEvent) {
|
||||||
const el = e.currentTarget as HTMLElement
|
const el = e.currentTarget as HTMLElement
|
||||||
if (!el.hasPointerCapture(e.pointerId)) return
|
if (!el.hasPointerCapture(e.pointerId)) return
|
||||||
@@ -3411,6 +3458,18 @@ onBeforeUnmount(() => {
|
|||||||
<span class="timeline-marker-flag"></span>
|
<span class="timeline-marker-flag"></span>
|
||||||
<span class="timeline-marker-label">{{ marker.name }}</span>
|
<span class="timeline-marker-label">{{ marker.name }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="!store.activeButtonAssetId"
|
||||||
|
class="duration-handle"
|
||||||
|
v-tooltip="t('timeline.durationHandle')"
|
||||||
|
:style="{ left: pxFromTime(duration) + 'px' }"
|
||||||
|
@pointerdown="onDurationHandlePointerDown"
|
||||||
|
@pointermove="onDurationHandlePointerMove"
|
||||||
|
@pointerup="onDurationHandlePointerUp"
|
||||||
|
@pointercancel="onDurationHandlePointerUp"
|
||||||
|
>
|
||||||
|
<span class="duration-handle-grip"></span>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
class="scrub-handle"
|
class="scrub-handle"
|
||||||
:class="{ obscured: scrubHandleObscured }"
|
:class="{ obscured: scrubHandleObscured }"
|
||||||
@@ -4907,6 +4966,38 @@ onBeforeUnmount(() => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Duration-resize grip pinned to the right edge of the scrub ruler.
|
||||||
|
Dragging changes the active timeline's duration (clip in clip-edit,
|
||||||
|
scene otherwise). Sits on top of the ruler so it's always reachable
|
||||||
|
even when the playhead is parked at the end. */
|
||||||
|
.duration-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 12px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-handle-grip {
|
||||||
|
width: 4px;
|
||||||
|
height: 70%;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: opacity 120ms var(--ease), background 120ms var(--ease);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-handle:hover .duration-handle-grip,
|
||||||
|
.duration-handle:active .duration-handle-grip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.scrub-handle {
|
.scrub-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -4684,6 +4684,15 @@ export function registerAllTests(): void {
|
|||||||
expect(btn).toBeTruthy()
|
expect(btn).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('topbar time display shows playhead + duration both rounded to 2 decimals', () => {
|
||||||
|
// Both halves run through .toFixed(2) so a frame-aligned float
|
||||||
|
// like 4.733333… reads as 4.73 instead of leaking precision.
|
||||||
|
const time = document.querySelector('.time') as HTMLElement | null
|
||||||
|
expect(time).toBeTruthy()
|
||||||
|
// "<num>s / <num>s" with each <num> being a 2-decimal float.
|
||||||
|
expect(/^-?\d+\.\d{2}s \/ -?\d+\.\d{2}s$/.test((time!.textContent ?? '').trim())).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test('canvas has crop toggle button', () => {
|
test('canvas has crop toggle button', () => {
|
||||||
const cropBtn = document.querySelector('.crop-btn')
|
const cropBtn = document.querySelector('.crop-btn')
|
||||||
expect(cropBtn).toBeTruthy()
|
expect(cropBtn).toBeTruthy()
|
||||||
@@ -7390,6 +7399,32 @@ export function registerAllTests(): void {
|
|||||||
handle.unmount()
|
handle.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('duration field rounds the displayed value to 2 decimals', async () => {
|
||||||
|
// Frame-aligned durations (e.g. 99/24 ≈ 4.125 or 43/30 ≈
|
||||||
|
// 1.43333…) must show as 4.13 / 1.43 in the input — the raw
|
||||||
|
// float would leak repeating digits into the field.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(43 / 30) // ≈ 1.4333333…
|
||||||
|
const handle = mountComponent(DocumentDrawer, {})
|
||||||
|
await nextTick()
|
||||||
|
const { useDocumentDrawer } = await import(
|
||||||
|
'@/composables/useDocumentDrawer'
|
||||||
|
)
|
||||||
|
useDocumentDrawer().openDrawer()
|
||||||
|
await nextTick()
|
||||||
|
const inputs = handle.host.querySelectorAll(
|
||||||
|
'.drawer-panel input[type="number"]',
|
||||||
|
) as NodeListOf<HTMLInputElement>
|
||||||
|
const duration = inputs[2]
|
||||||
|
// Input.value is a string; parsing back must equal the
|
||||||
|
// rounded value, not the full-precision raw float.
|
||||||
|
expect(Number(duration.value)).toBe(1.43)
|
||||||
|
// And the underlying store still holds the precise value
|
||||||
|
// (rounding is display-only).
|
||||||
|
expect(s.scene.duration).toBeCloseTo(43 / 30, 6)
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
test('project name input reflects the shared useProjectName ref', async () => {
|
test('project name input reflects the shared useProjectName ref', async () => {
|
||||||
// The drawer mirrors the topbar's name label — editing lives in
|
// The drawer mirrors the topbar's name label — editing lives in
|
||||||
// the drawer and the topbar just displays. The input defaults to
|
// the drawer and the topbar just displays. The input defaults to
|
||||||
@@ -8784,6 +8819,95 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('ui — Timeline duration handle', () => {
|
||||||
|
test('renders at the right edge of the lane and drags to extend the timeline', async () => {
|
||||||
|
// The handle's `left` style should land on the current
|
||||||
|
// duration's pixel offset; dragging clientX further right
|
||||||
|
// through pointermove must call setDuration with a frame-
|
||||||
|
// snapped value larger than before.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(10)
|
||||||
|
s.project.fps = 30
|
||||||
|
const handle = mountComponent(Timeline, {})
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
const grip = handle.host.querySelector('.duration-handle') as HTMLElement
|
||||||
|
expect(grip).toBeTruthy()
|
||||||
|
// pointerdown on the handle, then pointermove with a
|
||||||
|
// clientX past where 12s would land. We use the lane's
|
||||||
|
// bounding rect to compute target clientX precisely.
|
||||||
|
const lane = grip.parentElement as HTMLElement
|
||||||
|
const laneRect = lane.getBoundingClientRect()
|
||||||
|
const pps = laneRect.width / 10 // duration is 10s
|
||||||
|
const targetX = laneRect.left + 12 * pps
|
||||||
|
grip.dispatchEvent(new PointerEvent('pointerdown', {
|
||||||
|
bubbles: true, cancelable: true, pointerId: 1, button: 0,
|
||||||
|
clientX: laneRect.left + 10 * pps, clientY: laneRect.top + 5,
|
||||||
|
}))
|
||||||
|
grip.dispatchEvent(new PointerEvent('pointermove', {
|
||||||
|
bubbles: true, cancelable: true, pointerId: 1,
|
||||||
|
clientX: targetX, clientY: laneRect.top + 5,
|
||||||
|
}))
|
||||||
|
grip.dispatchEvent(new PointerEvent('pointerup', {
|
||||||
|
bubbles: true, cancelable: true, pointerId: 1, button: 0,
|
||||||
|
clientX: targetX, clientY: laneRect.top + 5,
|
||||||
|
}))
|
||||||
|
await nextTick()
|
||||||
|
// Frame-snapped: 12s × 30fps = 360 → exactly 12.
|
||||||
|
expect(s.scene.duration).toBeCloseTo(12, 5)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shrinks the timeline to a frame-aligned duration when dragged inward', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(10)
|
||||||
|
s.project.fps = 24
|
||||||
|
const handle = mountComponent(Timeline, {})
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
const grip = handle.host.querySelector('.duration-handle') as HTMLElement
|
||||||
|
const lane = grip.parentElement as HTMLElement
|
||||||
|
const laneRect = lane.getBoundingClientRect()
|
||||||
|
const pps = laneRect.width / 10
|
||||||
|
// Drag inward to ~4.123456s. Should snap to nearest frame
|
||||||
|
// at 24fps: round(4.123456 × 24) = round(98.96) = 99 → 99/24
|
||||||
|
const targetX = laneRect.left + 4.123456 * pps
|
||||||
|
grip.dispatchEvent(new PointerEvent('pointerdown', {
|
||||||
|
bubbles: true, cancelable: true, pointerId: 1, button: 0,
|
||||||
|
clientX: laneRect.left + 10 * pps, clientY: laneRect.top + 5,
|
||||||
|
}))
|
||||||
|
grip.dispatchEvent(new PointerEvent('pointermove', {
|
||||||
|
bubbles: true, cancelable: true, pointerId: 1,
|
||||||
|
clientX: targetX, clientY: laneRect.top + 5,
|
||||||
|
}))
|
||||||
|
grip.dispatchEvent(new PointerEvent('pointerup', {
|
||||||
|
bubbles: true, cancelable: true, pointerId: 1, button: 0,
|
||||||
|
clientX: targetX, clientY: laneRect.top + 5,
|
||||||
|
}))
|
||||||
|
await nextTick()
|
||||||
|
expect(s.scene.duration).toBeCloseTo(99 / 24, 4)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handle is hidden inside button-asset edit (state-grid is fixed)', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const ba = s.addButtonAsset('Btn', 80, 30)
|
||||||
|
s.enterButtonAssetEdit(ba.id)
|
||||||
|
const handle = mountComponent(Timeline, {})
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
expect(handle.host.querySelector('.duration-handle')).toBeNull()
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
s.exitButtonAssetEdit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('ui — Timeline audio track', () => {
|
suite('ui — Timeline audio track', () => {
|
||||||
test('audio track renders as an .audio-track lane with one block per clip', async () => {
|
test('audio track renders as an .audio-track lane with one block per clip', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
@@ -11770,6 +11894,30 @@ export function registerAllTests(): void {
|
|||||||
expect(times).toEqual([1, 3, 5])
|
expect(times).toEqual([1, 3, 5])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('addScriptFrame quantizes time to the scene fps', () => {
|
||||||
|
// Drag input feeds in arbitrary float ratios — store rounds to
|
||||||
|
// the nearest frame so the playhead lands exactly on the frame
|
||||||
|
// during tick() and the saved value isn't 1.4181818…
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(10)
|
||||||
|
s.project.fps = 30
|
||||||
|
const t = s.addScriptTrack()
|
||||||
|
const f = s.addScriptFrame(t.id, 1.4181818)!
|
||||||
|
// 1.4181818 × 30 = 42.545 → round 43 → 43/30 ≈ 1.4333…
|
||||||
|
expect(f.time).toBeCloseTo(43 / 30, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setScriptFrameTime quantizes the new time to the scene fps', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(10)
|
||||||
|
s.project.fps = 24
|
||||||
|
const t = s.addScriptTrack()
|
||||||
|
const f = s.addScriptFrame(t.id, 0)!
|
||||||
|
s.setScriptFrameTime(f.id, 2.0833)
|
||||||
|
// 2.0833 × 24 = 49.9992 → 50 → 50/24
|
||||||
|
expect(f.time).toBeCloseTo(50 / 24, 6)
|
||||||
|
})
|
||||||
|
|
||||||
test('removeScriptTrack cascades its frames and trackOrder entry', () => {
|
test('removeScriptTrack cascades its frames and trackOrder entry', () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const t = s.addScriptTrack()
|
const t = s.addScriptTrack()
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export const en = {
|
|||||||
hitTrackDelete: 'Delete hit shape',
|
hitTrackDelete: 'Delete hit shape',
|
||||||
addKeyframeTooltip: 'Add keyframe for the selected track at the playhead',
|
addKeyframeTooltip: 'Add keyframe for the selected track at the playhead',
|
||||||
setEndTooltip: 'Set / move end keyframe to the playhead — layer disappears here',
|
setEndTooltip: 'Set / move end keyframe to the playhead — layer disappears here',
|
||||||
|
durationHandle: 'Drag to change the timeline duration',
|
||||||
endKfTooltip: 'End keyframe {time} — layer disappears at this time',
|
endKfTooltip: 'End keyframe {time} — layer disappears at this time',
|
||||||
endKfMakeEnd: 'Convert to end keyframe',
|
endKfMakeEnd: 'Convert to end keyframe',
|
||||||
endKfBackToNormal: 'Back to normal keyframe',
|
endKfBackToNormal: 'Back to normal keyframe',
|
||||||
|
|||||||
+14
-3
@@ -208,6 +208,17 @@ function snapToButtonStateTime(t: number): number {
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Round a time to the nearest frame boundary at the scene's fps and
|
||||||
|
* clamp to [0, duration]. Used for script-frame timing so the stored
|
||||||
|
* time always matches an actual rendered frame — drag input ratios
|
||||||
|
* otherwise produce values like 1.4181818 that never align with the
|
||||||
|
* playhead during tick(). */
|
||||||
|
function quantizeToFrame(time: number, fps: number, duration: number): number {
|
||||||
|
const f = fps > 0 ? fps : 60
|
||||||
|
const snapped = Math.round(time * f) / f
|
||||||
|
return Math.max(0, Math.min(duration, snapped))
|
||||||
|
}
|
||||||
|
|
||||||
/** The button editor has two orthogonal sub-modes: state editing (4 content
|
/** The button editor has two orthogonal sub-modes: state editing (4 content
|
||||||
* states as kf-time slots) vs hit-area editing (hit shapes only, with normal
|
* states as kf-time slots) vs hit-area editing (hit shapes only, with normal
|
||||||
* state as onion-skin). Switching modes keeps each mode's last selection. */
|
* state as onion-skin). Switching modes keeps each mode's last selection. */
|
||||||
@@ -2134,7 +2145,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
): ScriptFrame | null {
|
): ScriptFrame | null {
|
||||||
const track = this.scene.scriptTracks?.find((t) => t.id === trackId)
|
const track = this.scene.scriptTracks?.find((t) => t.id === trackId)
|
||||||
if (!track) return null
|
if (!track) return null
|
||||||
const t = Math.max(0, Math.min(this.scene.duration, time))
|
const t = quantizeToFrame(time, this.scene.fps, this.scene.duration)
|
||||||
const frame: ScriptFrame = {
|
const frame: ScriptFrame = {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
trackId,
|
trackId,
|
||||||
@@ -2156,7 +2167,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
setScriptFrameTime(id: string, time: number) {
|
setScriptFrameTime(id: string, time: number) {
|
||||||
const frame = this.scene.scriptFrames?.find((f) => f.id === id)
|
const frame = this.scene.scriptFrames?.find((f) => f.id === id)
|
||||||
if (!frame) return
|
if (!frame) return
|
||||||
frame.time = Math.max(0, Math.min(this.scene.duration, time))
|
frame.time = quantizeToFrame(time, this.scene.fps, this.scene.duration)
|
||||||
this.scene.scriptFrames?.sort((a, b) => a.time - b.time)
|
this.scene.scriptFrames?.sort((a, b) => a.time - b.time)
|
||||||
},
|
},
|
||||||
duplicateScriptFrame(frameId: string): string | null {
|
duplicateScriptFrame(frameId: string): string | null {
|
||||||
@@ -2182,7 +2193,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
const copy: ScriptFrame = {
|
const copy: ScriptFrame = {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
trackId: source.trackId,
|
trackId: source.trackId,
|
||||||
time: target,
|
time: quantizeToFrame(target, this.scene.fps, this.scene.duration),
|
||||||
actions: source.actions.map((a) => ({ ...a })),
|
actions: source.actions.map((a) => ({ ...a })),
|
||||||
}
|
}
|
||||||
if (source.label) copy.label = source.label
|
if (source.label) copy.label = source.label
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '1.2.1'
|
export const APP_VERSION = '1.2.2'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user