├── .editorconfig
├── .gitattributes
├── .gitignore
├── .npmignore
├── .nvmrc
├── .prettierrc.json
├── .vscode
└── extensions.json
├── LICENSE
├── NOTES.md
├── README.md
├── env.d.ts
├── eslint.config.ts
├── index.html
├── package-lock.json
├── package.json
├── public
├── data.json
└── favicon.ico
├── src
├── Playground.vue
├── assets
│ └── main.css
├── components
│ ├── AudioPlayer.vue
│ ├── Icons
│ │ ├── IconClose.vue
│ │ ├── IconCollapse.vue
│ │ ├── IconExpand.vue
│ │ ├── IconHeart.vue
│ │ ├── IconLoadingWaveform.vue
│ │ ├── IconNext.vue
│ │ ├── IconPause.vue
│ │ ├── IconPlay.vue
│ │ ├── IconPrevious.vue
│ │ ├── IconRepeatAll.vue
│ │ ├── IconRepeatOne.vue
│ │ └── IconShuffle.vue
│ ├── PlayerPlaylist.vue
│ └── PlayerVolume.vue
├── composables
│ ├── usePlayer.ts
│ └── usePlayerPlaylist.ts
├── index.ts
└── main.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.package.json
├── tsconfig.vitest.json
├── vite.config.ts
└── vitest.config.ts
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
2 | charset = utf-8
3 | indent_size = 2
4 | indent_style = space
5 | insert_final_newline = true
6 | trim_trailing_whitespace = true
7 |
8 | end_of_line = lf
9 | max_line_length = 100
10 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | .DS_Store
12 | dist
13 | dist-ssr
14 | coverage
15 | *.local
16 |
17 | /cypress/videos/
18 | /cypress/screenshots/
19 |
20 | # Editor directories and files
21 | .vscode/*
22 | !.vscode/extensions.json
23 | .idea
24 | *.suo
25 | *.ntvs*
26 | *.njsproj
27 | *.sln
28 | *.sw?
29 |
30 | *.tsbuildinfo
31 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | .idea
3 | src/
4 | public/
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v22.14.0
2 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "$schema": "https://json.schemastore.org/prettierrc",
4 | "semi": false,
5 | "singleQuote": true,
6 | "printWidth": 100,
7 | "plugins": ["prettier-plugin-tailwindcss"],
8 | "tailwindStylesheet": "./src/assets/main.css"
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "Vue.volar",
4 | "vitest.explorer",
5 | "dbaeumer.vscode-eslint",
6 | "EditorConfig.EditorConfig",
7 | "esbenp.prettier-vscode"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Nemanja Dragun
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/NOTES.md:
--------------------------------------------------------------------------------
1 | ### Internal Notes
2 | ```sh
3 | npm version patch # For bug fixes and patches
4 | npm version minor # For new features that are backwards compatible
5 | npm version major # For breaking changes
6 | npm publish # Publish package
7 | ```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # vue-music-flow
2 |
3 | Modern Vue 3 / Nuxt 3 audio player component with playlist and waveform visualization.
4 |
5 | 
6 |
7 | [Documentation](https://vue-music-flow-docs.vercel.app/getting-started)
8 |
9 | [GitHub Repository](https://github.com/ndragun92/vue-music-flow)
10 |
11 | ## Compatibility
12 | >vue-music-flow works with version Vue 3+ or Nuxt 3+
13 |
14 | ## How to install
15 | >Recommended Node.js version is v22.x or higher
16 |
17 | ### Vue 3
18 | ```sh
19 | npm i vue-music-flow
20 | ```
21 |
22 | ### Nuxt 3
23 | ```sh
24 | npx nuxi module add nuxt-music-flow
25 | ```
26 |
27 | ## How to use
28 |
29 | ### Vue 3
30 |
31 | ###### Component.vue
32 | ```html
33 |
34 |
39 |
40 |
41 |
45 | ```
46 |
47 | ### Nuxt 3
48 |
49 | ###### Component.vue
50 | ```html
51 |
52 |
57 |
58 | ```
59 |
60 | ### For more advanced customization visit documentation
61 | [Click here to visit documentation](https://vue-music-flow-docs.vercel.app/getting-started)
62 |
63 | ___
64 |
65 | ## Development
66 |
67 | ```sh
68 | npm install
69 | ```
70 |
71 | ### Compile and Hot-Reload for Development
72 |
73 | ```sh
74 | npm run dev
75 | ```
76 |
77 | ### Type-Check, Compile and Minify for Production
78 |
79 | ```sh
80 | npm run build
81 | ```
82 |
83 | ### Run Unit Tests with [Vitest](https://vitest.dev/)
84 |
85 | ```sh
86 | npm run test:unit
87 | ```
88 |
89 | ### Lint with [ESLint](https://eslint.org/)
90 |
91 | ```sh
92 | npm run lint
93 | ```
94 |
95 | ## Want to support my work?
96 |
97 |
--------------------------------------------------------------------------------
/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/eslint.config.ts:
--------------------------------------------------------------------------------
1 | import pluginVue from 'eslint-plugin-vue'
2 | import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
3 | import pluginVitest from '@vitest/eslint-plugin'
4 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
5 |
6 | // To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
7 | // import { configureVueProject } from '@vue/eslint-config-typescript'
8 | // configureVueProject({ scriptLangs: ['ts', 'tsx'] })
9 | // More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
10 |
11 | export default defineConfigWithVueTs(
12 | {
13 | name: 'app/files-to-lint',
14 | files: ['**/*.{ts,mts,tsx,vue}'],
15 | },
16 |
17 | {
18 | name: 'app/files-to-ignore',
19 | ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
20 | },
21 |
22 | pluginVue.configs['flat/essential'],
23 | vueTsConfigs.recommended,
24 |
25 | {
26 | ...pluginVitest.configs.recommended,
27 | files: ['src/**/__tests__/*'],
28 | },
29 | skipFormatting,
30 | )
31 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite App
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-music-flow",
3 | "version": "0.2.8",
4 | "description": "Modern Vue 3 / Nuxt 3 audio player component with playlist and waveform visualization.",
5 | "main": "dist/vue-music-flow.umd.js",
6 | "module": "dist/vue-music-flow.es.js",
7 | "types": "dist/index.d.ts",
8 | "files": [
9 | "dist"
10 | ],
11 | "private": false,
12 | "type": "module",
13 | "author": "Nemanja Dragun (https://github.com/ndragun92)",
14 | "license": "MIT",
15 | "homepage": "https://vue-music-flow-docs.vercel.app/",
16 | "keywords": [
17 | "vue3",
18 | "nuxt3",
19 | "audio player",
20 | "music player",
21 | "playlist",
22 | "waveform",
23 | "vue-component"
24 | ],
25 | "scripts": {
26 | "dev": "vite",
27 | "build": "run-p type-check \"build-only {@}\" --",
28 | "preview": "vite preview",
29 | "test:unit": "vitest",
30 | "build-only": "vite build",
31 | "type-check": "vue-tsc --build",
32 | "lint": "eslint . --fix",
33 | "format": "prettier --write src/",
34 | "version-change": "bash -c 'nvm use $(cat .nvmrc)'"
35 | },
36 | "dependencies": {
37 | "@tailwindcss/vite": "^4.0.9",
38 | "@vueuse/core": "^12.7.0",
39 | "tailwindcss": "^4.0.9",
40 | "vue": "^3.5.13",
41 | "wavesurfer.js": "^7.9.1"
42 | },
43 | "devDependencies": {
44 | "@tsconfig/node22": "^22.0.0",
45 | "@types/jsdom": "^21.1.7",
46 | "@types/node": "^22.13.4",
47 | "@vitejs/plugin-vue": "^5.2.1",
48 | "@vitest/eslint-plugin": "1.1.31",
49 | "@vue/eslint-config-prettier": "^10.2.0",
50 | "@vue/eslint-config-typescript": "^14.4.0",
51 | "@vue/test-utils": "^2.4.6",
52 | "@vue/tsconfig": "^0.7.0",
53 | "eslint": "^9.20.1",
54 | "eslint-plugin-vue": "^9.32.0",
55 | "jiti": "^2.4.2",
56 | "jsdom": "^26.0.0",
57 | "npm-run-all2": "^7.0.2",
58 | "prettier": "^3.5.1",
59 | "prettier-plugin-tailwindcss": "^0.6.11",
60 | "typescript": "~5.7.3",
61 | "vite": "^6.1.0",
62 | "vite-plugin-vue-devtools": "^7.7.2",
63 | "vitest": "^3.0.5",
64 | "vue-tsc": "^2.2.2"
65 | },
66 | "repository": {
67 | "type": "git",
68 | "url": "https://github.com/ndragun92/vue-music-flow.git"
69 | },
70 | "bugs": {
71 | "url": "https://github.com/ndragun92/vue-music-flow/issues"
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/public/data.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "audio": "https://storage.googleapis.com/cadenzabox-prod-bucket/audiomachine/5bbf6dac65e26100130505c2_AM18_01_Tree%20of%20Life_lo.mp3",
5 | "title": "Tree of Life",
6 | "artist": "Kevin Rix",
7 | "artwork": "https://storage.googleapis.com/cadenzabox-prod-bucket/audiomachine/am18/5bbf5ddc65e261001304e9fe_awakenings.jpg",
8 | "album": "Awakenings",
9 | "original": {
10 | "versionName": "My version"
11 | }
12 | },
13 | {
14 | "id": 2,
15 | "audio": "https://storage.googleapis.com/cadenzabox-prod-bucket/audiomachine/5bbf6dac65e26100130505bc_AM18_03_Breaking Through_lo.mp3",
16 | "title": "Breaking Through",
17 | "artist": "Jeff Marsh",
18 | "artwork": "https://storage.googleapis.com/cadenzabox-prod-bucket/audiomachine/am18/5bbf5ddc65e261001304e9fe_awakenings.jpg",
19 | "album": "Awakenings"
20 | },
21 | {
22 | "id": 3,
23 | "audio": "https://storage.googleapis.com/cadenzabox-prod-bucket/audiomachine/5bbf6dac65e26100130505c5_AM18_04_The Fire Within_lo.mp3",
24 | "title": "The Fire Within",
25 | "artist": "Kevin Rix",
26 | "artwork": "https://storage.googleapis.com/cadenzabox-prod-bucket/audiomachine/am18/5bbf5ddc65e261001304e9fe_awakenings.jpg",
27 | "album": "Awakenings"
28 | },
29 | {
30 | "id": 4,
31 | "audio": "https://storage.googleapis.com/cadenzabox-prod-bucket/audiomachine/am120/66c3c5107907a083490abcf1_AM120_01_Prologue%20The%20Way%20of%20Legends_lo.mp3",
32 | "title": "Prologue: The Way of Legends",
33 | "artist": "Paul Dinletir",
34 | "artwork": "https://storage.googleapis.com/cadenzabox-prod-bucket/audiomachine/Even%20Poets%20Go%20to%20War_1724106064076.jpg",
35 | "album": "Even Poets Go to War"
36 | },
37 | {
38 | "id": 5,
39 | "audio": "https://storage.googleapis.com/cadenzabox-prod-bucket/audiomachine/no_release_slug/6500dba394d0815e804d2251_AM106_02_Christmas%20Canon_lo.mp3",
40 | "title": "Christmas Canon",
41 | "artist": "Paul Dinletir & Noah Putrich",
42 | "artwork": "https://storage.googleapis.com/cadenzabox-prod-bucket/audiomachine/Yuletide800x_1694555118506.jpg",
43 | "album": "Yuletide"
44 | }
45 | ]
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ndragun92/vue-music-flow/1794ec46befded7d626c1c89dad4b24fd03f38ff/public/favicon.ico
--------------------------------------------------------------------------------
/src/Playground.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Playground
4 |
5 |
6 | - Play single track example
7 | -
8 |
16 | {{ track.title }}
17 |
18 |
19 |
20 |
21 | - Play track as playlist example
22 | -
23 |
31 | {{ track.title }}
32 |
33 |
34 |
35 |
43 |
44 |
52 |
53 |
54 |
55 |
56 |
57 |
81 |
82 |
88 |
--------------------------------------------------------------------------------
/src/assets/main.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @theme {
4 | --breakpoint-phone: 40rem;
5 | --breakpoint-tablet: 80rem;
6 |
7 | --color-primary-dark: oklch(0.145 0 0);
8 | --color-primary: oklch(0.269 0 0);
9 | --color-secondary: oklch(0.371 0 0);
10 | --color-primary-border: oklch(0.205 0 0);
11 | --color-primary-hover: oklch(0.75 0.183 55.934);
12 | --color-primary-active: oklch(0.837 0.128 66.29);
13 | --color-primary-typography: oklch(0.985 0 0);
14 | --color-secondary-typography: oklch(0.708 0 0);
15 |
16 | --scroll-bar-background-light: oklch(0.269 0 0);
17 | --scroll-bar-background: oklch(0.269 0 0);
18 | --scroll-bar-slider: oklch(0.145 0 0);
19 | --scroll-bar-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
20 | --scroll-bar-width: 6px;
21 | }
--------------------------------------------------------------------------------
/src/components/AudioPlayer.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
16 |
20 |
25 |
26 |
27 |
![]()
34 |
35 |
36 |
37 |
43 |
44 |
45 | {{ returnTrack?.title }}
46 |
47 | {{ returnTrack?.artist }}
48 |
49 |
50 |
51 |
63 |
75 |
84 |
94 |
103 |
116 |
117 |
118 |
124 |
125 | {{ formattedCurrentDuration }}
126 |
127 |
128 |
129 |
130 |
131 |
135 |
136 |
137 |
138 |
139 |
145 |
146 | {{ formattedDuration }}
147 |
148 |
149 |
159 |
165 |
166 |
167 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
242 |
--------------------------------------------------------------------------------
/src/components/Icons/IconClose.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/src/components/Icons/IconCollapse.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/src/components/Icons/IconExpand.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/src/components/Icons/IconHeart.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/src/components/Icons/IconLoadingWaveform.vue:
--------------------------------------------------------------------------------
1 |
2 |
96 |
97 |
98 |
103 |
--------------------------------------------------------------------------------
/src/components/Icons/IconNext.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/src/components/Icons/IconPause.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
12 |
--------------------------------------------------------------------------------
/src/components/Icons/IconPlay.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/src/components/Icons/IconPrevious.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/src/components/Icons/IconRepeatAll.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/src/components/Icons/IconRepeatOne.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/src/components/Icons/IconShuffle.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/src/components/PlayerPlaylist.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
Next up:
12 |
13 | {{ returnNextTrack?.title }}
14 |
15 |
16 |
17 |
26 |
27 |
28 |
70 |
71 |
72 |
73 |
86 |
87 |
119 |
--------------------------------------------------------------------------------
/src/components/PlayerVolume.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
21 |
24 |
36 |
37 |
42 |
52 |
53 |
57 | {{ volume }}%
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
90 |
--------------------------------------------------------------------------------
/src/composables/usePlayer.ts:
--------------------------------------------------------------------------------
1 | import WaveSurfer, { type WaveSurferOptions } from 'wavesurfer.js'
2 | import { computed, ref, toRaw, watch } from 'vue'
3 | import { useLocalStorage } from '@vueuse/core'
4 | import usePlayerPlaylist from './usePlayerPlaylist'
5 |
6 | export type TOptions = Omit
7 | export type TPlayerTrack = {
8 | id: number
9 | audio: string
10 | title: string
11 | artist: string
12 | artwork: string
13 | album: string
14 | original?: Record
15 | }
16 | type MediaMetadataOptions = {
17 | title: TPlayerTrack['title']
18 | artist?: TPlayerTrack['artist']
19 | album?: TPlayerTrack['album']
20 | artwork?: { src: TPlayerTrack['artwork']; sizes: string; type: string }[]
21 | }
22 |
23 | const DEFAULT_ARTWORK_SRC = 'https://placehold.co/512x512'
24 |
25 | const track = ref(null)
26 | const wavesurfer = ref()
27 | const initializing = ref(true)
28 | const currentDuration = ref(0)
29 | const duration = ref(0)
30 | const isPlaying = ref(false)
31 | const options = ref({})
32 | const wavesurferElement = ref(null)
33 |
34 | export default function usePlayer(_options?: TOptions) {
35 | if (_options) {
36 | options.value = Object(_options)
37 | }
38 |
39 | const volume = useLocalStorage('player:volume', 75)
40 | const audioSource = computed(() => track.value?.audio)
41 |
42 | const { onSetPlaylist, onResetPlaylist, playlist, playlistOptions } = usePlayerPlaylist()
43 |
44 | const init = () => {
45 | const el = wavesurferElement.value
46 | if (!el || !audioSource.value) {
47 | console.error(el ? 'Missing audio URL' : 'HTMLElement not found')
48 | return
49 | }
50 |
51 | initializing.value = true
52 | updateMediaMetadata({
53 | title: track.value?.title || '',
54 | artist: track.value?.artist || '',
55 | album: track.value?.album || '',
56 | artwork: [
57 | {
58 | src: track.value?.artwork || DEFAULT_ARTWORK_SRC,
59 | sizes: '512x512',
60 | type: 'image/jpeg',
61 | },
62 | ],
63 | })
64 |
65 | const {
66 | waveColor = '#e7e7e7',
67 | progressColor = '#ffb86a',
68 | barWidth = 3,
69 | barRadius = 2,
70 | barGap = 3,
71 | barHeight = 0.8,
72 | height = 50,
73 | dragToSeek = { debounceTime: 1000 },
74 | ...filteredOptions
75 | } = toRaw(options.value) as TOptions
76 |
77 | wavesurfer.value = WaveSurfer.create({
78 | container: el,
79 | waveColor,
80 | progressColor,
81 | barWidth,
82 | barRadius,
83 | barGap,
84 | barHeight,
85 | height,
86 | dragToSeek,
87 | url: audioSource.value,
88 | ...filteredOptions,
89 | })
90 |
91 | setupWaveSurferListeners()
92 | }
93 |
94 | const setupWaveSurferListeners = () => {
95 | wavesurfer.value?.on('init', () => {
96 | wavesurfer.value?.setVolume(volume.value / 100)
97 | })
98 | wavesurfer.value?.on('ready', () => {
99 | initializing.value = false
100 | duration.value = wavesurfer.value?.getDuration() || 0
101 | })
102 | wavesurfer.value?.on('play', () => (isPlaying.value = true))
103 | wavesurfer.value?.on('pause', () => (isPlaying.value = false))
104 | wavesurfer.value?.on('audioprocess', (time: number) => {
105 | currentDuration.value = time || 0
106 | })
107 | wavesurfer.value?.on('finish', () => {
108 | const { repeat } = playlistOptions.value
109 | if (playlist.value?.length) {
110 | if (repeat === 'single') {
111 | wavesurfer.value?.play()
112 | } else if (repeat === 'all') {
113 | onPlayNextTrack()
114 | }
115 | } else {
116 | if (repeat === 'single') {
117 | wavesurfer.value?.play()
118 | } else {
119 | wavesurfer.value?.stop()
120 | }
121 | }
122 | })
123 | wavesurfer.value?.on('destroy', cleanupAfterDestroy)
124 | }
125 |
126 | const cleanupAfterDestroy = () => {
127 | wavesurfer.value = undefined
128 | duration.value = 0
129 | currentDuration.value = 0
130 | track.value = null
131 | resetMediaMetadata()
132 | isPlaying.value = false
133 | }
134 |
135 | const handleTrackPlay = (_track: TPlayerTrack) => {
136 | if (!_track) {
137 | console.error('Invalid track data')
138 | return
139 | }
140 | if (track.value?.id === _track.id) {
141 | togglePlayback()
142 | } else {
143 | destroy()
144 | track.value = _track
145 | init()
146 | }
147 | }
148 |
149 | const togglePlayback = () => {
150 | wavesurfer.value?.playPause()
151 | }
152 |
153 | const destroy = () => {
154 | wavesurfer.value?.destroy()
155 | }
156 |
157 | const formattedCurrentDuration = computed(() => formatDuration(currentDuration.value))
158 | const formattedDuration = computed(() => formatDuration(duration.value))
159 | const returnTrack = computed(() => track.value)
160 |
161 | const isTrackPlaying = (_id: TPlayerTrack['id']): boolean => {
162 | return returnTrack.value?.id === _id && isPlaying.value
163 | }
164 |
165 | watch(volume, (_value) => {
166 | wavesurfer.value?.setVolume(_value / 100)
167 | })
168 |
169 | const onPlayAsPlaylist = (tracks: TPlayerTrack[], track?: TPlayerTrack) => {
170 | if (!tracks?.length) {
171 | alert('Missing tracks data')
172 | return
173 | }
174 | onSetPlaylist(tracks)
175 | if (track) {
176 | handleTrackPlay(track)
177 | } else {
178 | handleTrackPlay(tracks[0])
179 | }
180 | }
181 |
182 | const onPlaySingleTrack = (track: TPlayerTrack) => {
183 | if (!track) {
184 | alert('Missing track data')
185 | return
186 | }
187 | onResetPlaylist()
188 | handleTrackPlay(track)
189 | }
190 |
191 | const formatDuration = (seconds: number) => {
192 | const totalSeconds = Math.floor(seconds)
193 | const minutes = Math.floor(totalSeconds / 60)
194 | const remainingSeconds = totalSeconds % 60
195 | return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`
196 | }
197 |
198 | const updateMediaMetadata = (data: MediaMetadataOptions) => {
199 | if ('mediaSession' in navigator) {
200 | navigator.mediaSession.metadata = new MediaMetadata({
201 | title: data.title,
202 | artist: data.artist || '',
203 | album: data.album || '',
204 | artwork: data.artwork || [],
205 | })
206 | } else {
207 | console.warn('Media Session API not supported.')
208 | }
209 | }
210 |
211 | const resetMediaMetadata = () => {
212 | if ('mediaSession' in navigator) {
213 | navigator.mediaSession.metadata = null
214 | } else {
215 | console.warn('Media Session API not supported.')
216 | }
217 | }
218 |
219 | const returnCurrentTrackIndex = computed(() => {
220 | if (!playlist.value.length) return null
221 | return playlist.value.findIndex((track) => track.id === returnTrack.value?.id)
222 | })
223 |
224 | const returnPreviousTrack = computed(() => {
225 | if (returnCurrentTrackIndex.value == null) return
226 | const findPreviousTrack = playlist.value[returnCurrentTrackIndex.value - 1]
227 | return findPreviousTrack || playlist.value[playlist.value.length - 1]
228 | })
229 |
230 | const returnNextTrack = computed(() => {
231 | if (returnCurrentTrackIndex.value == null) return
232 | const findNextTrack = playlist.value[returnCurrentTrackIndex.value + 1]
233 | return findNextTrack || playlist.value[0]
234 | })
235 |
236 | const onPlayPreviousTrack = () => {
237 | onPlayAsPlaylist(playlist.value, returnPreviousTrack.value)
238 | }
239 |
240 | const onPlayNextTrack = () => {
241 | onPlayAsPlaylist(playlist.value, returnNextTrack.value)
242 | }
243 |
244 | return {
245 | wavesurferElement,
246 | wavesurfer,
247 | init,
248 | destroy,
249 | onPlaySingleTrack,
250 | onPlayAsPlaylist,
251 | togglePlayback,
252 | onClose: destroy,
253 | audioSource,
254 | initializing,
255 | isPlaying,
256 | formattedCurrentDuration,
257 | formattedDuration,
258 | returnTrack,
259 | isTrackPlaying,
260 | playlist,
261 | returnNextTrack,
262 | onPlayPreviousTrack,
263 | onPlayNextTrack,
264 | }
265 | }
266 |
--------------------------------------------------------------------------------
/src/composables/usePlayerPlaylist.ts:
--------------------------------------------------------------------------------
1 | import { type TPlayerTrack } from './usePlayer'
2 | import { ref, toRaw } from 'vue'
3 | import { useLocalStorage } from '@vueuse/core'
4 |
5 | type TPlaylistOptions = {
6 | repeat: 'none' | 'single' | 'all'
7 | }
8 |
9 | const playlist = ref([])
10 | const shuffle = ref(false)
11 | const playlistOriginal = ref([])
12 | const playlistShuffled = ref([])
13 |
14 | const playlistOptions = useLocalStorage('player:playlist:options', {
15 | repeat: 'all',
16 | })
17 |
18 | export default function usePlayerPlaylist() {
19 | const onSetPlaylist = (tracks: TPlayerTrack[]) => {
20 | shuffle.value = false
21 | playlist.value = tracks
22 | }
23 |
24 | const onResetPlaylist = () => {
25 | playlist.value = []
26 | }
27 |
28 | const onToggleRepeat = () => {
29 | const { repeat } = playlistOptions.value
30 |
31 | switch (repeat) {
32 | case 'none':
33 | playlistOptions.value.repeat = 'single'
34 | break
35 | case 'single':
36 | playlistOptions.value.repeat = playlist.value.length ? 'all' : 'none'
37 | break
38 | case 'all':
39 | playlistOptions.value.repeat = 'none'
40 | break
41 | }
42 | }
43 |
44 | const shuffleArray = (array: TPlayerTrack[] | []) => {
45 | // Create a copy of the array to avoid mutating the original
46 | const shuffled = [...array]
47 |
48 | for (let i = shuffled.length - 1; i > 0; i--) {
49 | // Generate a random index
50 | const randomIndex = Math.floor(Math.random() * (i + 1))
51 |
52 | // Swap the current element with the random index element
53 | ;[shuffled[i], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[i]]
54 | }
55 |
56 | return shuffled
57 | }
58 |
59 | const onToggleShuffle = () => {
60 | shuffle.value = !shuffle.value
61 |
62 | if (shuffle.value) {
63 | playlistOriginal.value = toRaw(playlist.value)
64 | playlistShuffled.value = shuffleArray(toRaw(playlist.value))
65 | playlist.value = toRaw(playlistShuffled.value)
66 | } else {
67 | playlist.value = toRaw(playlistOriginal.value)
68 | }
69 | }
70 |
71 | return {
72 | onSetPlaylist,
73 | onResetPlaylist,
74 | playlist,
75 | playlistOptions,
76 | shuffle,
77 | onToggleRepeat,
78 | onToggleShuffle,
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import './assets/main.css'
2 |
3 | export { default as MusicFlow } from './components/AudioPlayer.vue'
4 | export { default as useMusicFlow } from './composables/usePlayer'
5 | export type { TPlayerTrack as TMusicFlow } from './composables/usePlayer'
6 | export type { TOptions as TMusicFlowOptions } from './composables/usePlayer'
7 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import './assets/main.css'
2 |
3 | import { createApp } from 'vue'
4 | import App from './Playground.vue'
5 |
6 | createApp(App).mount('#app')
7 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.dom.json",
3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
4 | "exclude": ["src/**/__tests__/*"],
5 | "compilerOptions": {
6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
7 |
8 | "paths": {
9 | "@/*": ["./src/*"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.node.json"
6 | },
7 | {
8 | "path": "./tsconfig.app.json"
9 | },
10 | {
11 | "path": "./tsconfig.vitest.json"
12 | },
13 | {
14 | "path": "./tsconfig.package.json"
15 | }
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node22/tsconfig.json",
3 | "include": [
4 | "vite.config.*",
5 | "vitest.config.*",
6 | "cypress.config.*",
7 | "nightwatch.conf.*",
8 | "playwright.config.*",
9 | "eslint.config.*"
10 | ],
11 | "compilerOptions": {
12 | "noEmit": true,
13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
14 |
15 | "module": "ESNext",
16 | "moduleResolution": "Bundler",
17 | "types": ["node"]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/tsconfig.package.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "skipLibCheck": true,
9 | "declaration": true, // Generates .d.ts files
10 | "declarationDir": "dist", // Output folder for .d.ts files
11 | "outDir": "dist", // Output folder for compiled JS files
12 | "emitDeclarationOnly": true, // Only emit declaration files (if you're using Vite for JS)
13 | "jsx": "preserve",
14 | "sourceMap": true
15 | },
16 | "include": [
17 | "src/**/*.ts",
18 | "src/**/*.vue"
19 | ],
20 | "exclude": [
21 | "node_modules",
22 | "dist"
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/tsconfig.vitest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.app.json",
3 | "include": ["src/**/__tests__/*", "env.d.ts"],
4 | "exclude": [],
5 | "compilerOptions": {
6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
7 |
8 | "lib": [],
9 | "types": ["node", "jsdom"]
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from 'node:url'
2 |
3 | import { defineConfig } from 'vite'
4 | import tailwindcss from '@tailwindcss/vite'
5 | import vue from '@vitejs/plugin-vue'
6 | import vueDevTools from 'vite-plugin-vue-devtools'
7 | import path from 'path'
8 |
9 | // https://vite.dev/config/
10 | export default defineConfig({
11 | plugins: [vue(), vueDevTools(), tailwindcss()],
12 | build: {
13 | lib: {
14 | entry: path.resolve(__dirname, 'src/index.ts'),
15 | name: 'VueMusicFlow',
16 | formats: ['es', 'umd'],
17 | fileName: (format) => `vue-music-flow.${format}.js`,
18 | },
19 | rollupOptions: {
20 | external: ['vue', 'wavesurfer.js'],
21 | output: {
22 | globals: {
23 | vue: 'Vue',
24 | 'wavesurfer.js': 'WaveSurfer',
25 | '@vueuse/core': 'VueUse',
26 | },
27 | },
28 | },
29 | },
30 | resolve: {
31 | alias: {
32 | '@': fileURLToPath(new URL('./src', import.meta.url)),
33 | },
34 | },
35 | })
36 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath } from 'node:url'
2 | import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
3 | import viteConfig from './vite.config'
4 |
5 | export default mergeConfig(
6 | viteConfig,
7 | defineConfig({
8 | test: {
9 | environment: 'jsdom',
10 | exclude: [...configDefaults.exclude, 'e2e/**'],
11 | root: fileURLToPath(new URL('./', import.meta.url)),
12 | },
13 | }),
14 | )
15 |
--------------------------------------------------------------------------------