Skip to content

feat(VAudio): add audio player component - #23155

Closed
bUxEE wants to merge 5 commits into
vuetifyjs:devfrom
bUxEE:feat/v-audio
Closed

bUxEE wants to merge 5 commits into
vuetifyjs:devfrom
bUxEE:feat/v-audio

Conversation

@bUxEE

@bUxEE bUxEE commented Aug 28, 2026 •

Copy link
Copy Markdown

Description

Vuetify ships VVideo but has no audio counterpart. This adds VAudio to labs: a player for self-hosted audio with a seekable waveform, built to mirror the existing VVideo / VVideoControls / VVideoVolume split.

  • VAudio — the <audio> element, media state, models, peaks acquisition, error and loading states
  • VAudioControls — the transport row; purely presentational, no media element access
  • VAudioWaveform — peaks to SVG, the seek interaction, hover preview. Independently useful with nothing but peaks and a model

Zero new dependencies. Only existing Vuetify components, composables and utilities.

Peaks are the primary API, not an optimisation

The waveform can decode peaks itself (fetch → decodeAudioData), but that needs CORS headers from the media host, and it decompresses the whole file — roughly 10 MB of memory per minute of audio. Recording URLs in real apps are usually cross-origin CDN links, so for many consumers that path simply is not available. Passing precomputed peaks skips decoding, works cross-origin, and is SSR-safe. The docs page says this in as many words; decode remains as a same-origin convenience.

Playground

packages/vuetify/dev/Playground.vue
<template>
  <v-app>
    <v-container class="d-flex flex-column ga-8 py-8" style="max-width: 720px;">
      <div>
        <div class="text-subtitle-2 mb-2">Default</div>
        <v-audio :src="src"></v-audio>
      </div>

      <div>
        <div class="text-subtitle-2 mb-2">Precomputed peaks</div>
        <v-audio :peaks="peaks" :src="src"></v-audio>
      </div>

      <div>
        <div class="text-subtitle-2 mb-2">Whole component seekable, no waveform</div>
        <v-audio :src="src" seek-target="container" hide-waveform></v-audio>
      </div>

      <div>
        <div class="text-subtitle-2 mb-2">Pills, compact, 15s skip, coloured</div>
        <v-audio
          :peaks="peaks"
          :skip-interval="15"
          :src="src"
          color="primary"
          density="compact"
          pills
        ></v-audio>
      </div>

      <div>
        <div class="text-subtitle-2 mb-2">Waveform on its own — {{ Math.round(position) }}%</div>
        <v-audio-waveform
          v-model="position"
          :bars="96"
          :duration="218"
          :height="48"
          :peaks="peaks"
          color="primary"
          mirror
        ></v-audio-waveform>
      </div>
    </v-container>
  </v-app>
</template>

<script>
  import { shallowRef } from 'vue'

  export default {
    name: 'Playground',
    setup () {
      return {
        position: shallowRef(35),
        src: 'https://cdn.freesound.org/previews/612/612095_5674468-lq.mp3',
        peaks: [
          0.35, 0.35, 0.57, 0.49, 0.69, 0.60, 0.85, 0.43,
          0.61, 0.28, 0.43, 0.67, 0.53, 0.49, 0.36, 0.02,
          0.02, 0.01, 0.01, 0.01, 0.03, 0.03, 0.01, 0.01,
          0.41, 0.37, 0.65, 0.69, 1.00, 0.66, 0.68, 0.72,
          0.75, 0.27, 0.25, 0.31, 0.38, 0.92, 0.48, 0.56,
          0.32, 0.33, 0.02, 0.03, 0.01, 0.01, 0.02, 0.02,
          0.04, 0.02, 0.29, 0.45, 0.48, 0.83, 0.87, 0.45,
          0.21, 0.37, 0.33, 0.29, 0.53, 0.66, 0.83, 0.98,
        ],
      }
    },
  }
</script>
image

Notes for review

The iconset diff. $stop, $download, $skipForward and $skipBackward exist in no iconset, and stop renders by default, so all 14 alias-defining files gain four entries — the same way $play/$pause arrived with VVideo. fa-svg.ts re-exports fa.ts's map and inherits them, and IconAliases is untouched, since play, pause, fullscreen and volume* are not declared there either. Every name was checked against that set's own catalogue; the mdi-svg paths come from @mdi/js (mdiStop, mdiDownload, mdiFastForward, mdiRewind). Two sets have no generic fast-forward glyph, so carbon and tabler use their interval-jump icons (forward-10, rewind-forward-10) — happy to change these.

Locales. locale/__tests__/index.spec.ts enforces structural parity, so the audio block is added to all 43 files. Six keys (play, pause, seek, volume, mute, unmute) are lifted verbatim from each file's own video block, so they are real translations. The six genuinely new ones (stop, download, skipForward, skipBackward, playbackRate, error) are English placeholders — I did not want to machine-translate into 42 languages. Happy to drop them to en only if Crowdin is the preferred route.

Seek semantics. A visible waveform is always the seek surface; seek-target decides what happens when there is none. container therefore takes effect together with hide-waveform, which is the compact table-row player, and none disables seeking everywhere. Exactly one element carries the seek role="slider" in every combination, asserted by a spec.

The <audio> element is not exposed to assistive technology. Browsers apply display: none to audio:not([controls]) from the UA stylesheet, and an author !important does not override it. Giving it controls would put a duplicate set of play/seek controls in the accessibility tree and the tab order, so the component's own labelled controls are the accessible interface instead.

Progress is painted from requestAnimationFrame, not a CSS transition. A transition always animates toward its target, so the fill trailed the audio by up to one timeupdate interval and read as lag. The loop runs only while playing and writes a custom property directly on the element, so per-frame work never re-enters Vue's update cycle; the models still update at timeupdate rate. $audio-waveform-progress-transition is kept as a variable, defaulting to none, for anyone who prefers the eased behaviour.

Pointer Events. The waveform uses setPointerCapture rather than the mousedown + window-listener protocol in VSlider: one path for mouse, touch and pen, nothing to leak on the window, and pointercancel as a real cleanup signal. Flagging it since it diverges from the existing slider.

Docs examples point at a third-party sample MP3, since there is no audio asset on the Vuetify CDN. Glad to swap it for a hosted one.

Checks

  • pnpm build vuetify — passes
  • pnpm build api — passes, no missing descriptions for the three new components
  • pnpm lint on the touched paths — clean
  • vitest --project unit — full suite green (916), including the locale parity test
  • vitest --project browser src/labs/VAudio — 20 specs green, covering click and drag seeking, keyboard, RTL, the seek-target matrix, the error slot and the skip-button labels

Markup

<v-audio src="…" /> is a complete player. Everything else is opt-in.

@J-Sek

J-Sek commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Quick notes

  • remove fetch - push loading peaks to the user
  • how can we support different layouts (waveform above/below the actions)
  • do we need download action at all (maybe a slot use case)
  • fix missing translations
  • unify media icon aliases

bUxEE and others added 2 commits September 3, 2026 23:49
Remove the fetch/decode pipeline. `peaks` is supplied by the consumer; a
missing array draws the placeholder track, which still seeks and still
shows progress. Deleting the Web Audio path also removes the CORS caveat
and the per-mount cost of decoding a whole file.

Support stacked layouts. `variant` gains `waveform-top` and
`waveform-bottom`, resolved in sass with a wrapping row and one `order`
rule — the same way VVideoControls resolves `tube`, and with an identical
render tree in every variant.

Drop the download action. It was one button with one hardcoded behaviour;
a link in the `append` slot covers it and stays a real anchor. Removes the
prop, icon prop, `click:download` emit, locale key and `$download` alias.

Fix the remaining untranslated strings. VAudioWaveform now labels itself
so it is not an unlabelled slider when used standalone, and the playback
rate suffix and remaining-time sign become `playbackRateValue` and
`remainingTime` keys rather than literals.

Unify the media icon aliases. `IconAliases` declares the media group
(`play`, `pause`, `stop`, `skipForward`, `skipBackward`, `fullscreen`,
`fullscreenExit`, `volume*`) instead of leaving it to the interface's index
signature — every alias-defining iconset already supplies all of them, so
this changes no behaviour but makes an omission a type error rather than a
blank button. The volume button also walks the same four-step ladder as
VVideoVolume instead of using two of the four volume aliases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bUxEE

bUxEE commented Sep 3, 2026

Copy link
Copy Markdown
Author

Thanks for the review — all five points are addressed in 620e2bd.

Remove fetch — push loading peaks to the user. decode.ts, the AbortController, the peaks cache and the fetch → decodeAudioData path are gone. peaks is now the only source; without it the component draws the flat placeholder track, which still seeks and still shows progress. downsamplePeaks/normalizePeaks stay, since a consumer passing 4096 values for a 64-bar render still needs downsampling and normalize is a live render-time prop. crossorigin survives for a narrower reason, noted in the source: audio is exposed, so a consumer can route the element into their own Web Audio graph.

Different layouts. variant gains waveform-top and waveform-bottom, which give the waveform its own row above or below the actions. Following VVideoControls' tube, it is one enum rather than a second orthogonal prop, and it is resolved entirely in sass — a wrapping flex row plus one order rule, so the render tree is identical in every variant.

Download. Removed: prop, downloadIcon, click:download, the locale key and the $download alias across the 14 iconsets. It was one button with one hardcoded behaviour. The append slot covers it and keeps it a real anchor — right-clickable and keyboard-reachable, which a scripted click is not. Documented as slot-append.

Missing translations. Two real gaps: VAudioWaveform never called useLocale, so used standalone it was an unlabelled role="slider"; and the playback-rate suffix and remaining-time sign were string literals. The waveform now labels itself, and those became playbackRateValue ({0}x) and remainingTime (-{0}) — keys rather than literals because neither notation is universal. A browser spec now asserts nothing renders containing $vuetify, since t() echoes the key back on a miss.

Unify media icon aliases. The underlying issue is that media is the one alias group with no type-level contract: IconAliases declares 64 entries but none of play, pause, fullscreen, fullscreenExit or volume* — they arrived with VVideo and live in the iconset files, resolved through the index signature. Adding three more silently widened that gap. So this declares the whole media group on the interface. Every alias-defining iconset already supplies all eleven (verified), so behaviour is unchanged; it just makes an omission a type error instead of a blank button. fa-svg.ts needs no edit — it re-exports fa.ts's map.

One thing under that heading you did not explicitly ask for, so please push back if you disagree: VVideoVolume steps through all four volume aliases by level (>70 high, >40 medium, >10 low, else off) while VAudioControls used only high and off. The two media components now read the group identically. volumeIcon defaults to unset so the ladder runs, and setting it still pins one glyph.

Verified on the branch: dev:typecheck clean, lint clean, and the VAudio suites at 46 passing (22 unit, 24 browser).

loop: Boolean,
muted: Boolean,
startAt: [Number, String],
eager: Boolean,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prop is not used anywhere and can be safely removed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — removed in 387e7d8.

It was originally read by a ref tracking whether loading had started; removing the decode pipeline left that ref dead and orphaned the prop. preload already covers loading ahead of interaction, so there was nothing left for it to do.

Also added a spec that fails when VAudio declares a prop it never reads (directly, or by name through a model composable), so a prop orphaned by a refactor can't slip through again.

`eager` was only ever read by a ref tracking whether loading had started,
and removing the decode pipeline left that ref dead. `preload` already
covers loading before interaction, so the prop had no implementation
behind it.

Add a spec that fails when VAudio declares a prop it never reads, so a
prop orphaned by a refactor cannot slip through again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@J-Sek J-Sek added the C: New Component This issue would need a new component to be developed. label Sep 4, 2026
@J-Sek J-Sek added this to the v4.3.0 milestone Sep 4, 2026
@J-Sek J-Sek mentioned this pull request Sep 29, 2026
1 of 2 tasks
@J-Sek

J-Sek commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Closing in favor of #23227 - I have taken it as a baseline and did some major improvements:

  • inner layout is easier to customize
  • seek bar as separate component
  • waveform is opt-in and optimized for performance
  • support for large files (12h+ audiobooks)
  • added "lazy" and "live" modes

@J-Sek J-Sek closed this Sep 29, 2026
@J-Sek J-Sek removed this from the v4.3.0 milestone Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C: New Component This issue would need a new component to be developed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants