253 lines
6.5 KiB
Vue
253 lines
6.5 KiB
Vue
<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>
|