Compare commits
6 Commits
e3f3ab5c2a
...
6a5a72828f
Author | SHA1 | Date | |
---|---|---|---|
6a5a72828f | |||
b49d662e72 | |||
c5410bb3f5 | |||
7794c91eca | |||
ab2cd0d346 | |||
3420c7cc9c |
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
|
||||
import { computed, ref, watch, onUnmounted } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useTimelineTicks } from '../hooks/useTimelineTicks';
|
||||
|
||||
@ -21,8 +21,8 @@ const SLIDER_PADDING = 8; // Ant Design Slider has 8px padding on each side
|
||||
const HOUR_IN_MS = 3600 * 1000;
|
||||
|
||||
// --- Internal State ---
|
||||
const internalCurrentTime = ref(props.currentTime);
|
||||
const selectedHour = ref(Math.floor(props.currentTime / HOUR_IN_MS));
|
||||
const isUserInteracting = ref(false); // 标志位,防止 props 更新与用户操作冲突
|
||||
|
||||
// --- Computed View Range ---
|
||||
const viewStartTime = computed(() => selectedHour.value * HOUR_IN_MS);
|
||||
@ -35,66 +35,23 @@ const { ticks } = useTimelineTicks(viewStartTime, viewEndTime);
|
||||
watch(
|
||||
() => props.currentTime,
|
||||
(newValue) => {
|
||||
// Only update from prop if not playing
|
||||
if (!props.isPlaying) {
|
||||
internalCurrentTime.value = newValue;
|
||||
// 如果用户正在主动交互(如拖动、点击),则暂时不根据外部时间更新小时选择
|
||||
if (isUserInteracting.value) {
|
||||
return;
|
||||
}
|
||||
const newHour = Math.floor(newValue / HOUR_IN_MS);
|
||||
if (newHour !== selectedHour.value) {
|
||||
selectedHour.value = newHour;
|
||||
|
||||
// 仅在有效范围内才同步小时;避免负数/异常 currentTime 破坏选择器
|
||||
if (Number.isFinite(newValue) && newValue >= 0 && props.totalDuration > 0) {
|
||||
const maxHour = Math.max(Math.ceil(props.totalDuration / HOUR_IN_MS) - 1, 0);
|
||||
const newHour = Math.min(Math.max(Math.floor(newValue / HOUR_IN_MS), 0), maxHour);
|
||||
if (newHour !== selectedHour.value) {
|
||||
selectedHour.value = newHour;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// --- Animation Frame Timer for Playback ---
|
||||
let animationFrameId: number | null = null;
|
||||
let lastTimestamp: number | null = null;
|
||||
|
||||
const animationLoop = (timestamp: number) => {
|
||||
if (!lastTimestamp) {
|
||||
lastTimestamp = timestamp;
|
||||
}
|
||||
const deltaTime = timestamp - lastTimestamp;
|
||||
lastTimestamp = timestamp;
|
||||
|
||||
// Ensure we don't jump too far ahead if the tab was inactive
|
||||
const clampedDelta = Math.min(deltaTime, 1000); // Max 1-second jump
|
||||
|
||||
const newTime = internalCurrentTime.value + clampedDelta;
|
||||
|
||||
if (newTime < props.totalDuration) {
|
||||
internalCurrentTime.value = newTime;
|
||||
// No need to emit timeupdate as per user decision
|
||||
// emit('timeupdate', newTime);
|
||||
animationFrameId = requestAnimationFrame(animationLoop);
|
||||
} else {
|
||||
internalCurrentTime.value = props.totalDuration;
|
||||
emit('pause'); // Auto-pause at the end
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.isPlaying,
|
||||
(isPlaying) => {
|
||||
if (isPlaying) {
|
||||
lastTimestamp = null; // Reset timestamp on play
|
||||
animationFrameId = requestAnimationFrame(animationLoop);
|
||||
} else {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Computed properties for display and binding ---
|
||||
const formatTime = (ms: number): string => {
|
||||
if (isNaN(ms) || ms < 0) return '00:00:00';
|
||||
@ -105,22 +62,16 @@ const formatTime = (ms: number): string => {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const currentTimeFormatted = computed(() => formatTime(internalCurrentTime.value));
|
||||
const currentTimeFormatted = computed(() => formatTime(props.currentTime));
|
||||
const totalDurationFormatted = computed(() => formatTime(props.totalDuration));
|
||||
|
||||
const sliderValue = computed({
|
||||
get: () => internalCurrentTime.value,
|
||||
set: (val: number) => {
|
||||
internalCurrentTime.value = val;
|
||||
emit('seek', val);
|
||||
},
|
||||
});
|
||||
|
||||
const playheadPosition = computed(() => {
|
||||
if (!props.totalDuration) return '0%';
|
||||
const relativeTime = internalCurrentTime.value - viewStartTime.value;
|
||||
const viewDuration = viewEndTime.value - viewStartTime.value;
|
||||
return `${(relativeTime / viewDuration) * 100}%`;
|
||||
if (viewDuration <= 0) return '0%';
|
||||
const current = props.currentTime;
|
||||
const relativeTime = current - viewStartTime.value;
|
||||
const clamped = Math.min(Math.max(relativeTime, 0), viewDuration);
|
||||
return `${(clamped / viewDuration) * 100}%`;
|
||||
});
|
||||
|
||||
const hourOptions = computed(() => {
|
||||
@ -137,20 +88,24 @@ const hourOptions = computed(() => {
|
||||
|
||||
// --- Event Handlers ---
|
||||
const timelineContainerRef = ref<HTMLElement | null>(null);
|
||||
let interactionTimer: number | undefined;
|
||||
|
||||
const handleTimelineClick = (event: MouseEvent) => {
|
||||
if (!timelineContainerRef.value || !props.totalDuration) return;
|
||||
// 设置一个短暂的用户交互锁,以避免外部时间更新覆盖用户的即时选择
|
||||
const setUserInteraction = () => {
|
||||
isUserInteracting.value = true;
|
||||
clearTimeout(interactionTimer);
|
||||
interactionTimer = window.setTimeout(() => {
|
||||
isUserInteracting.value = false;
|
||||
}, 2000); // 2秒内,用户的操作优先
|
||||
};
|
||||
|
||||
const rect = timelineContainerRef.value.getBoundingClientRect();
|
||||
const effectiveWidth = rect.width - SLIDER_PADDING * 2;
|
||||
const clickX = event.clientX - rect.left - SLIDER_PADDING;
|
||||
|
||||
const percentage = Math.max(0, Math.min(1, clickX / effectiveWidth));
|
||||
const newTimeInView = percentage * HOUR_IN_MS;
|
||||
const newAbsoluteTime = viewStartTime.value + newTimeInView;
|
||||
|
||||
internalCurrentTime.value = newAbsoluteTime;
|
||||
emit('seek', newAbsoluteTime);
|
||||
const handleHourChange = (newHour: number) => {
|
||||
setUserInteraction();
|
||||
selectedHour.value = newHour;
|
||||
const newTime = newHour * HOUR_IN_MS;
|
||||
// 确保搜寻时间不超过总时长
|
||||
const clampedTime = Math.min(newTime, props.totalDuration);
|
||||
emit('seek', clampedTime);
|
||||
};
|
||||
|
||||
const handlePlayPause = () => {
|
||||
@ -160,6 +115,28 @@ const handlePlayPause = () => {
|
||||
emit('play');
|
||||
}
|
||||
};
|
||||
|
||||
// 根据点击时间轴位置计算对应时间并触发 seek
|
||||
const handleTimelineClick = (event: MouseEvent) => {
|
||||
setUserInteraction();
|
||||
const container = timelineContainerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const effectiveWidth = Math.max(rect.width - SLIDER_PADDING * 2, 1);
|
||||
const clampedX = Math.min(Math.max(event.clientX - rect.left - SLIDER_PADDING, 0), effectiveWidth);
|
||||
const ratio = clampedX / effectiveWidth;
|
||||
const viewDuration = viewEndTime.value - viewStartTime.value;
|
||||
const targetTime = Math.floor(viewStartTime.value + ratio * viewDuration);
|
||||
emit('seek', targetTime);
|
||||
// 点击发生后,立即更新所选小时,保持左侧小时选择与视窗一致
|
||||
const clickedHour = Math.floor(targetTime / HOUR_IN_MS);
|
||||
const maxHour = Math.max(Math.ceil(props.totalDuration / HOUR_IN_MS) - 1, 0);
|
||||
const clampedHour = Math.min(Math.max(clickedHour, 0), maxHour);
|
||||
if (clampedHour !== selectedHour.value) {
|
||||
selectedHour.value = clampedHour;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -184,7 +161,7 @@ const handlePlayPause = () => {
|
||||
|
||||
<!-- Hour Selector -->
|
||||
<a-col>
|
||||
<a-select v-model:value="selectedHour" style="width: 150px" :options="hourOptions" />
|
||||
<a-select :value="selectedHour" style="width: 150px" :options="hourOptions" @change="handleHourChange" />
|
||||
</a-col>
|
||||
|
||||
<!-- Timeline Slider -->
|
||||
@ -202,13 +179,6 @@ const handlePlayPause = () => {
|
||||
<span v-if="tick.label" class="tick-label">{{ tick.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<a-slider
|
||||
v-model:value="sliderValue"
|
||||
:min="viewStartTime"
|
||||
:max="viewEndTime"
|
||||
:tip-formatter="formatTime as any"
|
||||
:tooltip-open="false"
|
||||
/>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@ -244,7 +214,20 @@ const handlePlayPause = () => {
|
||||
position: relative;
|
||||
padding-top: 20px; // Space for tick labels
|
||||
margin-bottom: 5px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.playhead-marker {
|
||||
@ -300,5 +283,8 @@ body[data-theme='dark'] {
|
||||
.tick-label {
|
||||
color: #aaa;
|
||||
}
|
||||
.timeline-container::after {
|
||||
background-color: #434343;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -13,6 +13,11 @@ type PlaybackMessage = {
|
||||
data: any;
|
||||
};
|
||||
|
||||
type PlaybackSceneData = {
|
||||
json: string;
|
||||
totalDuration: number;
|
||||
};
|
||||
|
||||
// Hook's return type definition
|
||||
export interface UsePlaybackWebSocketReturn {
|
||||
// Connection status
|
||||
@ -62,11 +67,10 @@ export function usePlaybackWebSocket(editorService: ShallowRef<EditorService | u
|
||||
});
|
||||
client.value = wsInstance;
|
||||
|
||||
wsInstance.onopen = () => {
|
||||
isConnected.value = true;
|
||||
message.success('回放连接已建立');
|
||||
startRenderLoop();
|
||||
};
|
||||
// [关键修复] ws.create() 返回时连接已建立,直接执行连接成功逻辑
|
||||
isConnected.value = true;
|
||||
message.success('回放连接已建立');
|
||||
startRenderLoop();
|
||||
|
||||
wsInstance.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data) as PlaybackMessage;
|
||||
@ -76,8 +80,9 @@ export function usePlaybackWebSocket(editorService: ShallowRef<EditorService | u
|
||||
}
|
||||
|
||||
if (msg.type === 'SCENE') {
|
||||
// For seamless scene switching, we only update the scene JSON.
|
||||
// Time and playback state should continue uninterrupted.
|
||||
sceneJson.value = msg.data;
|
||||
latestRobotData.clear();
|
||||
} else if (msg.type === 'AMR') {
|
||||
(msg.data as RobotRealtimeInfo[]).forEach(robot => {
|
||||
latestRobotData.set(robot.id, robot);
|
||||
@ -120,6 +125,9 @@ export function usePlaybackWebSocket(editorService: ShallowRef<EditorService | u
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
// 双重保险:在播放前,强制发送一次SEEK指令,确保后端从正确的时间点开始。
|
||||
// 这可以防止因后端状态不同步或时序问题导致的播放位置错乱。
|
||||
sendCommand(`SEEK:${currentTime.value}`);
|
||||
sendCommand('PLAY');
|
||||
isPlaying.value = true;
|
||||
};
|
||||
@ -138,11 +146,13 @@ export function usePlaybackWebSocket(editorService: ShallowRef<EditorService | u
|
||||
};
|
||||
|
||||
const batchUpdateRobots = (updates: Array<{ id: string; data: RobotRealtimeInfo }>) => {
|
||||
|
||||
const editor = editorService.value;
|
||||
if (!editor || updates.length === 0) return;
|
||||
|
||||
updates.forEach(({ id, data }) => {
|
||||
if (editor.checkRobotById(id)) {
|
||||
const robotExists = editor.checkRobotById(id);
|
||||
if (robotExists) {
|
||||
const { x, y, angle, ...rest } = data;
|
||||
editor.updateRobot(id, rest);
|
||||
editor.setValue({
|
||||
@ -157,22 +167,28 @@ export function usePlaybackWebSocket(editorService: ShallowRef<EditorService | u
|
||||
};
|
||||
|
||||
const renderLoop = () => {
|
||||
const updates: Array<{ id: string; data: RobotRealtimeInfo }> = [];
|
||||
for (const [id, data] of latestRobotData.entries()) {
|
||||
updates.push({ id, data });
|
||||
try {
|
||||
const updates: Array<{ id: string; data: RobotRealtimeInfo }> = [];
|
||||
for (const [id, data] of latestRobotData.entries()) {
|
||||
updates.push({ id, data });
|
||||
}
|
||||
latestRobotData.clear();
|
||||
|
||||
if (updates.length > 0) {
|
||||
batchUpdateRobots(updates);
|
||||
}
|
||||
|
||||
// if (isPlaying.value && currentTime.value >= totalDuration.value && totalDuration.value > 0) {
|
||||
// pause();
|
||||
// currentTime.value = totalDuration.value;
|
||||
// }
|
||||
|
||||
editorService.value?.render();
|
||||
} catch (error) {
|
||||
console.error('[Playback] Error in renderLoop:', error);
|
||||
stopRenderLoop(); // Stop the loop on error to prevent flooding the console
|
||||
return; // Exit the function
|
||||
}
|
||||
latestRobotData.clear();
|
||||
|
||||
if (updates.length > 0) {
|
||||
batchUpdateRobots(updates);
|
||||
}
|
||||
|
||||
// if (isPlaying.value && currentTime.value >= totalDuration.value && totalDuration.value > 0) {
|
||||
// pause();
|
||||
// currentTime.value = totalDuration.value;
|
||||
// }
|
||||
|
||||
editorService.value?.render();
|
||||
animationFrameId = requestAnimationFrame(renderLoop);
|
||||
};
|
||||
|
||||
|
@ -42,6 +42,7 @@ export function useTimelineTicks(viewStartTime: Ref<number>, viewEndTime: Ref<nu
|
||||
|
||||
result.push({
|
||||
position: `${(relativeTime / viewDuration) * 100}%`,
|
||||
// 使用绝对时间来展示刻度标签,确保其在切换小时后能正确反映当前时间
|
||||
label: isMajor ? formatLabelTime(time) : null,
|
||||
isMajor,
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { LockState } from '@meta2d/core';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { computed, onMounted, onUnmounted, provide, ref, type ShallowRef, shallowRef, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
@ -97,9 +97,30 @@ const handleDateChange = (date: Dayjs) => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(playback.sceneJson, (newJson) => {
|
||||
const handleSeek = (relativeTime: number) => {
|
||||
if (selectedDate.value) {
|
||||
const absoluteTimestamp = relativeTime + selectedDate.value.startOf('day').valueOf();
|
||||
// 乐观更新,立即将前端时间状态同步到用户选择的时间点
|
||||
playback.currentTime.value = absoluteTimestamp;
|
||||
playback.seek(absoluteTimestamp);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听后台推送的绝对时间戳,如果跨天,则自动更新UI上的日期选择器
|
||||
watch(playback.currentTime, (newTimestamp) => {
|
||||
if (newTimestamp > 0 && selectedDate.value) {
|
||||
const newDate = dayjs(newTimestamp);
|
||||
if (!newDate.isSame(selectedDate.value, 'day')) {
|
||||
selectedDate.value = newDate;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(playback.sceneJson, async (newJson) => {
|
||||
if (newJson) {
|
||||
editor.value?.load(newJson);
|
||||
await editor.value?.load(newJson);
|
||||
// [关键修复] 场景加载后,立即重新初始化机器人
|
||||
await editor.value?.initRobots();
|
||||
}
|
||||
});
|
||||
|
||||
@ -660,7 +681,7 @@ const handleGlobalKeydown = (event: KeyboardEvent) => {
|
||||
:total-duration="playback.totalDuration.value"
|
||||
@play="playback.play"
|
||||
@pause="playback.pause"
|
||||
@seek="(time) => playback.seek(time + (selectedDate?.startOf('day').valueOf() ?? 0))"
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
Loading…
x
Reference in New Issue
Block a user