some small chenges in device ui
This commit is contained in:
@@ -26,45 +26,65 @@ const usrIDs = selectedUserIds.value
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card>
|
<Card class="flex w-full max-w-4xl mx-auto">
|
||||||
<CardHeader>
|
<CardHeader class="pb-4">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
Dashboard
|
Dashboard
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent class="flex-1 space-y-6">
|
||||||
<Label for="users">Allowed users</Label>
|
<div class="grid gap-5">
|
||||||
<AssignDevice v-model="usrIDs"></AssignDevice>
|
<div class="grid space-y-2 grid-cols-4 items-center">
|
||||||
<Button>Start recording</Button>
|
<Label for="users">Allowed users</Label>
|
||||||
<Button>Stop recording</Button>
|
<AssignDevice v-model="usrIDs" class="col-span-3 w-full"></AssignDevice>
|
||||||
<NumberField id="duration" :default-value="120" :min="30">
|
</div>
|
||||||
<Label for="duration"> Record duration in seconds</Label>
|
</div>
|
||||||
<NumberFieldContent>
|
|
||||||
<NumberFieldDecrement></NumberFieldDecrement>
|
<div class="flex space-x-4 pt-2 gap-4">
|
||||||
<NumberFieldInput></NumberFieldInput>
|
<Button>Start recording</Button>
|
||||||
<NumberFieldIncrement></NumberFieldIncrement>
|
<Button>Stop recording</Button>
|
||||||
</NumberFieldContent>
|
</div>
|
||||||
</NumberField>
|
<div class="space-y-2 gap-4">
|
||||||
<Separator>Communication settings</Separator>
|
<NumberField id="duration" :default-value="120" :min="30">
|
||||||
<NumberField id="polling" :default-value="60" :min="30">
|
<Label for="duration"> Record duration in seconds</Label>
|
||||||
<Label for="polling"> Timeout interwal in seconds </Label>
|
<NumberFieldContent>
|
||||||
<NumberFieldContent>
|
<NumberFieldDecrement></NumberFieldDecrement>
|
||||||
<NumberFieldDecrement></NumberFieldDecrement>
|
<NumberFieldInput></NumberFieldInput>
|
||||||
<NumberFieldInput></NumberFieldInput>
|
<NumberFieldIncrement></NumberFieldIncrement>
|
||||||
<NumberFieldIncrement></NumberFieldIncrement>
|
</NumberFieldContent>
|
||||||
</NumberFieldContent>
|
</NumberField>
|
||||||
</NumberField>
|
</div>
|
||||||
<NumberField id="jitter" :default-value="10" :min="5">
|
|
||||||
<Label for="jitter"> Jitter in seconds </Label>
|
<Separator class="my-6">Communication settings</Separator>
|
||||||
<NumberFieldContent>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<NumberFieldDecrement></NumberFieldDecrement>
|
<div class="space-y-2">
|
||||||
<NumberFieldInput></NumberFieldInput>
|
<NumberField id="polling" :default-value="60" :min="30">
|
||||||
<NumberFieldIncrement></NumberFieldIncrement>
|
<Label for="polling"> Timeout interwal in seconds </Label>
|
||||||
</NumberFieldContent>
|
<NumberFieldContent>
|
||||||
</NumberField>
|
<NumberFieldDecrement></NumberFieldDecrement>
|
||||||
|
<NumberFieldInput></NumberFieldInput>
|
||||||
|
<NumberFieldIncrement></NumberFieldIncrement>
|
||||||
|
</NumberFieldContent>
|
||||||
|
</NumberField>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
|
||||||
|
<NumberField id="jitter" :default-value="10" :min="5">
|
||||||
|
<Label for="jitter"> Jitter in seconds </Label>
|
||||||
|
<NumberFieldContent>
|
||||||
|
<NumberFieldDecrement></NumberFieldDecrement>
|
||||||
|
<NumberFieldInput></NumberFieldInput>
|
||||||
|
<NumberFieldIncrement></NumberFieldIncrement>
|
||||||
|
</NumberFieldContent>
|
||||||
|
</NumberField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Separator></Separator>
|
<Separator></Separator>
|
||||||
<Label for="sleepdate">Date/time to sleep</Label>
|
<div class="space-y-2 gap-4">
|
||||||
<DataRangePicker id="sleepdate"></DataRangePicker>
|
<Label for="sleepdate">Date/time to sleep</Label>
|
||||||
|
<DataRangePicker id="sleepdate"></DataRangePicker>
|
||||||
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,35 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
|
||||||
import type { PropType } from 'vue';
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
type DeviceRecord = {
|
type DeviceRecord = {
|
||||||
id: number
|
id: number
|
||||||
startedAt: number // unix seconds
|
startedAt: number // unix seconds
|
||||||
stoppedAt: number // unix seconds
|
stoppedAt: number // unix seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListResp = {
|
type ListResp = {
|
||||||
records: DeviceRecord[]
|
records: DeviceRecord[]
|
||||||
offset: number
|
offset: number
|
||||||
limit: number
|
limit: number
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
guid: { type: String as PropType<string>, required: true },
|
guid: {
|
||||||
pageSize: { type: Number as PropType<number>, default: 50 },
|
type: String as PropType<string>,
|
||||||
// If your API base is set via Vite (e.g. "/api"), it will be used automatically.
|
required: true,
|
||||||
apiBase: {
|
},
|
||||||
type: String as PropType<string>,
|
pageSize: {
|
||||||
default: (import.meta as any)?.env?.VITE_API_URL || ''
|
type: Number as PropType<number>,
|
||||||
},
|
default: 50,
|
||||||
// Provide a custom builder if your record stream URL differs.
|
},
|
||||||
recordSrcBuilder: {
|
apiBase: {
|
||||||
type: Function as PropType<(id: number) => string>,
|
type: String as PropType<string>,
|
||||||
default: undefined
|
default: (import.meta as any)?.env?.VITE_API_URL || '',
|
||||||
}
|
},
|
||||||
|
recordSrcBuilder: {
|
||||||
|
type: Function as PropType<(id: number) => string>,
|
||||||
|
default: undefined,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -37,122 +41,184 @@ const error = ref<string | null>(null)
|
|||||||
const records = ref<DeviceRecord[]>([])
|
const records = ref<DeviceRecord[]>([])
|
||||||
const offset = ref(0)
|
const offset = ref(0)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|
||||||
const baseUrl = computed(() => (props.apiBase || '').replace(/\/+$/, ''))
|
const baseUrl = computed(() => (props.apiBase || '').replace(/\/+$/, ''))
|
||||||
|
const audioBlobUrls = ref<Map<number, string>>(new Map())
|
||||||
|
|
||||||
const sortedRecords = computed(() =>
|
const sortedRecords = computed(() =>
|
||||||
[...records.value].sort((a, b) => b.startedAt - a.startedAt)
|
[...records.value].sort((a, b) => b.startedAt - a.startedAt)
|
||||||
)
|
)
|
||||||
|
|
||||||
const dtf = new Intl.DateTimeFormat(undefined, {
|
const dtf = new Intl.DateTimeFormat(undefined, {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short'
|
timeStyle: 'short',
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDate(unix: number) {
|
function formatDate(unix: number) {
|
||||||
return dtf.format(new Date(unix * 1000))
|
return dtf.format(new Date(unix * 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
function durationMinutes(rec: DeviceRecord) {
|
function durationMinutes(rec: DeviceRecord) {
|
||||||
const seconds = Math.max(0, (rec.stoppedAt ?? 0) - (rec.startedAt ?? 0))
|
const seconds = Math.max(0, (rec.stoppedAt ?? 0) - (rec.startedAt ?? 0))
|
||||||
return (seconds / 60).toFixed(2) // minutes with 2 decimals
|
return (seconds / 60).toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecordSrc(id: number) {
|
// Get audio URL using blob approach to preserve authentication
|
||||||
if (props.recordSrcBuilder) return props.recordSrcBuilder(id)
|
async function getAudioUrl(id: number): Promise<string> {
|
||||||
|
// Return existing blob URL if available
|
||||||
|
if (audioBlobUrls.value.has(id)) {
|
||||||
|
return audioBlobUrls.value.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
// Build from Axios baseURL (works for '/api' or 'https://host/api')
|
try {
|
||||||
const base = (api.defaults.baseURL as string | undefined)?.replace(/\/+$/, '') ?? ''
|
const response = await api.get(`/records/${id}/file`, {
|
||||||
return `${base}/records/${id}/file`
|
responseType: 'blob',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'audio/*'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create blob URL from the response
|
||||||
|
const blob = new Blob([response.data], {
|
||||||
|
type: response.headers['content-type'] || 'audio/mpeg'
|
||||||
|
})
|
||||||
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
// Store for future use
|
||||||
|
audioBlobUrls.value.set(id, blobUrl)
|
||||||
|
|
||||||
|
return blobUrl
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load audio:', err)
|
||||||
|
throw new Error('Failed to load audio file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up blob URLs when component is destroyed
|
||||||
|
onUnmounted(() => {
|
||||||
|
audioBlobUrls.value.forEach(url => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
})
|
||||||
|
audioBlobUrls.value.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optional: Preload audio for visible items
|
||||||
|
const preloadAudio = async (recordId: number) => {
|
||||||
|
try {
|
||||||
|
await getAudioUrl(recordId)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to preload audio for record ${recordId}:`, error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRecords(reset = true) {
|
async function fetchRecords(reset = true) {
|
||||||
if (!props.guid) return
|
if (!props.guid) return
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
try {
|
loading.value = true
|
||||||
if (reset) {
|
error.value = null
|
||||||
offset.value = 0
|
|
||||||
records.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await api.get<ListResp>('/records', {
|
try {
|
||||||
params: {
|
if (reset) {
|
||||||
guid: props.guid,
|
offset.value = 0
|
||||||
offset: offset.value,
|
records.value = []
|
||||||
limit: props.pageSize,
|
// Clean up old blob URLs
|
||||||
},
|
audioBlobUrls.value.forEach(url => URL.revokeObjectURL(url))
|
||||||
})
|
audioBlobUrls.value.clear()
|
||||||
|
|
||||||
records.value = reset ? data.records : [...records.value, ...data.records]
|
|
||||||
total.value = data.total ?? records.value.length
|
|
||||||
offset.value = (data.offset ?? offset.value) + (data.limit ?? props.pageSize)
|
|
||||||
} catch (err: any) {
|
|
||||||
// Axios-style error extraction
|
|
||||||
error.value =
|
|
||||||
(err?.response?.data?.error as string) ??
|
|
||||||
(err?.message as string) ??
|
|
||||||
'Failed to load recordings'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data } = await api.get<ListResp>('/records', {
|
||||||
|
params: {
|
||||||
|
guid: props.guid,
|
||||||
|
offset: offset.value,
|
||||||
|
limit: props.pageSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
records.value = reset ? data.records : [...records.value, ...data.records]
|
||||||
|
total.value = data.total ?? records.value.length
|
||||||
|
offset.value = (data.offset ?? offset.value) + (data.limit ?? props.pageSize)
|
||||||
|
|
||||||
|
// Preload first few audio files for better UX
|
||||||
|
if (reset && data.records.length > 0) {
|
||||||
|
const recordsToPreload = data.records.slice(0, 3)
|
||||||
|
recordsToPreload.forEach(rec => preloadAudio(rec.id))
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = (err?.response?.data?.error as string) ??
|
||||||
|
(err?.message as string) ??
|
||||||
|
'Failed to load recordings'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMore = computed(() => offset.value < total.value)
|
const hasMore = computed(() => offset.value < total.value)
|
||||||
|
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
if (!loading.value && hasMore.value) fetchRecords(false)
|
if (!loading.value && hasMore.value) {
|
||||||
|
fetchRecords(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchRecords(true)
|
fetchRecords(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(() => props.guid, () => {
|
||||||
() => props.guid,
|
fetchRecords(true)
|
||||||
() => fetchRecords(true),
|
}, { immediate: false })
|
||||||
{ immediate: false }
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ScrollArea class="w-full h-full">
|
<ScrollArea class="w-full h-full">
|
||||||
<div class="p-3 space-y-3">
|
<div class="p-3 space-y-3">
|
||||||
<div v-if="error" class="text-sm text-red-500 border border-red-200 rounded-md p-2">
|
<div v-if="error" class="text-sm text-red-500 border border-red-200 rounded-md p-2">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && records.length === 0 && !error" class="text-sm text-muted-foreground">
|
||||||
|
No recordings found.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="divide-y divide-border rounded-lg border">
|
||||||
|
<li v-for="rec in sortedRecords" :key="rec.id"
|
||||||
|
class="p-3 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||||
|
<div class="space-y-1 flex-1">
|
||||||
|
<div class="font-medium">Record #{{ rec.id }}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{{ formatDate(rec.startedAt) }} → {{ formatDate(rec.stoppedAt) }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
<div v-if="!loading && records.length === 0 && !error" class="text-sm text-muted-foreground">
|
Duration: <span class="font-semibold">{{ durationMinutes(rec) }}</span> min
|
||||||
No recordings found.
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul v-else class="divide-y divide-border rounded-lg border">
|
<!-- Audio player with loading state -->
|
||||||
<li v-for="rec in sortedRecords" :key="rec.id"
|
<div class="w-full md:w-80 flex items-center">
|
||||||
class="p-3 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
<audio
|
||||||
<div class="space-y-1">
|
class="w-full"
|
||||||
<div class="font-medium">Record #{{ rec.id }}</div>
|
:src="audioBlobUrls.get(rec.id) || ''"
|
||||||
<div class="text-sm text-muted-foreground">
|
controls
|
||||||
{{ formatDate(rec.startedAt) }} → {{ formatDate(rec.stoppedAt) }}
|
preload="none"
|
||||||
</div>
|
@play="() => preloadAudio(rec.id)"
|
||||||
<div class="text-xs">
|
>
|
||||||
Duration: <span class="font-semibold">{{ durationMinutes(rec) }}</span> min
|
Your browser does not support the audio element.
|
||||||
</div>
|
</audio>
|
||||||
</div>
|
<div v-if="!audioBlobUrls.has(rec.id)" class="text-xs text-muted-foreground ml-2">
|
||||||
|
Click play to load
|
||||||
<!-- Optional playback; update getRecordSrc via prop if your URL differs -->
|
|
||||||
<audio class="w-full md:w-auto" :src="getRecordSrc(rec.id)" controls preload="none">
|
|
||||||
Your browser does not support the audio element.
|
|
||||||
</audio>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button v-if="hasMore" class="px-3 py-2 rounded-md border text-sm hover:bg-accent" @click="loadMore"
|
|
||||||
:disabled="loading">
|
|
||||||
{{ loading ? 'Loading…' : 'Load more' }}
|
|
||||||
</button>
|
|
||||||
<span v-else class="text-xs text-muted-foreground">All loaded</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button v-if="hasMore"
|
||||||
|
class="px-3 py-2 rounded-md border text-sm hover:bg-accent transition-colors disabled:opacity-50"
|
||||||
|
@click="loadMore"
|
||||||
|
:disabled="loading">
|
||||||
|
{{ loading ? 'Loading…' : 'Load more' }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-xs text-muted-foreground">All loaded</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</template>
|
</template>
|
||||||
Reference in New Issue
Block a user