added custom audio player, but this code needs some more improvments

This commit is contained in:
tdv
2025-09-05 18:13:37 +03:00
parent d840664ee7
commit 0f266d45a6
4 changed files with 295 additions and 166 deletions

View File

@@ -22,6 +22,7 @@
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul-vue": "^0.4.1", "vaul-vue": "^0.4.1",
"vue": "^3.5.17", "vue": "^3.5.17",
"vue-audio-visual": "^3.0.11",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "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": { "node_modules/vue-router": {
"version": "4.5.1", "version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",

View File

@@ -23,6 +23,7 @@
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul-vue": "^0.4.1", "vaul-vue": "^0.4.1",
"vue": "^3.5.17", "vue": "^3.5.17",
"vue-audio-visual": "^3.0.11",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,39 +1,27 @@
<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 { Button } from '@/components/ui/button'
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'; import { Slider } from '@/components/ui/slider'
import type { PropType } from 'vue'; import {
Drawer, DrawerContent, DrawerDescription, DrawerFooter,
DrawerHeader, DrawerTitle
} from '@/components/ui/drawer'
type DeviceRecord = { import { Play, Pause, Volume2, VolumeX } from 'lucide-vue-next'
id: number import { api } from '@/lib/api'
startedAt: number // unix seconds import { ref, computed, onMounted, watch, onUnmounted, nextTick } from 'vue'
stoppedAt: number // unix seconds import { useColorMode } from '@vueuse/core' // NEW: read theme
} import type { PropType } from 'vue'
import { useAVWaveform } from 'vue-audio-visual'
type ListResp = { type DeviceRecord = { id: number; startedAt: number; stoppedAt: number }
records: DeviceRecord[] type ListResp = { records: DeviceRecord[]; offset: number; limit: number; total: number }
offset: number
limit: number
total: number
}
const props = defineProps({ const props = defineProps({
guid: { guid: { type: String as PropType<string>, required: true },
type: String as PropType<string>, pageSize: { type: Number as PropType<number>, default: 50 },
required: true, 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 },
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) const loading = ref(false)
@@ -44,181 +32,215 @@ 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 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, { dateStyle: 'medium', timeStyle: 'short' })
) function formatDate(unix: number) { return dtf.format(new Date(unix * 1000)) }
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) { 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) return (seconds / 60).toFixed(2)
} }
// Get audio URL using blob approach to preserve authentication // --- Drawer + player state
async function getAudioUrl(id: number): Promise<string> { const drawerOpen = ref(false)
// Return existing blob URL if available const selected = ref<DeviceRecord | null>(null)
if (audioBlobUrls.value.has(id)) { const waveSrc = ref<string>('')
return audioBlobUrls.value.get(id)! const player = ref<HTMLAudioElement | null>(null)
} const canvas = ref<HTMLCanvasElement | null>(null)
const playing = ref(false)
const volume = ref<number[]>([80])
try { // NEW: theme-aware waveform colors for contrast
const response = await api.get(`/records/${id}/file`, { const mode = useColorMode()
responseType: 'blob', const wf = computed(() => {
headers: { const dark = mode.value === 'dark'
'Accept': 'audio/*' 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
// Create blob URL from the response playtimeSliderColor: dark ? '#F97316' : '#DC2626', // orange-500 vs red-600
const blob = new Blob([response.data], { canvFillColor: 'transparent',
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 // --- blob URL loader (preserves auth)
const preloadAudio = async (recordId: number) => { async function getAudioUrl(id: number): Promise<string> {
try { if (audioBlobUrls.value.has(id)) return audioBlobUrls.value.get(id)!
await getAudioUrl(recordId) const response = await api.get(`/records/${id}/file`, {
} catch (error) { responseType: 'blob',
console.warn(`Failed to preload audio for record ${recordId}:`, error) 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) { async function fetchRecords(reset = true) {
if (!props.guid) return if (!props.guid) return
loading.value = true; error.value = null
loading.value = true
error.value = null
try { try {
if (reset) { if (reset) {
offset.value = 0 offset.value = 0; records.value = []
records.value = [] audioBlobUrls.value.forEach(URL.revokeObjectURL); audioBlobUrls.value.clear()
// Clean up old blob URLs
audioBlobUrls.value.forEach(url => URL.revokeObjectURL(url))
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] records.value = reset ? data.records : [...records.value, ...data.records]
total.value = data.total ?? records.value.length total.value = data.total ?? records.value.length
offset.value = (data.offset ?? offset.value) + (data.limit ?? props.pageSize) offset.value = (data.offset ?? offset.value) + (data.limit ?? props.pageSize)
if (reset && data.records.length > 0) data.records.slice(0, 3).forEach(rec => preloadAudio(rec.id))
// 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) { } catch (err: any) {
error.value = (err?.response?.data?.error as string) ?? error.value = err?.response?.data?.error ?? err?.message ?? 'Failed to load recordings'
(err?.message as string) ?? } finally { loading.value = false }
'Failed to load recordings'
} finally {
loading.value = false
}
} }
const hasMore = computed(() => offset.value < total.value) 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() { // --- Drawer / player
if (!loading.value && hasMore.value) { async function openPlayer(rec: DeviceRecord) {
fetchRecords(false) 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(() => { function initWaveform() {
fetchRecords(true) 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, () => { // cleanup
fetchRecords(true) onUnmounted(() => {
}, { immediate: false }) 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> </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 }}</div>
{{ error }} <div v-if="!loading && records.length === 0 && !error" class="text-sm text-muted-foreground">No recordings found.</div>
</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"> <ul v-else class="divide-y divide-border rounded-lg border">
<li v-for="rec in sortedRecords" :key="rec.id" <li
class="p-3 flex flex-col md:flex-row md:items-center md:justify-between gap-3"> 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="space-y-1 flex-1">
<div class="font-medium">Record #{{ rec.id }}</div> <div class="font-medium">Record #{{ rec.id }}</div>
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">{{ formatDate(rec.startedAt) }} {{ formatDate(rec.stoppedAt) }}</div>
{{ formatDate(rec.startedAt) }} {{ formatDate(rec.stoppedAt) }} <div class="text-xs">Duration: <span class="font-semibold">{{ durationMinutes(rec) }}</span> min</div>
</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> </div>
<div class="text-xs text-muted-foreground md:w-40 text-right">Click to open waveform</div>
</li> </li>
</ul> </ul>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button v-if="hasMore" <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">
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' }} {{ loading ? 'Loading' : 'Load more' }}
</button> </Button>
<span v-else class="text-xs text-muted-foreground">All loaded</span> <span v-else class="text-xs text-muted-foreground">All loaded</span>
</div> </div>
</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> </ScrollArea>
</template> </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>

View File

@@ -2,5 +2,6 @@ import { createApp } from 'vue'
import './style.css' import './style.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { AVPlugin } from "vue-audio-visual";
createApp(App).use(router).mount('#app') createApp(App).use(router).use(AVPlugin).mount('#app')