feat: bind/unbind by id, video streaming proxy, unmute default, library refresh, hamburger click-outside
- Core API: bind/unbind accept id (integer PK) when face_id is null - Tauri proxy: stream video responses instead of buffering for seek bar - VideoPlayer: remove muted attribute, unmute by default - LibraryView: call invalidateFiles() before ensureFiles() on register/unregister - PeopleView: close sort panel on click-outside, not just hamburger toggle - bind_face: prefer face_id, fallback to id when face_id is null
This commit is contained in:
@@ -1,46 +1,71 @@
|
||||
<template>
|
||||
<div v-if="visible" class="video-player-modal" @click.self="close">
|
||||
<div class="video-player-box">
|
||||
<button class="close-btn" @click="close">×</button>
|
||||
|
||||
<div v-if="videoLoading" class="video-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div v-if="visible" class="ms-modal-overlay show" @click.self="close">
|
||||
<div class="ms-modal ms-modal-video">
|
||||
<div class="ms-modal-video-header">
|
||||
<h3 class="ms-modal-video-title">{{ title }}</h3>
|
||||
<span class="ms-video-seg-info">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||
<button class="ms-modal-video-close" @click="close">×</button>
|
||||
</div>
|
||||
|
||||
<div v-if="videoError" class="ms-video-loading">
|
||||
<p style="color:#f44336">{{ videoError }}</p>
|
||||
</div>
|
||||
<div v-else-if="videoLoading" class="ms-video-loading">
|
||||
<div class="ms-video-loading-spinner"></div>
|
||||
<p>Loading video...</p>
|
||||
</div>
|
||||
|
||||
<video v-show="!videoLoading" ref="videoEl" class="video" controls autoplay @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate">
|
||||
<source :src="videoSrc" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<div class="video-info">
|
||||
<h3>{{ title }}</h3>
|
||||
<div class="time-display">
|
||||
<span>{{ formatTime(currentTime) }}</span>
|
||||
<span class="separator">/</span>
|
||||
<span>{{ formatTime(duration) }}</span>
|
||||
<span v-if="hasRange" class="range">({{ formatTime(rangeStart) }} - {{ formatTime(rangeEnd) }})</span>
|
||||
|
||||
<video v-if="videoSrc && !videoLoading" ref="videoEl" :src="videoSrc" class="video" controls autoplay playsinline @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate" @error="onVideoError"></video>
|
||||
|
||||
<!-- Timeline bar (all raw traces) -->
|
||||
<div class="ms-video-timeline-wrap" v-if="!simple && allTraces.length && !videoLoading">
|
||||
<div class="ms-video-tl-bar">
|
||||
<div
|
||||
v-for="(dot, i) in timelineMarkers"
|
||||
:key="i"
|
||||
class="ms-video-tl-dot"
|
||||
:class="{ active: i === currentTraceIdx }"
|
||||
:style="{ left: dot.pct + '%', width: dot.w + '%' }"
|
||||
@click="loadTrace(dot.idx)"
|
||||
>
|
||||
<span class="ms-video-tl-tip">{{ formatTime(dot.start) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-video-tl-labels">
|
||||
<span>{{ formatTime(tlStart) }}</span>
|
||||
<span>{{ formatTime(tlEnd) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasRange && !videoLoading" class="segment-controls">
|
||||
<button @click="seekToStart" class="seg-btn">⏮ 跳到起點</button>
|
||||
<button @click="togglePlay" class="seg-btn">{{ isPlaying ? '⏸ 暫停' : '▶ 播放' }}</button>
|
||||
<button @click="seekToEnd" class="seg-btn">跳到結尾 ⏭</button>
|
||||
|
||||
<div class="ms-video-nav" v-if="!videoLoading">
|
||||
<div v-if="!simple && allTraces.length" style="display:flex;align-items:center;gap:10px;flex:1;">
|
||||
<button class="ms-fm-btn ms-video-nav-btn" @click="prevTrace" :disabled="currentTraceIdx <= 0">← 上一個</button>
|
||||
<span class="ms-video-seg-info2">{{ currentTraceIdx + 1 }} / {{ allTraces.length }}</span>
|
||||
<button class="ms-fm-btn ms-video-nav-btn" @click="nextTrace" :disabled="currentTraceIdx >= allTraces.length - 1">下一個 →</button>
|
||||
</div>
|
||||
<span v-else class="ms-video-seg-info">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { apiCall } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
fileUuid: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
allTraces?: any[]
|
||||
initialTraceIdx?: number
|
||||
title?: string
|
||||
}>()
|
||||
simple?: boolean
|
||||
}>(), {
|
||||
allTraces: () => [],
|
||||
initialTraceIdx: 0,
|
||||
simple: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
@@ -51,13 +76,81 @@ const duration = ref(0)
|
||||
const isPlaying = ref(false)
|
||||
const videoSrc = ref('')
|
||||
const videoLoading = ref(true)
|
||||
const videoError = ref('')
|
||||
const currentTraceIdx = ref(props.initialTraceIdx ?? 0)
|
||||
const curFileUuid = ref(props.fileUuid)
|
||||
|
||||
const hasRange = computed(() => {
|
||||
return props.startTime !== undefined && props.endTime !== undefined
|
||||
// Timeline computation
|
||||
const tlStart = computed(() => {
|
||||
if (!props.allTraces.length) return 0
|
||||
return props.allTraces[0].first_sec || props.allTraces[0].start_time || 0
|
||||
})
|
||||
const tlEnd = computed(() => {
|
||||
if (!props.allTraces.length) return 0
|
||||
const last = props.allTraces[props.allTraces.length - 1]
|
||||
return last.last_sec || last.end_time || 0
|
||||
})
|
||||
const tlRange = computed(() => Math.max(tlEnd.value - tlStart.value, 1))
|
||||
|
||||
const timelineMarkers = computed(() => {
|
||||
return props.allTraces.map((t: any, i: number) => {
|
||||
const st = t.first_sec || t.start_time || 0
|
||||
const en = t.last_sec || t.end_time || 0
|
||||
return {
|
||||
idx: i,
|
||||
start: st,
|
||||
end: en,
|
||||
pct: ((st - tlStart.value) / tlRange.value) * 100,
|
||||
w: Math.max(0.5, ((en - st) / tlRange.value) * 100),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const rangeStart = computed(() => props.startTime ?? 0)
|
||||
const rangeEnd = computed(() => props.endTime ?? 0)
|
||||
async function loadTrace(idx: number) {
|
||||
const traces = props.allTraces
|
||||
if (idx < 0 || idx >= traces.length) return
|
||||
currentTraceIdx.value = idx
|
||||
const t = traces[idx]
|
||||
const fu = t.file_uuid || ''
|
||||
if (!fu || fu === 'undefined') return
|
||||
|
||||
const st = t.first_sec || t.start_time || 0
|
||||
const en = Math.max(t.last_sec || t.end_time || 0, st + 0.1)
|
||||
|
||||
curFileUuid.value = fu
|
||||
// Preserve volume across reloads
|
||||
const prevVolume = videoEl.value?.volume ?? 1
|
||||
videoLoading.value = true
|
||||
videoError.value = ''
|
||||
videoSrc.value = ''
|
||||
try {
|
||||
const data = await apiCall('get_video_stream', {
|
||||
uuid: fu,
|
||||
startTime: st,
|
||||
endTime: en,
|
||||
startFrame: null,
|
||||
endFrame: null,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
} else {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
|
||||
videoSrc.value = URL.createObjectURL(blob)
|
||||
}
|
||||
await nextTick()
|
||||
} catch (e: any) {
|
||||
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
|
||||
return
|
||||
} finally {
|
||||
videoLoading.value = false
|
||||
await nextTick()
|
||||
const el = videoEl.value
|
||||
if (el) {
|
||||
el.volume = prevVolume
|
||||
el.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
@@ -65,51 +158,46 @@ function close() {
|
||||
}
|
||||
|
||||
function onLoaded() {
|
||||
if (videoEl.value && props.startTime) {
|
||||
videoEl.value.currentTime = props.startTime
|
||||
videoEl.value.play()
|
||||
isPlaying.value = true
|
||||
const el = videoEl.value
|
||||
if (!el) return
|
||||
const t = props.allTraces[currentTraceIdx.value]
|
||||
if (t) {
|
||||
const off = props.startTime ?? 0
|
||||
el.currentTime = (t.first_sec || t.start_time || 0) - off
|
||||
} else if (props.startTime) {
|
||||
el.currentTime = 0
|
||||
}
|
||||
el.play().catch(() => {})
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
if (videoEl.value) {
|
||||
currentTime.value = videoEl.value.currentTime
|
||||
duration.value = videoEl.value.duration || 0
|
||||
|
||||
// 如果播放到 range end,暫停
|
||||
if (hasRange.value && videoEl.value.currentTime >= (props.endTime ?? 0)) {
|
||||
videoEl.value.pause()
|
||||
isPlaying.value = false
|
||||
}
|
||||
if (!videoEl.value) return
|
||||
currentTime.value = videoEl.value.currentTime
|
||||
duration.value = videoEl.value.duration || 0
|
||||
}
|
||||
|
||||
function onVideoError() {
|
||||
const el = videoEl.value
|
||||
if (el) {
|
||||
const msg = el.error?.message || 'unknown error'
|
||||
videoError.value = `Video playback error: ${msg}`
|
||||
} else {
|
||||
videoError.value = 'Video playback error'
|
||||
}
|
||||
}
|
||||
|
||||
function seekToStart() {
|
||||
if (videoEl.value && props.startTime) {
|
||||
videoEl.value.currentTime = props.startTime
|
||||
videoEl.value.play()
|
||||
isPlaying.value = true
|
||||
}
|
||||
function prevTrace() {
|
||||
if (currentTraceIdx.value > 0) loadTrace(currentTraceIdx.value - 1)
|
||||
}
|
||||
|
||||
function seekToEnd() {
|
||||
if (videoEl.value && props.endTime) {
|
||||
videoEl.value.currentTime = props.endTime - 1
|
||||
videoEl.value.play()
|
||||
isPlaying.value = true
|
||||
}
|
||||
function nextTrace() {
|
||||
if (currentTraceIdx.value < props.allTraces.length - 1) loadTrace(currentTraceIdx.value + 1)
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (videoEl.value) {
|
||||
if (videoEl.value.paused) {
|
||||
videoEl.value.play()
|
||||
isPlaying.value = true
|
||||
} else {
|
||||
videoEl.value.pause()
|
||||
isPlaying.value = false
|
||||
}
|
||||
if (videoEl.value.paused) { videoEl.value.play(); isPlaying.value = true }
|
||||
else { videoEl.value.pause(); isPlaying.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,38 +208,47 @@ function formatTime(sec: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 鍵盤快捷鍵
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!visible.value) return
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
close()
|
||||
break
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
togglePlay()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
if (videoEl.value) videoEl.value.currentTime = Math.max(0, videoEl.value.currentTime - 5)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (videoEl.value) videoEl.value.currentTime = Math.min(duration.value, videoEl.value.currentTime + 5)
|
||||
break
|
||||
case 'Escape': close(); break
|
||||
case ' ': e.preventDefault(); togglePlay(); break
|
||||
case 'ArrowLeft': if (videoEl.value) videoEl.value.currentTime = Math.max(0, videoEl.value.currentTime - 5); break
|
||||
case 'ArrowRight': if (videoEl.value) videoEl.value.currentTime = Math.min(duration.value, videoEl.value.currentTime + 5); break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
if (!props.fileUuid || props.fileUuid === 'undefined') {
|
||||
videoError.value = 'No file associated with this result'
|
||||
videoLoading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
videoSrc.value = await invoke('get_video_stream', {
|
||||
const st = props.startTime ?? 0
|
||||
let et = props.endTime ?? st + 1
|
||||
if (et <= st || et - st < 1) et = st + 1
|
||||
const data = await apiCall('get_video_stream', {
|
||||
uuid: props.fileUuid,
|
||||
startTime: props.startTime ?? 0,
|
||||
endTime: props.endTime ?? 99999
|
||||
startTime: st,
|
||||
endTime: et,
|
||||
startFrame: null,
|
||||
endFrame: null,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
} else {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
|
||||
videoSrc.value = URL.createObjectURL(blob)
|
||||
}
|
||||
await nextTick()
|
||||
} catch (e: any) {
|
||||
console.error('Video stream failed:', e)
|
||||
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
|
||||
} finally {
|
||||
videoLoading.value = false
|
||||
await nextTick()
|
||||
videoEl.value?.load()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -161,52 +258,29 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-player-modal {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 2000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
.video-player-box {
|
||||
background: #111;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
max-width: 90vw;
|
||||
position: relative;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
.close-btn {
|
||||
position: absolute; top: -12px; right: -12px;
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: #fff; border: none; font-size: 1.5rem;
|
||||
cursor: pointer; z-index: 10;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.close-btn:hover { background: #f3f4f6; }
|
||||
.video {
|
||||
max-width: 80vw; max-height: 60vh;
|
||||
border-radius: 8px; display: block;
|
||||
}
|
||||
.video-info {
|
||||
color: #fff; margin-top: 16px; text-align: center;
|
||||
}
|
||||
.video-info h3 { font-size: 1rem; margin-bottom: 8px; font-weight: 500; }
|
||||
.time-display { font-size: 0.85rem; color: #9ca3af; display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.range { color: #4f46e5; }
|
||||
.segment-controls {
|
||||
display: flex; justify-content: center; gap: 12px; margin-top: 12px;
|
||||
}
|
||||
.seg-btn {
|
||||
padding: 8px 16px; background: #1f2937; color: #fff;
|
||||
border: 1px solid #374151; border-radius: 8px;
|
||||
cursor: pointer; font-size: 0.85rem; transition: background 0.15s;
|
||||
}
|
||||
.seg-btn:hover { background: #374151; }
|
||||
.video-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: #9ca3af; }
|
||||
.loading-spinner { width: 40px; height: 40px; border: 4px solid #374151; border-top-color: #4f46e5; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 12px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
.ms-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.3); z-index: 999; display: grid; place-items: center; }
|
||||
.ms-modal { background: #fff; border-radius: 20px; max-width: 380px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,.18); }
|
||||
.ms-modal-video { max-width: 720px; width: 95%; padding: 20px 24px 24px; text-align: left; background: #1a1a1a; }
|
||||
.ms-modal-video-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.ms-modal-video-title { font-size: 14px; font-weight: 600; color: #e8eaed; margin: 0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ms-modal-video-close { border: none; background: transparent; font-size: 18px; color: #9aa0a6; cursor: pointer; padding: 0; line-height: 1; flex-shrink: 0; }
|
||||
.ms-modal-video-close:hover { color: #fff; }
|
||||
.video { max-width: 100%; max-height: 60vh; border-radius: 8px; display: block; width: 100%; }
|
||||
.ms-video-loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 20px; color: #5f6368; font-size: 13px; }
|
||||
.ms-video-loading-spinner { width: 18px; height: 18px; border: 2px solid #e8eaed; border-top-color: #1a56db; border-radius: 50%; animation: ms-spin .7s linear infinite; }
|
||||
@keyframes ms-spin { to { transform: rotate(360deg); } }
|
||||
.ms-video-timeline-wrap { padding: 14px 0 6px; }
|
||||
.ms-video-tl-bar { position: relative; width: 100%; height: 4px; background: rgba(255,255,255,0.15); border-radius: 999px; margin-bottom: 8px; overflow: visible; }
|
||||
.ms-video-tl-dot { position: absolute; top: 50%; transform: translate(0, -50%); height: 8px; border-radius: 4px; background: #9aa0a6; cursor: pointer; transition: height .12s, background .12s; z-index: 2; }
|
||||
.ms-video-tl-dot:hover { height: 12px; background: #fff; }
|
||||
.ms-video-tl-dot.active { background: #fff; box-shadow: 0 0 0 2.5px rgba(255,255,255,0.35); }
|
||||
.ms-video-tl-labels { display: flex; justify-content: space-between; font-size: 10px; color: rgba(255,255,255,0.45); }
|
||||
.ms-video-tl-tip { display: none; position: absolute; bottom: 16px; transform: translateX(-50%); background: rgba(0,0,0,.75); color: #fff; font-size: 10px; padding: 2px 7px; border-radius: 4px; white-space: nowrap; pointer-events: none; z-index: 10; }
|
||||
.ms-video-tl-dot:hover .ms-video-tl-tip { display: block; }
|
||||
.ms-video-nav { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; gap: 10px; }
|
||||
.ms-video-seg-info { font-size: 12px; color: rgba(255,255,255,0.6); flex-shrink: 0; }
|
||||
.ms-video-seg-info2 { font-size: 12px; color: #9aa0a6; flex: 1; text-align: center; }
|
||||
.ms-video-nav-btn { background: #2c2c2c; border-color: #3c3c3c; color: #e8eaed; }
|
||||
.ms-video-nav-btn:hover { background: #3c3c3c; }
|
||||
.ms-video-nav-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user