web-map/src/components/PlaybackController.vue

253 lines
6.5 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
import { computed, ref, watch } from 'vue';
import { useTimelineTicks } from '../hooks/useTimelineTicks';
const props = defineProps<{
isPlaying: boolean;
currentTime: number; // in milliseconds
totalDuration: number; // in milliseconds
}>();
const emit = defineEmits<{
(e: 'play'): void;
(e: 'pause'): void;
(e: 'seek', time: number): void;
}>();
// --- Constants ---
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));
// --- Computed View Range ---
const viewStartTime = computed(() => selectedHour.value * HOUR_IN_MS);
const viewEndTime = computed(() => viewStartTime.value + HOUR_IN_MS);
// --- Ticks Logic ---
const { ticks } = useTimelineTicks(viewStartTime, viewEndTime);
// --- Sync internal time with external prop ---
watch(
() => props.currentTime,
(newValue) => {
internalCurrentTime.value = newValue;
const newHour = Math.floor(newValue / HOUR_IN_MS);
if (newHour !== selectedHour.value) {
selectedHour.value = newHour;
}
},
);
// --- Computed properties for display and binding ---
const formatTime = (ms: number): string => {
if (isNaN(ms) || ms < 0) return '00:00:00';
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
};
const currentTimeFormatted = computed(() => formatTime(internalCurrentTime.value));
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}%`;
});
const hourOptions = computed(() => {
const numHours = Math.ceil(props.totalDuration / HOUR_IN_MS);
return Array.from({ length: numHours }, (_, i) => {
const start = formatTime(i * HOUR_IN_MS).substring(0, 5);
const end = formatTime((i + 1) * HOUR_IN_MS).substring(0, 5);
return {
value: i,
label: `${start} - ${end}`,
};
});
});
// --- Event Handlers ---
const timelineContainerRef = ref<HTMLElement | null>(null);
const handleTimelineClick = (event: MouseEvent) => {
if (!timelineContainerRef.value || !props.totalDuration) return;
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 handlePlayPause = () => {
if (props.isPlaying) {
emit('pause');
} else {
emit('play');
}
};
</script>
<template>
<div class="playback-controller">
<a-row align="middle" :gutter="16">
<!-- Play/Pause Button -->
<a-col>
<a-button shape="circle" @click="handlePlayPause">
<template #icon>
<PauseOutlined v-if="isPlaying" />
<CaretRightOutlined v-else />
</template>
</a-button>
</a-col>
<!-- Timestamp -->
<a-col>
<a-typography-text class="time-display">
{{ currentTimeFormatted }} / {{ totalDurationFormatted }}
</a-typography-text>
</a-col>
<!-- Hour Selector -->
<a-col>
<a-select v-model:value="selectedHour" style="width: 150px" :options="hourOptions" />
</a-col>
<!-- Timeline Slider -->
<a-col flex="1">
<div class="timeline-container" ref="timelineContainerRef" @click="handleTimelineClick">
<div class="timeline-ticks">
<div class="playhead-marker" :style="{ left: playheadPosition }"></div>
<span
v-for="(tick, index) in ticks"
:key="index"
class="tick"
:class="{ 'tick-major': tick.isMajor }"
:style="{ left: tick.position }"
>
<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>
</div>
</template>
<style scoped lang="scss">
.playback-controller {
position: fixed;
bottom: 0;
left: 0;
width: calc(100% - 48px);
padding: 16px 24px;
background-color: rgba(255, 255, 255, 0.9);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
backdrop-filter: blur(5px);
border-top: 1px solid #f0f0f0;
body[data-theme='dark'] & {
background-color: rgba(20, 20, 20, 0.9);
border-top: 1px solid #303030;
}
}
.time-display {
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
min-width: 180px; // Prevents layout shift
}
.timeline-container {
position: relative;
padding-top: 20px; // Space for tick labels
margin-bottom: 5px;
cursor: pointer;
}
.playhead-marker {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: red;
pointer-events: none;
z-index: 5;
transition: left 0.1s linear;
}
.timeline-ticks {
position: absolute;
top: 0;
left: 8px;
right: 8px;
height: 20px;
pointer-events: none;
}
.tick {
position: absolute;
bottom: 0;
width: 1px;
height: 6px;
background-color: #a0a0a0;
&.tick-major {
height: 10px;
background-color: #333;
}
}
.tick-label {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #555;
white-space: nowrap;
}
body[data-theme='dark'] {
.tick {
background-color: #777;
&.tick-major {
background-color: #ddd;
}
}
.tick-label {
color: #aaa;
}
}
</style>