some small chenges in device ui
This commit is contained in:
@@ -26,17 +26,25 @@ const usrIDs = selectedUserIds.value
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card class="flex w-full max-w-4xl mx-auto">
|
||||
<CardHeader class="pb-4">
|
||||
<CardTitle>
|
||||
Dashboard
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent class="flex-1 space-y-6">
|
||||
<div class="grid gap-5">
|
||||
<div class="grid space-y-2 grid-cols-4 items-center">
|
||||
<Label for="users">Allowed users</Label>
|
||||
<AssignDevice v-model="usrIDs"></AssignDevice>
|
||||
<AssignDevice v-model="usrIDs" class="col-span-3 w-full"></AssignDevice>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4 pt-2 gap-4">
|
||||
<Button>Start recording</Button>
|
||||
<Button>Stop recording</Button>
|
||||
</div>
|
||||
<div class="space-y-2 gap-4">
|
||||
<NumberField id="duration" :default-value="120" :min="30">
|
||||
<Label for="duration"> Record duration in seconds</Label>
|
||||
<NumberFieldContent>
|
||||
@@ -45,7 +53,11 @@ const usrIDs = selectedUserIds.value
|
||||
<NumberFieldIncrement></NumberFieldIncrement>
|
||||
</NumberFieldContent>
|
||||
</NumberField>
|
||||
<Separator>Communication settings</Separator>
|
||||
</div>
|
||||
|
||||
<Separator class="my-6">Communication settings</Separator>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<NumberField id="polling" :default-value="60" :min="30">
|
||||
<Label for="polling"> Timeout interwal in seconds </Label>
|
||||
<NumberFieldContent>
|
||||
@@ -54,6 +66,9 @@ const usrIDs = selectedUserIds.value
|
||||
<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>
|
||||
@@ -62,9 +77,14 @@ const usrIDs = selectedUserIds.value
|
||||
<NumberFieldIncrement></NumberFieldIncrement>
|
||||
</NumberFieldContent>
|
||||
</NumberField>
|
||||
</div>
|
||||
</div>
|
||||
<Separator></Separator>
|
||||
<div class="space-y-2 gap-4">
|
||||
<Label for="sleepdate">Date/time to sleep</Label>
|
||||
<DataRangePicker id="sleepdate"></DataRangePicker>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
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';
|
||||
|
||||
type DeviceRecord = {
|
||||
@@ -18,17 +18,21 @@ type ListResp = {
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
guid: { type: String as PropType<string>, required: true },
|
||||
pageSize: { type: Number as PropType<number>, default: 50 },
|
||||
// If your API base is set via Vite (e.g. "/api"), it will be used automatically.
|
||||
guid: {
|
||||
type: String as PropType<string>,
|
||||
required: true,
|
||||
},
|
||||
pageSize: {
|
||||
type: Number as PropType<number>,
|
||||
default: 50,
|
||||
},
|
||||
apiBase: {
|
||||
type: String as PropType<string>,
|
||||
default: (import.meta as any)?.env?.VITE_API_URL || ''
|
||||
default: (import.meta as any)?.env?.VITE_API_URL || '',
|
||||
},
|
||||
// Provide a custom builder if your record stream URL differs.
|
||||
recordSrcBuilder: {
|
||||
type: Function as PropType<(id: number) => string>,
|
||||
default: undefined
|
||||
default: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -37,8 +41,8 @@ const error = ref<string | null>(null)
|
||||
const records = ref<DeviceRecord[]>([])
|
||||
const offset = ref(0)
|
||||
const total = ref(0)
|
||||
|
||||
const baseUrl = computed(() => (props.apiBase || '').replace(/\/+$/, ''))
|
||||
const audioBlobUrls = ref<Map<number, string>>(new Map())
|
||||
|
||||
const sortedRecords = computed(() =>
|
||||
[...records.value].sort((a, b) => b.startedAt - a.startedAt)
|
||||
@@ -46,7 +50,7 @@ const sortedRecords = computed(() =>
|
||||
|
||||
const dtf = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
function formatDate(unix: number) {
|
||||
@@ -55,19 +59,60 @@ function formatDate(unix: number) {
|
||||
|
||||
function durationMinutes(rec: DeviceRecord) {
|
||||
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) {
|
||||
if (props.recordSrcBuilder) return props.recordSrcBuilder(id)
|
||||
// Get audio URL using blob approach to preserve authentication
|
||||
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')
|
||||
const base = (api.defaults.baseURL as string | undefined)?.replace(/\/+$/, '') ?? ''
|
||||
return `${base}/records/${id}/file`
|
||||
try {
|
||||
const response = await api.get(`/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) {
|
||||
if (!props.guid) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -75,6 +120,9 @@ async function fetchRecords(reset = true) {
|
||||
if (reset) {
|
||||
offset.value = 0
|
||||
records.value = []
|
||||
// Clean up old blob URLs
|
||||
audioBlobUrls.value.forEach(url => URL.revokeObjectURL(url))
|
||||
audioBlobUrls.value.clear()
|
||||
}
|
||||
|
||||
const { data } = await api.get<ListResp>('/records', {
|
||||
@@ -88,10 +136,14 @@ async function fetchRecords(reset = true) {
|
||||
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) {
|
||||
// Axios-style error extraction
|
||||
error.value =
|
||||
(err?.response?.data?.error as string) ??
|
||||
error.value = (err?.response?.data?.error as string) ??
|
||||
(err?.message as string) ??
|
||||
'Failed to load recordings'
|
||||
} finally {
|
||||
@@ -102,19 +154,20 @@ async function fetchRecords(reset = true) {
|
||||
const hasMore = computed(() => offset.value < total.value)
|
||||
|
||||
function loadMore() {
|
||||
if (!loading.value && hasMore.value) fetchRecords(false)
|
||||
if (!loading.value && hasMore.value) {
|
||||
fetchRecords(false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords(true)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.guid,
|
||||
() => fetchRecords(true),
|
||||
{ immediate: false }
|
||||
)
|
||||
watch(() => props.guid, () => {
|
||||
fetchRecords(true)
|
||||
}, { immediate: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollArea class="w-full h-full">
|
||||
<div class="p-3 space-y-3">
|
||||
@@ -129,7 +182,7 @@ watch(
|
||||
<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">
|
||||
<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) }}
|
||||
@@ -139,15 +192,28 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional playback; update getRecordSrc via prop if your URL differs -->
|
||||
<audio class="w-full md:w-auto" :src="getRecordSrc(rec.id)" controls preload="none">
|
||||
<!-- Audio player with loading state -->
|
||||
<div class="w-full md:w-80 flex items-center">
|
||||
<audio
|
||||
class="w-full"
|
||||
:src="audioBlobUrls.get(rec.id) || ''"
|
||||
controls
|
||||
preload="none"
|
||||
@play="() => preloadAudio(rec.id)"
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<div v-if="!audioBlobUrls.has(rec.id)" class="text-xs text-muted-foreground ml-2">
|
||||
Click play to load
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user