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

221
src/store.ts Normal file
View File

@@ -0,0 +1,221 @@
import { ref } from 'vue'
import { apiCall } from '@/api'
import { isTauri } from './api/config'
export const filesCache = ref<any[]>([])
export const filesLoaded = ref(false)
export const peopleCache = ref<any[]>([])
export const peopleLoaded = ref(false)
export const faceCandidatesCache = ref<any[]>([])
export const faceCandidatesLoaded = ref(false)
export const thumbnailsCache = ref<Record<string, string>>({})
export const profilesCache = ref<Record<string, string>>({})
export const faceThumbsCache = ref<Record<string, string>>({})
const thumbQueue: (() => Promise<void>)[] = []
let activeThumbLoads = 0
const MAX_CONCURRENT = 16
const profileQueue: (() => Promise<void>)[] = []
let activeProfileLoads = 0
const MAX_PROFILE_CONCURRENT = 4
const loadingThumbs = new Set<string>()
const loadingProfiles = new Set<string>()
const loadingFaceThumbs = new Set<string>()
function drainThumbQueue() {
if (activeThumbLoads >= MAX_CONCURRENT || thumbQueue.length === 0) return
activeThumbLoads++
const task = thumbQueue.shift()!
task().then(() => {
activeThumbLoads--
drainThumbQueue()
}).catch(() => {
activeThumbLoads--
drainThumbQueue()
})
}
function drainProfileQueue() {
if (activeProfileLoads >= MAX_PROFILE_CONCURRENT || profileQueue.length === 0) return
activeProfileLoads++
const task = profileQueue.shift()!
task().then(() => {
activeProfileLoads--
drainProfileQueue()
}).catch(() => {
activeProfileLoads--
drainProfileQueue()
})
}
function queueThumb(fn: () => Promise<void>) {
thumbQueue.push(fn)
drainThumbQueue()
}
function queueProfile(fn: () => Promise<void>) {
profileQueue.push(fn)
drainProfileQueue()
}
let _peopleLoading = false
let _filesLoading = false
let _faceCandidatesLoading = false
export async function ensureFiles() {
if (filesLoaded.value) return
if (_filesLoading) {
await new Promise<void>(r => { const check = setInterval(() => { if (filesLoaded.value || !_filesLoading) { clearInterval(check); r() } }, 100) })
return
}
_filesLoading = true
try {
const result = await apiCall('get_files', { args: { pageSize: 500 } })
filesCache.value = Array.isArray(result) ? result : []
} catch (e) {
console.error('Failed to load files:', e)
} finally {
_filesLoading = false
filesLoaded.value = true
}
}
export async function ensurePeople() {
if (peopleLoaded.value) return
if (_peopleLoading) {
await new Promise<void>(r => { const check = setInterval(() => { if (peopleLoaded.value || !_peopleLoading) { clearInterval(check); r() } }, 100) })
return
}
_peopleLoading = true
try {
if (isTauri) {
const result: any = await apiCall('get_people', { page: 1, perPage: 100 })
peopleCache.value = Array.isArray(result) ? result : []
} else {
const allPeople: any[] = []
for (let page = 1; page <= 5; page++) {
const batch: any = await apiCall('get_people', { page, perPage: 20 })
const arr = Array.isArray(batch) ? batch : []
if (!arr.length) break
allPeople.push(...arr)
if (arr.length < 20) break
}
peopleCache.value = allPeople
}
} catch (e) {
console.error('Failed to load people:', e)
} finally {
_peopleLoading = false
peopleLoaded.value = true
}
}
export async function ensureFaceCandidates() {
if (faceCandidatesLoaded.value) return
if (_faceCandidatesLoading) {
await new Promise<void>(r => { const check = setInterval(() => { if (faceCandidatesLoaded.value || !_faceCandidatesLoading) { clearInterval(check); r() } }, 100) })
return
}
_faceCandidatesLoading = true
try {
if (isTauri) {
const fc: any = await apiCall('get_face_candidates', { page: 1, perPage: 100 })
faceCandidatesCache.value = Array.isArray(fc) ? fc : []
} else {
const all: any[] = []
for (let page = 1; page <= 5; page++) {
const batch: any = await apiCall('get_face_candidates', { page, perPage: 20 })
const arr = Array.isArray(batch) ? batch : []
if (!arr.length) break
all.push(...arr)
if (arr.length < 20) break
}
faceCandidatesCache.value = all
}
} catch (e) {
console.error('Failed to load face candidates:', e)
} finally {
_faceCandidatesLoading = false
faceCandidatesLoaded.value = true
}
}
export function loadThumbnail(uuid: string, frame = 30) {
const key = `${uuid}:${frame}`
if (!uuid || thumbnailsCache.value[key] || loadingThumbs.has(key)) return
loadingThumbs.add(key)
queueThumb(async () => {
try {
const result = await apiCall('get_thumbnail', { uuid, frame })
if (result) thumbnailsCache.value[key] = result
} catch (e) {
console.error('loadThumbnail failed:', uuid, frame, e)
} finally {
loadingThumbs.delete(key)
}
})
}
export function loadProfile(uuid: string) {
if (!uuid) { console.error('[loadProfile] called with empty uuid'); return }
if (profilesCache.value[uuid]) return
if (loadingProfiles.has(uuid)) return
loadingProfiles.add(uuid)
console.error('[loadProfile] requesting', uuid)
queueProfile(async () => {
try {
const result = await apiCall('get_identity_profile', { uuid })
console.error('[loadProfile] got result for', uuid, typeof result, result ? (result as string).substring(0, 30) : 'null')
if (result) profilesCache.value[uuid] = result
else console.error('[loadProfile] empty result for', uuid)
} catch (e) {
console.error('loadProfile failed:', uuid, e)
} finally {
loadingProfiles.delete(uuid)
}
})
}
export function loadFaceThumb(key: string, uuid: string, frame: number, bbox?: any) {
if (!uuid || faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return
loadingFaceThumbs.add(key)
queueThumb(async () => {
try {
const args: any = { uuid, frame }
if (bbox) {
args.bboxX = Math.round(bbox.x)
args.bboxY = Math.round(bbox.y)
args.bboxW = Math.round(bbox.width)
args.bboxH = Math.round(bbox.height)
}
const result = await apiCall('get_face_thumbnail', args)
if (result) faceThumbsCache.value[key] = result
} catch (e) {
console.error('loadFaceThumb failed:', key, uuid, e)
} finally {
loadingFaceThumbs.delete(key)
}
})
}
export function invalidateFiles() {
filesLoaded.value = false
filesCache.value = []
}
export function invalidatePeople() {
peopleLoaded.value = false
peopleCache.value = []
faceCandidatesLoaded.value = false
faceCandidatesCache.value = []
}
export function invalidateProfile(uuid: string) {
delete profilesCache.value[uuid]
loadingProfiles.delete(uuid)
}