Compare commits

...

6 Commits

4 changed files with 138 additions and 114 deletions

View File

@ -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>

View File

@ -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);
};

View File

@ -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,
});

View File

@ -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>