<template>
<div class="audio-player" :class="{ 'is-compact': compact }">
<div class="ap-art">
<i class="ph ph-waveform"></i>
</div>
<div v-if="title" class="ap-title" :title="title">{{ title }}</div>
<div v-if="error" class="ap-error">
{{ t('artifacts.audioFailed') }}
</div>
<div class="ap-controls">
<GnIconButton
:icon="playing ? 'ph ph-pause' : 'ph ph-play'"
:label="playing ? t('artifacts.pause') : t('artifacts.play')"
@click="togglePlay"
/>
<span class="ap-time">{{ formatTime(currentTime) }}</span>
<GnRange
class="ap-seek"
:model-value="currentTime"
:min="0"
:max="duration || 0"
:step="0.1"
:aria-label="t('artifacts.seek')"
@update:model-value="seekTo"
/>
<span class="ap-time">{{ formatTime(duration) }}</span>
<GnIconButton
:icon="muted ? 'ph ph-speaker-simple-slash' : 'ph ph-speaker-simple-high'"
:label="muted ? t('artifacts.unmute') : t('artifacts.mute')"
@click="toggleMute"
/>
<div class="ap-volume">
<GnRange
:model-value="muted ? 0 : volume"
:min="0"
:max="1"
:step="0.05"
:aria-label="t('artifacts.volume')"
@update:model-value="setVolume"
/>
</div>
</div>
<audio
ref="audioEl"
:src="src"
class="ap-audio-el"
preload="metadata"
@loadedmetadata="onMetadata"
@timeupdate="onTimeUpdate"
@play="playing = true"
@pause="playing = false"
@ended="playing = false"
@volumechange="onVolumeChange"
@error="error = true"
/>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { GnIconButton, GnRange } from 'gnexus-ui-kit/vue'
import { t } from '@/i18n/index.js'
// The browser's own audio element is a thin grey bar that says nothing about
// the file. This wraps it in a player that names the track, shows both times
// and keeps a seek and a volume slider — the same one the full-screen viewer
// uses, so a file sounds and looks the same wherever it is opened.
const props = defineProps({
src: { type: String, required: true },
title: { type: String, default: '' },
// Tighter art and padding for a card body, where the player sits under a
// summary row rather than alone in a dialog.
compact: { type: Boolean, default: false },
})
const audioEl = ref(null)
const playing = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(1)
const muted = ref(false)
const error = ref(false)
function reset() {
playing.value = false
currentTime.value = 0
duration.value = 0
volume.value = 1
muted.value = false
error.value = false
}
watch(() => props.src, reset)
async function togglePlay() {
const el = audioEl.value
if (!el) return
if (el.paused) {
try {
await el.play()
} catch {
error.value = true
}
} else {
el.pause()
}
}
function onMetadata() {
const el = audioEl.value
if (!el) return
// A metadata-less stream reports Infinity/NaN; the seek bar must stay sane.
duration.value = Number.isFinite(el.duration) ? el.duration : 0
}
function onTimeUpdate() {
if (audioEl.value) currentTime.value = audioEl.value.currentTime
}
function seekTo(value) {
const el = audioEl.value
if (!el) return
const target = Math.min(Number(value) || 0, duration.value || 0)
el.currentTime = target
currentTime.value = target
}
function setVolume(value) {
const el = audioEl.value
if (!el) return
const next = Math.max(0, Math.min(1, Number(value) || 0))
el.muted = false
el.volume = next
volume.value = next
muted.value = next === 0
}
function toggleMute() {
const el = audioEl.value
if (!el) return
el.muted = !el.muted
}
function onVolumeChange() {
const el = audioEl.value
if (!el) return
muted.value = el.muted || el.volume === 0
if (!el.muted) volume.value = el.volume
}
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
const total = Math.floor(seconds)
const mins = Math.floor(total / 60)
const secs = total % 60
return `${mins}:${String(secs).padStart(2, '0')}`
}
</script>
<style scoped>
.audio-player {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 28px 24px;
background: var(--gn-surface-panel);
}
.ap-art {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
border-radius: 50%;
background: var(--gn-color-black);
color: var(--gn-color-accent);
font-size: 40px;
}
.ap-title {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--gn-color-text-light);
font-size: 14px;
font-weight: 500;
}
.ap-error {
color: var(--gn-color-error);
font-size: 12px;
}
.ap-controls {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
}
.ap-audio-el {
display: none;
}
.ap-time {
flex: none;
color: var(--gn-color-text-dark);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
/* The kit range renders a label wrapper; inside a media row it needs to
behave as a bare track. */
.ap-controls :deep(.range) {
flex: 1;
min-width: 0;
margin: 0;
}
.ap-controls :deep(.range .label) {
margin: 0;
}
.ap-seek {
flex: 1;
}
.ap-volume {
flex: none;
width: 90px;
display: flex;
}
.ap-volume :deep(.range) {
width: 100%;
margin: 0;
}
.audio-player.is-compact {
gap: 10px;
padding: 14px;
}
.audio-player.is-compact .ap-art {
width: 56px;
height: 56px;
font-size: 24px;
}
.audio-player.is-compact .ap-title {
font-size: 13px;
}
@media (max-width: 600px) {
.ap-volume {
display: none;
}
.audio-player {
padding: 20px 16px;
}
}
</style>