├── .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 | ![Preview](https://ik.imagekit.io/ltdassets/public/social.jpg?updatedAt=1741803287465) 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 | 40 | 41 | 45 | ``` 46 | 47 | ### Nuxt 3 48 | 49 | ###### Component.vue 50 | ```html 51 | 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 | 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 | 180 | 181 | 242 | -------------------------------------------------------------------------------- /src/components/Icons/IconClose.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/Icons/IconCollapse.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/Icons/IconExpand.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/Icons/IconHeart.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/Icons/IconLoadingWaveform.vue: -------------------------------------------------------------------------------- 1 | 97 | 98 | 103 | -------------------------------------------------------------------------------- /src/components/Icons/IconNext.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/Icons/IconPause.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/Icons/IconPlay.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/Icons/IconPrevious.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/Icons/IconRepeatAll.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/Icons/IconRepeatOne.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/Icons/IconShuffle.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/PlayerPlaylist.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 86 | 87 | 119 | -------------------------------------------------------------------------------- /src/components/PlayerVolume.vue: -------------------------------------------------------------------------------- 1 | 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 | --------------------------------------------------------------------------------