264 lines
5.5 KiB
Vue
264 lines
5.5 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
|
|
const props = defineProps<{
|
|
frames: Array<{ seq: number; timestamp: number; jpeg_b64: string }>
|
|
currentIndex: number
|
|
selectionStart: number
|
|
selectionEnd: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'frame-click': [index: number]
|
|
'selection-change': [start: number, end: number]
|
|
}>()
|
|
|
|
const stripEl = ref<HTMLElement | null>(null)
|
|
|
|
// --- Drag handle logic ---
|
|
type DragTarget = 'start' | 'end' | null
|
|
const dragging = ref<DragTarget>(null)
|
|
|
|
function onHandleMousedown(target: DragTarget, e: MouseEvent) {
|
|
dragging.value = target
|
|
e.preventDefault()
|
|
}
|
|
|
|
function onMousemove(e: MouseEvent) {
|
|
if (!dragging.value || !stripEl.value) return
|
|
const rect = stripEl.value.getBoundingClientRect()
|
|
const x = e.clientX - rect.left
|
|
const ratio = Math.max(0, Math.min(1, x / rect.width))
|
|
const idx = Math.round(ratio * (props.frames.length - 1))
|
|
|
|
if (dragging.value === 'start') {
|
|
const newStart = Math.min(idx, props.selectionEnd)
|
|
emit('selection-change', newStart, props.selectionEnd)
|
|
} else {
|
|
const newEnd = Math.max(idx, props.selectionStart)
|
|
emit('selection-change', props.selectionStart, newEnd)
|
|
}
|
|
}
|
|
|
|
function onMouseup() {
|
|
dragging.value = null
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('mousemove', onMousemove)
|
|
window.addEventListener('mouseup', onMouseup)
|
|
})
|
|
onUnmounted(() => {
|
|
window.removeEventListener('mousemove', onMousemove)
|
|
window.removeEventListener('mouseup', onMouseup)
|
|
})
|
|
|
|
// Handle positions as % of strip width
|
|
const startPct = computed(() => {
|
|
if (props.frames.length <= 1) return 0
|
|
return (props.selectionStart / (props.frames.length - 1)) * 100
|
|
})
|
|
const endPct = computed(() => {
|
|
if (props.frames.length <= 1) return 100
|
|
return (props.selectionEnd / (props.frames.length - 1)) * 100
|
|
})
|
|
|
|
function isInSelection(idx: number) {
|
|
return idx >= props.selectionStart && idx <= props.selectionEnd
|
|
}
|
|
|
|
// --- Scrub slider ---
|
|
function onScrub(e: Event) {
|
|
const val = Number((e.target as HTMLInputElement).value)
|
|
emit('frame-click', val)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="frame-strip" ref="stripEl">
|
|
<!-- Thumbnails -->
|
|
<div class="thumbs">
|
|
<div
|
|
v-for="(frame, idx) in frames"
|
|
:key="frame.seq"
|
|
class="thumb"
|
|
:class="{
|
|
current: idx === currentIndex,
|
|
dimmed: !isInSelection(idx),
|
|
}"
|
|
@click="emit('frame-click', idx)"
|
|
:title="`Frame ${frame.seq} · ${frame.timestamp.toFixed(2)}s`"
|
|
>
|
|
<img :src="`data:image/jpeg;base64,${frame.jpeg_b64}`" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scrub slider -->
|
|
<div class="scrub-row">
|
|
<input
|
|
type="range"
|
|
class="scrub-slider"
|
|
:min="0"
|
|
:max="frames.length - 1"
|
|
:value="currentIndex"
|
|
@input="onScrub"
|
|
/>
|
|
<span class="scrub-label">{{ currentIndex + 1 }}/{{ frames.length }}</span>
|
|
</div>
|
|
|
|
<!-- Selection handles -->
|
|
<div class="handles">
|
|
<div
|
|
class="handle handle-start"
|
|
:style="{ left: startPct + '%' }"
|
|
@mousedown="onHandleMousedown('start', $event)"
|
|
title="Drag to set selection start"
|
|
/>
|
|
<div
|
|
class="handle handle-end"
|
|
:style="{ left: endPct + '%' }"
|
|
@mousedown="onHandleMousedown('end', $event)"
|
|
title="Drag to set selection end"
|
|
/>
|
|
<div
|
|
class="selection-range"
|
|
:style="{ left: startPct + '%', width: (endPct - startPct) + '%' }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.frame-strip {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--surface-1);
|
|
border-top: var(--panel-border);
|
|
user-select: none;
|
|
}
|
|
|
|
.thumbs {
|
|
display: flex;
|
|
flex-direction: row;
|
|
gap: 2px;
|
|
padding: 4px 6px 0;
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
}
|
|
.thumbs::-webkit-scrollbar { display: none; }
|
|
|
|
.thumb {
|
|
flex-shrink: 0;
|
|
width: 80px;
|
|
height: 50px;
|
|
border: 2px solid transparent;
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
transition: border-color 0.1s, opacity 0.15s;
|
|
}
|
|
|
|
.thumb img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
.thumb.current {
|
|
border-color: #00bcd4;
|
|
}
|
|
|
|
.thumb.dimmed {
|
|
opacity: 0.35;
|
|
}
|
|
|
|
.thumb:hover:not(.current) {
|
|
border-color: var(--surface-3);
|
|
}
|
|
|
|
/* Scrub slider row */
|
|
.scrub-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 2px 6px;
|
|
}
|
|
|
|
.scrub-slider {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
flex: 1;
|
|
height: 4px;
|
|
background: var(--surface-3);
|
|
border-radius: 2px;
|
|
outline: none;
|
|
}
|
|
|
|
.scrub-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: #00bcd4;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.scrub-slider::-moz-range-thumb {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: #00bcd4;
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
|
|
.scrub-label {
|
|
font-size: 9px;
|
|
color: var(--text-dim);
|
|
font-family: var(--font-mono);
|
|
min-width: 30px;
|
|
text-align: right;
|
|
}
|
|
|
|
/* Handle track area */
|
|
.handles {
|
|
position: relative;
|
|
height: 14px;
|
|
margin: 0 6px;
|
|
}
|
|
|
|
.selection-range {
|
|
position: absolute;
|
|
top: 5px;
|
|
height: 4px;
|
|
background: #00bcd4;
|
|
opacity: 0.35;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.handle {
|
|
position: absolute;
|
|
top: 0;
|
|
width: 10px;
|
|
height: 14px;
|
|
transform: translateX(-50%);
|
|
cursor: ew-resize;
|
|
z-index: 2;
|
|
}
|
|
|
|
.handle::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 2px;
|
|
transform: translateX(-50%);
|
|
width: 3px;
|
|
height: 10px;
|
|
background: #00bcd4;
|
|
border-radius: 2px;
|
|
}
|
|
</style>
|