added custom audio player, but this code needs some more improvments
This commit is contained in:
105
management-ui/package-lock.json
generated
105
management-ui/package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"uuid": "^11.1.0",
|
||||
"vaul-vue": "^0.4.1",
|
||||
"vue": "^3.5.17",
|
||||
"vue-audio-visual": "^3.0.11",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2835,6 +2836,110 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-audio-visual": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/vue-audio-visual/-/vue-audio-visual-3.0.11.tgz",
|
||||
"integrity": "sha512-toXUXswQqo/oHZuzVnhuZ+m9rM9OU5lgOkckQHGpXizF00XI1gZ1f2KpRrLMKj4e7LC23tZjcgMxDeBJRTenvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^9.1.0",
|
||||
"vue": "^3.2.37"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-audio-visual/node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
|
||||
"integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-audio-visual/node_modules/@vueuse/core": {
|
||||
"version": "9.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz",
|
||||
"integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.16",
|
||||
"@vueuse/metadata": "9.13.0",
|
||||
"@vueuse/shared": "9.13.0",
|
||||
"vue-demi": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-audio-visual/node_modules/@vueuse/core/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-audio-visual/node_modules/@vueuse/metadata": {
|
||||
"version": "9.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz",
|
||||
"integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-audio-visual/node_modules/@vueuse/shared": {
|
||||
"version": "9.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz",
|
||||
"integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue-demi": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-audio-visual/node_modules/@vueuse/shared/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"uuid": "^11.1.0",
|
||||
"vaul-vue": "^0.4.1",
|
||||
"vue": "^3.5.17",
|
||||
"vue-audio-visual": "^3.0.11",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,39 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { api } from '@/lib/api';
|
||||
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import {
|
||||
Drawer, DrawerContent, DrawerDescription, DrawerFooter,
|
||||
DrawerHeader, DrawerTitle
|
||||
} from '@/components/ui/drawer'
|
||||
|
||||
type DeviceRecord = {
|
||||
id: number
|
||||
startedAt: number // unix seconds
|
||||
stoppedAt: number // unix seconds
|
||||
}
|
||||
import { Play, Pause, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
import { api } from '@/lib/api'
|
||||
import { ref, computed, onMounted, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { useColorMode } from '@vueuse/core' // NEW: read theme
|
||||
import type { PropType } from 'vue'
|
||||
import { useAVWaveform } from 'vue-audio-visual'
|
||||
|
||||
type ListResp = {
|
||||
records: DeviceRecord[]
|
||||
offset: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
type DeviceRecord = { id: number; startedAt: number; stoppedAt: number }
|
||||
type ListResp = { records: DeviceRecord[]; offset: number; limit: number; total: number }
|
||||
|
||||
const props = defineProps({
|
||||
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 || '',
|
||||
},
|
||||
recordSrcBuilder: {
|
||||
type: Function as PropType<(id: number) => string>,
|
||||
default: undefined,
|
||||
}
|
||||
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 || '' },
|
||||
recordSrcBuilder: { type: Function as PropType<(id: number) => string>, default: undefined },
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -44,181 +32,215 @@ 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)
|
||||
)
|
||||
|
||||
const dtf = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
function formatDate(unix: number) {
|
||||
return dtf.format(new Date(unix * 1000))
|
||||
}
|
||||
|
||||
const sortedRecords = computed(() => [...records.value].sort((a, b) => b.startedAt - a.startedAt))
|
||||
const dtf = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
function formatDate(unix: number) { return dtf.format(new Date(unix * 1000)) }
|
||||
function durationMinutes(rec: DeviceRecord) {
|
||||
const seconds = Math.max(0, (rec.stoppedAt ?? 0) - (rec.startedAt ?? 0))
|
||||
return (seconds / 60).toFixed(2)
|
||||
}
|
||||
|
||||
// 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)!
|
||||
// --- Drawer + player state
|
||||
const drawerOpen = ref(false)
|
||||
const selected = ref<DeviceRecord | null>(null)
|
||||
const waveSrc = ref<string>('')
|
||||
const player = ref<HTMLAudioElement | null>(null)
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const playing = ref(false)
|
||||
const volume = ref<number[]>([80])
|
||||
|
||||
// NEW: theme-aware waveform colors for contrast
|
||||
const mode = useColorMode()
|
||||
const wf = computed(() => {
|
||||
const dark = mode.value === 'dark'
|
||||
return {
|
||||
playedLineColor: dark ? '#FAFAFA' : '#111827', // white vs zinc-900
|
||||
noplayedLineColor: dark ? '#52525B' : '#CBD5E1', // zinc-600 vs slate-300
|
||||
playtimeFontColor: dark ? '#E5E7EB' : '#334155', // slate-200 vs slate-700
|
||||
playtimeSliderColor: dark ? '#F97316' : '#DC2626', // orange-500 vs red-600
|
||||
canvFillColor: 'transparent',
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// --- blob URL loader (preserves auth)
|
||||
async function getAudioUrl(id: number): Promise<string> {
|
||||
if (audioBlobUrls.value.has(id)) return audioBlobUrls.value.get(id)!
|
||||
const response = await api.get(`/records/${id}/file`, {
|
||||
responseType: 'blob',
|
||||
headers: { Accept: 'audio/*' },
|
||||
})
|
||||
const blob = new Blob([response.data], { type: response.headers['content-type'] || 'audio/mpeg' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
audioBlobUrls.value.set(id, blobUrl)
|
||||
return blobUrl
|
||||
}
|
||||
|
||||
const preloadAudio = async (recordId: number) => { try { await getAudioUrl(recordId) } catch {} }
|
||||
|
||||
// --- records fetch
|
||||
async function fetchRecords(reset = true) {
|
||||
if (!props.guid) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
if (reset) {
|
||||
offset.value = 0
|
||||
records.value = []
|
||||
// Clean up old blob URLs
|
||||
audioBlobUrls.value.forEach(url => URL.revokeObjectURL(url))
|
||||
audioBlobUrls.value.clear()
|
||||
offset.value = 0; records.value = []
|
||||
audioBlobUrls.value.forEach(URL.revokeObjectURL); audioBlobUrls.value.clear()
|
||||
}
|
||||
|
||||
const { data } = await api.get<ListResp>('/records', {
|
||||
params: {
|
||||
guid: props.guid,
|
||||
offset: offset.value,
|
||||
limit: props.pageSize,
|
||||
},
|
||||
})
|
||||
|
||||
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))
|
||||
}
|
||||
if (reset && data.records.length > 0) data.records.slice(0, 3).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
|
||||
}
|
||||
error.value = err?.response?.data?.error ?? err?.message ?? 'Failed to load recordings'
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const hasMore = computed(() => offset.value < total.value)
|
||||
function loadMore() { if (!loading.value && hasMore.value) fetchRecords(false) }
|
||||
onMounted(() => fetchRecords(true))
|
||||
watch(() => props.guid, () => fetchRecords(true))
|
||||
|
||||
function loadMore() {
|
||||
if (!loading.value && hasMore.value) {
|
||||
fetchRecords(false)
|
||||
}
|
||||
// --- Drawer / player
|
||||
async function openPlayer(rec: DeviceRecord) {
|
||||
selected.value = rec
|
||||
drawerOpen.value = true
|
||||
waveSrc.value = ''
|
||||
try {
|
||||
const url = await getAudioUrl(rec.id)
|
||||
waveSrc.value = url
|
||||
await nextTick()
|
||||
initWaveform()
|
||||
} catch { error.value = 'Failed to load audio' }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords(true)
|
||||
function initWaveform() {
|
||||
if (!player.value || !canvas.value || !waveSrc.value) return
|
||||
const w = canvas.value.parentElement?.clientWidth ?? 800
|
||||
useAVWaveform(player, canvas, {
|
||||
src: waveSrc.value,
|
||||
canvWidth: w,
|
||||
canvHeight: 120,
|
||||
// HIGHER CONTRAST LINES & UI (theme aware)
|
||||
playedLineWidth: 1.4,
|
||||
noplayedLineWidth: 1.0,
|
||||
playedLineColor: wf.value.playedLineColor,
|
||||
noplayedLineColor: wf.value.noplayedLineColor,
|
||||
playtime: true,
|
||||
playtimeWithMs: false,
|
||||
playtimeFontSize: 12,
|
||||
playtimeFontFamily: 'monospace',
|
||||
playtimeFontColor: wf.value.playtimeFontColor,
|
||||
playtimeSlider: true,
|
||||
playtimeSliderColor: wf.value.playtimeSliderColor,
|
||||
playtimeSliderWidth: 2,
|
||||
playtimeClickable: true,
|
||||
canvFillColor: wf.value.canvFillColor,
|
||||
})
|
||||
const a = player.value
|
||||
a.volume = (volume.value[0] ?? 80) / 100
|
||||
a.addEventListener('play', onPlay)
|
||||
a.addEventListener('pause', onPause)
|
||||
a.addEventListener('ended', onPause)
|
||||
}
|
||||
function onPlay() { playing.value = true }
|
||||
function onPause() { playing.value = false }
|
||||
function togglePlay() { const a = player.value; if (a) a.paused ? a.play() : a.pause() }
|
||||
|
||||
watch(volume, (v) => { if (player.value) player.value.volume = Math.min(1, Math.max(0, (v?.[0] ?? 0) / 100)) })
|
||||
watch(drawerOpen, (open) => {
|
||||
if (!open) {
|
||||
const a = player.value
|
||||
if (a) { a.pause(); a.currentTime = 0; a.removeEventListener('play', onPlay); a.removeEventListener('pause', onPause); a.removeEventListener('ended', onPause) }
|
||||
playing.value = false; waveSrc.value = ''; selected.value = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.guid, () => {
|
||||
fetchRecords(true)
|
||||
}, { immediate: false })
|
||||
// cleanup
|
||||
onUnmounted(() => {
|
||||
audioBlobUrls.value.forEach(URL.revokeObjectURL); audioBlobUrls.value.clear()
|
||||
const a = player.value
|
||||
if (a) { a.removeEventListener('play', onPlay); a.removeEventListener('pause', onPause); a.removeEventListener('ended', onPause) }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollArea class="w-full h-full">
|
||||
<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">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && records.length === 0 && !error" class="text-sm text-muted-foreground">
|
||||
No recordings found.
|
||||
</div>
|
||||
<div v-if="error" class="text-sm text-red-500 border border-red-200 rounded-md p-2">{{ 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">
|
||||
<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 hover:bg-accent/40 cursor-pointer"
|
||||
@click="openPlayer(rec)" @keyup.enter="openPlayer(rec)" role="button" tabindex="0"
|
||||
>
|
||||
<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 class="text-xs">
|
||||
Duration: <span class="font-semibold">{{ durationMinutes(rec) }}</span> min
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 class="text-sm text-muted-foreground">{{ formatDate(rec.startedAt) }} → {{ formatDate(rec.stoppedAt) }}</div>
|
||||
<div class="text-xs">Duration: <span class="font-semibold">{{ durationMinutes(rec) }}</span> min</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground md:w-40 text-right">Click to open waveform</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 transition-colors disabled:opacity-50"
|
||||
@click="loadMore"
|
||||
:disabled="loading">
|
||||
<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>
|
||||
</Button>
|
||||
<span v-else class="text-xs text-muted-foreground">All loaded</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FULL-WIDTH Drawer w/o blur -->
|
||||
<Drawer v-model:open="drawerOpen">
|
||||
<DrawerContent
|
||||
class="fixed inset-x-0 bottom-0 z-50 w-screen max-w-none rounded-t-xl border-t bg-background p-0"
|
||||
>
|
||||
<DrawerHeader class="px-6 pt-4">
|
||||
<DrawerTitle>Record {{ selected ? `#${selected.id}` : '' }}</DrawerTitle>
|
||||
<DrawerDescription v-if="selected">
|
||||
{{ formatDate(selected.startedAt) }} → {{ formatDate(selected.stoppedAt) }} · {{ durationMinutes(selected) }} min
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div class="px-6 pb-6 space-y-4">
|
||||
<div v-if="waveSrc" class="rounded-lg border p-4 bg-background">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="inline-flex items-center justify-center h-10 w-10 rounded-full border hover:bg-accent"
|
||||
@click="togglePlay" :aria-label="playing ? 'Pause' : 'Play'"
|
||||
>
|
||||
<component :is="playing ? Pause : Play" class="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1">
|
||||
<canvas ref="canvas" class="w-full h-28 block"></canvas>
|
||||
<audio ref="player" :src="waveSrc" preload="auto" class="hidden" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<component :is="(volume[0] ?? 0) === 0 ? VolumeX : Volume2" class="h-4 w-4" />
|
||||
<Slider v-model="volume" :min="0" :max="100" :step="1" class="w-full" />
|
||||
<span class="text-xs w-10 text-right">{{ volume[0] }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-sm text-muted-foreground">Loading audio…</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter class="px-6 pb-4">
|
||||
<Button variant="outline" @click="drawerOpen = false">Close</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</ScrollArea>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Remove any blur from the Drawer overlay (Vaul exposes an overlay element). */
|
||||
:deep([vaul-overlay]) { backdrop-filter: none !important; }
|
||||
/* Optional: if your implementation adds backdrop-blur via a class name */
|
||||
:deep(.backdrop-blur) { backdrop-filter: none !important; }
|
||||
</style>
|
||||
|
||||
@@ -2,5 +2,6 @@ import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { AVPlugin } from "vue-audio-visual";
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
createApp(App).use(router).use(AVPlugin).mount('#app')
|
||||
|
||||
Reference in New Issue
Block a user