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:
2026-06-18 22:24:03 +08:00
parent 97af4da331
commit 7855983dc1
19 changed files with 5321 additions and 793 deletions

View File

@@ -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">&times;</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">&larr; 上一個</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">下一個 &rarr;</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>