Newer
Older
navi-1 / webclient / src / components / ui / AudioPlayer.vue
<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>