feat: 在播放控制器中添加时间轴逻辑和小时选择器,优化播放时间同步和界面交互
This commit is contained in:
parent
f4e6424104
commit
e7b6dc736f
@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
|
import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
|
||||||
import { computed } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useTimelineTicks } from '../hooks/useTimelineTicks';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@ -14,11 +16,36 @@ const emit = defineEmits<{
|
|||||||
(e: 'seek', time: number): void;
|
(e: 'seek', time: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// --- Computed properties for display ---
|
// --- 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 => {
|
const formatTime = (ms: number): string => {
|
||||||
if (isNaN(ms) || ms < 0) {
|
if (isNaN(ms) || ms < 0) return '00:00:00';
|
||||||
return '00:00:00';
|
|
||||||
}
|
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
@ -26,16 +53,54 @@ const formatTime = (ms: number): string => {
|
|||||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentTimeFormatted = computed(() => formatTime(props.currentTime));
|
const currentTimeFormatted = computed(() => formatTime(internalCurrentTime.value));
|
||||||
const totalDurationFormatted = computed(() => formatTime(props.totalDuration));
|
const totalDurationFormatted = computed(() => formatTime(props.totalDuration));
|
||||||
|
|
||||||
const sliderValue = computed({
|
const sliderValue = computed({
|
||||||
get: () => props.currentTime,
|
get: () => internalCurrentTime.value,
|
||||||
set: (val: number) => {
|
set: (val: number) => {
|
||||||
|
internalCurrentTime.value = val;
|
||||||
emit('seek', 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 = () => {
|
const handlePlayPause = () => {
|
||||||
if (props.isPlaying) {
|
if (props.isPlaying) {
|
||||||
emit('pause');
|
emit('pause');
|
||||||
@ -65,14 +130,34 @@ const handlePlayPause = () => {
|
|||||||
</a-typography-text>
|
</a-typography-text>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
|
<!-- Hour Selector -->
|
||||||
|
<a-col>
|
||||||
|
<a-select v-model:value="selectedHour" style="width: 150px" :options="hourOptions" />
|
||||||
|
</a-col>
|
||||||
|
|
||||||
<!-- Timeline Slider -->
|
<!-- Timeline Slider -->
|
||||||
<a-col flex="1">
|
<a-col flex="1">
|
||||||
<a-slider
|
<div class="timeline-container" ref="timelineContainerRef" @click="handleTimelineClick">
|
||||||
v-model:value="sliderValue"
|
<div class="timeline-ticks">
|
||||||
:max="totalDuration"
|
<div class="playhead-marker" :style="{ left: playheadPosition }"></div>
|
||||||
:tip-formatter="formatTime as any"
|
<span
|
||||||
:tooltip-open="false"
|
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-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</div>
|
</div>
|
||||||
@ -102,4 +187,66 @@ const handlePlayPause = () => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
min-width: 180px; // Prevents layout shift
|
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>
|
</style>
|
||||||
|
|||||||
26
src/hooks/useTimeFormat.ts
Normal file
26
src/hooks/useTimeFormat.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// src/hooks/useTimeFormat.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats seconds into a HH:mm:ss time string.
|
||||||
|
* @param seconds The total seconds.
|
||||||
|
* @returns A string in HH:mm:ss format.
|
||||||
|
*/
|
||||||
|
export function useTimeFormat() {
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
if (isNaN(seconds) || seconds < 0) {
|
||||||
|
return '00:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
56
src/hooks/useTimelineTicks.ts
Normal file
56
src/hooks/useTimelineTicks.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { computed, type Ref } from 'vue';
|
||||||
|
|
||||||
|
export interface Tick {
|
||||||
|
position: string; // Percentage position within the view
|
||||||
|
label: string | null;
|
||||||
|
isMajor: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats time as hh:mm for labels
|
||||||
|
const formatLabelTime = (ms: number): string => {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||||
|
return `${pad(hours)}:${pad(minutes)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTimelineTicks(viewStartTime: Ref<number>, viewEndTime: Ref<number>) {
|
||||||
|
const ticks = computed<Tick[]>(() => {
|
||||||
|
const startTime = viewStartTime.value;
|
||||||
|
const endTime = viewEndTime.value;
|
||||||
|
const viewDuration = endTime - startTime;
|
||||||
|
|
||||||
|
if (isNaN(startTime) || isNaN(endTime) || viewDuration <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Tick[] = [];
|
||||||
|
|
||||||
|
// For a 1-hour view, we use fixed intervals
|
||||||
|
const majorTickInterval = 10 * 60 * 1000; // Every 10 minutes for labels
|
||||||
|
const minorTickInterval = 1 * 60 * 1000; // Every 1 minute for small ticks
|
||||||
|
|
||||||
|
for (let time = startTime; time <= endTime; time += minorTickInterval) {
|
||||||
|
// Ensure we only create ticks at exact minute marks relative to the start of the day
|
||||||
|
if (time % minorTickInterval !== 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeTime = time - startTime;
|
||||||
|
const isMajor = time % majorTickInterval === 0;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
position: `${(relativeTime / viewDuration) * 100}%`,
|
||||||
|
label: isMajor ? formatLabelTime(time) : null,
|
||||||
|
isMajor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ticks,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user