mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: visualizer overhaul (#1575)
This commit is contained in:
parent
4854e56fdb
commit
5e283ef539
27 changed files with 902 additions and 102 deletions
|
@ -31,6 +31,7 @@
|
||||||
"select": "^1.1.2",
|
"select": "^1.1.2",
|
||||||
"sketch-js": "^1.1.3",
|
"sketch-js": "^1.1.3",
|
||||||
"slugify": "^1.0.2",
|
"slugify": "^1.0.2",
|
||||||
|
"three": "^0.146.0",
|
||||||
"vue": "^3.2.32",
|
"vue": "^3.2.32",
|
||||||
"vue-global-events": "^2.1.1",
|
"vue-global-events": "^2.1.1",
|
||||||
"youtube-player": "^3.0.4"
|
"youtube-player": "^3.0.4"
|
||||||
|
@ -49,6 +50,7 @@
|
||||||
"@types/lodash": "^4.14.150",
|
"@types/lodash": "^4.14.150",
|
||||||
"@types/nprogress": "^0.2.0",
|
"@types/nprogress": "^0.2.0",
|
||||||
"@types/pusher-js": "^4.2.2",
|
"@types/pusher-js": "^4.2.2",
|
||||||
|
"@types/three": "^0.144.0",
|
||||||
"@types/youtube-player": "^5.5.2",
|
"@types/youtube-player": "^5.5.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.22.0",
|
"@typescript-eslint/eslint-plugin": "^5.22.0",
|
||||||
"@typescript-eslint/parser": "^4.11.1",
|
"@typescript-eslint/parser": "^4.11.1",
|
||||||
|
|
|
@ -14,7 +14,7 @@ new class extends UnitTestCase {
|
||||||
Volume: this.stub('Volume')
|
Volume: this.stub('Volume')
|
||||||
},
|
},
|
||||||
provide: {
|
provide: {
|
||||||
[CurrentSongKey]: factory<Song>('song', {
|
[<symbol>CurrentSongKey]: factory<Song>('song', {
|
||||||
playback_state: 'Playing'
|
playback_state: 'Playing'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="extra-controls" data-testid="other-controls">
|
<div class="extra-controls" data-testid="other-controls">
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<button
|
<a
|
||||||
v-if="song?.playback_state === 'Playing'"
|
|
||||||
v-koel-tooltip.top
|
v-koel-tooltip.top
|
||||||
class="visualizer-btn"
|
class="visualizer-btn"
|
||||||
data-testid="toggle-visualizer-btn"
|
data-testid="toggle-visualizer-btn"
|
||||||
title="Toggle the visualizer"
|
href="/#/visualizer"
|
||||||
type="button"
|
title="Show the visualizer"
|
||||||
@click.prevent="toggleVisualizer"
|
|
||||||
>
|
>
|
||||||
<icon :icon="faBolt"/>
|
<icon :icon="faBolt"/>
|
||||||
</button>
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="useEqualizer"
|
v-if="useEqualizer"
|
||||||
|
@ -41,7 +39,6 @@ import Volume from '@/components/ui/Volume.vue'
|
||||||
const song = requireInjection(CurrentSongKey, ref(null))
|
const song = requireInjection(CurrentSongKey, ref(null))
|
||||||
|
|
||||||
const showEqualizer = () => eventBus.emit('MODAL_SHOW_EQUALIZER')
|
const showEqualizer = () => eventBus.emit('MODAL_SHOW_EQUALIZER')
|
||||||
const toggleVisualizer = () => eventBus.emit('TOGGLE_VISUALIZER')
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<div class="extra-controls" data-testid="other-controls" data-v-8bf5fe81="">
|
<div class="extra-controls" data-testid="other-controls" data-v-8bf5fe81="">
|
||||||
<div class="wrapper" data-v-8bf5fe81=""><button class="visualizer-btn" data-testid="toggle-visualizer-btn" title="Toggle the visualizer" type="button" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></button>
|
<div class="wrapper" data-v-8bf5fe81=""><a class="visualizer-btn" data-testid="toggle-visualizer-btn" href="/#/visualizer" title="Show the visualizer" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></a>
|
||||||
<!--v-if--><br data-testid="Volume" data-v-8bf5fe81="">
|
<!--v-if--><br data-testid="Volume" data-v-8bf5fe81="">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { ref } from 'vue'
|
||||||
import { waitFor } from '@testing-library/vue'
|
import { waitFor } from '@testing-library/vue'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { eventBus } from '@/utils'
|
|
||||||
import { albumStore, preferenceStore } from '@/stores'
|
import { albumStore, preferenceStore } from '@/stores'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { CurrentSongKey } from '@/symbols'
|
import { CurrentSongKey } from '@/symbols'
|
||||||
|
@ -10,17 +9,17 @@ import AlbumArtOverlay from '@/components/ui/AlbumArtOverlay.vue'
|
||||||
import MainContent from './MainContent.vue'
|
import MainContent from './MainContent.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
private renderComponent (hasCurrentSong = false) {
|
private renderComponent () {
|
||||||
return this.render(MainContent, {
|
return this.render(MainContent, {
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
[CurrentSongKey]: ref(factory<Song>('song'))
|
[<symbol>CurrentSongKey]: ref(factory<Song>('song'))
|
||||||
},
|
},
|
||||||
stubs: {
|
stubs: {
|
||||||
AlbumArtOverlay,
|
AlbumArtOverlay,
|
||||||
HomeScreen: this.stub(), // so that home overview requests are not made
|
HomeScreen: this.stub(), // so that home overview requests are not made
|
||||||
Visualizer: this.stub('visualizer'),
|
Visualizer: this.stub('visualizer')
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -41,15 +40,5 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await waitFor(() => expect(queryByTestId('album-art-overlay')).toBeNull())
|
await waitFor(() => expect(queryByTestId('album-art-overlay')).toBeNull())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles visualizer', async () => {
|
|
||||||
const { getByTestId, queryByTestId } = this.renderComponent()
|
|
||||||
|
|
||||||
eventBus.emit('TOGGLE_VISUALIZER')
|
|
||||||
await waitFor(() => getByTestId('visualizer'))
|
|
||||||
|
|
||||||
eventBus.emit('TOGGLE_VISUALIZER')
|
|
||||||
await waitFor(() => expect(queryByTestId('visualizer')).toBeNull())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
lists), so we use v-show.
|
lists), so we use v-show.
|
||||||
For those that don't need to maintain their own UI state, we use v-if and enjoy some code-splitting juice.
|
For those that don't need to maintain their own UI state, we use v-if and enjoy some code-splitting juice.
|
||||||
-->
|
-->
|
||||||
<Visualizer v-if="showingVisualizer"/>
|
<VisualizerScreen v-if="screen === 'Visualizer'"/>
|
||||||
<AlbumArtOverlay v-if="showAlbumArtOverlay && currentSong" :album="currentSong?.album_id"/>
|
<AlbumArtOverlay v-if="showAlbumArtOverlay && currentSong" :album="currentSong?.album_id"/>
|
||||||
|
|
||||||
<HomeScreen v-show="screen === 'Home'"/>
|
<HomeScreen v-show="screen === 'Home'"/>
|
||||||
|
@ -34,7 +34,7 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, onMounted, ref, toRef } from 'vue'
|
import { defineAsyncComponent, onMounted, ref, toRef } from 'vue'
|
||||||
import { eventBus, requireInjection } from '@/utils'
|
import { requireInjection } from '@/utils'
|
||||||
import { preferenceStore } from '@/stores'
|
import { preferenceStore } from '@/stores'
|
||||||
import { useThirdPartyServices } from '@/composables'
|
import { useThirdPartyServices } from '@/composables'
|
||||||
import { CurrentSongKey, RouterKey } from '@/symbols'
|
import { CurrentSongKey, RouterKey } from '@/symbols'
|
||||||
|
@ -61,7 +61,7 @@ const ProfileScreen = defineAsyncComponent(() => import('@/components/screens/Pr
|
||||||
const YoutubeScreen = defineAsyncComponent(() => import('@/components/screens/YouTubeScreen.vue'))
|
const YoutubeScreen = defineAsyncComponent(() => import('@/components/screens/YouTubeScreen.vue'))
|
||||||
const SearchSongResultsScreen = defineAsyncComponent(() => import('@/components/screens/search/SearchSongResultsScreen.vue'))
|
const SearchSongResultsScreen = defineAsyncComponent(() => import('@/components/screens/search/SearchSongResultsScreen.vue'))
|
||||||
const NotFoundScreen = defineAsyncComponent(() => import('@/components/screens/NotFoundScreen.vue'))
|
const NotFoundScreen = defineAsyncComponent(() => import('@/components/screens/NotFoundScreen.vue'))
|
||||||
const Visualizer = defineAsyncComponent(() => import('@/components/ui/Visualizer.vue'))
|
const VisualizerScreen = defineAsyncComponent(() => import('@/components/screens/VisualizerScreen.vue'))
|
||||||
|
|
||||||
const { useYouTube } = useThirdPartyServices()
|
const { useYouTube } = useThirdPartyServices()
|
||||||
|
|
||||||
|
@ -69,13 +69,10 @@ const router = requireInjection(RouterKey)
|
||||||
const currentSong = requireInjection(CurrentSongKey, ref(null))
|
const currentSong = requireInjection(CurrentSongKey, ref(null))
|
||||||
|
|
||||||
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
|
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
|
||||||
const showingVisualizer = ref(false)
|
|
||||||
const screen = ref<ScreenName>('Home')
|
const screen = ref<ScreenName>('Home')
|
||||||
|
|
||||||
router.onRouteChanged(route => (screen.value = route.screen))
|
router.onRouteChanged(route => (screen.value = route.screen))
|
||||||
|
|
||||||
eventBus.on('TOGGLE_VISUALIZER', () => (showingVisualizer.value = !showingVisualizer.value))
|
|
||||||
|
|
||||||
onMounted(() => router.resolve())
|
onMounted(() => router.resolve())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
148
resources/assets/js/components/screens/VisualizerScreen.vue
Normal file
148
resources/assets/js/components/screens/VisualizerScreen.vue
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
<template>
|
||||||
|
<section id="vizContainer" :class="{ fullscreen: isFullscreen }" @dblclick="toggleFullscreen">
|
||||||
|
<div class="artifacts">
|
||||||
|
<div class="credits" v-if="selectedVisualizer">
|
||||||
|
<h3>{{ selectedVisualizer.name }}</h3>
|
||||||
|
<p class="text-secondary" v-if="selectedVisualizer.credits">
|
||||||
|
by {{ selectedVisualizer.credits.author }}
|
||||||
|
<a :href="selectedVisualizer.credits.url" target="_blank">
|
||||||
|
<icon :icon="faUpRightFromSquare"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select v-model="selectedId">
|
||||||
|
<option disabled value="-1">Pick a visualizer</option>
|
||||||
|
<option v-for="v in visualizers" :key="v.id" :value="v.id">{{ v.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div ref="el" class="viz"/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { faUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { logger } from '@/utils'
|
||||||
|
import { preferenceStore as preferences, visualizerStore } from '@/stores'
|
||||||
|
|
||||||
|
const visualizers = visualizerStore.all
|
||||||
|
let destroyVisualizer: () => void
|
||||||
|
|
||||||
|
const el = ref<HTMLElement>()
|
||||||
|
const selectedId = ref<Visualizer['id']>()
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
|
||||||
|
const render = async (viz: Visualizer) => {
|
||||||
|
if (!el.value) {
|
||||||
|
await nextTick()
|
||||||
|
await render(viz)
|
||||||
|
}
|
||||||
|
|
||||||
|
freeUp()
|
||||||
|
|
||||||
|
try {
|
||||||
|
destroyVisualizer = await viz.init(el.value!)
|
||||||
|
} catch (e) {
|
||||||
|
// in e.g., DOM testing, the call will fail due to the lack of proper API support
|
||||||
|
logger.warn('Failed to initialize visualizer', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedVisualizer = ref<Visualizer>()
|
||||||
|
|
||||||
|
watch(selectedId, id => {
|
||||||
|
preferences.visualizer = id
|
||||||
|
selectedVisualizer.value = visualizerStore.getVisualizerById(id || 'default')!
|
||||||
|
render(selectedVisualizer.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
isFullscreen.value ? document.exitFullscreen() : el.value?.requestFullscreen()
|
||||||
|
isFullscreen.value = !isFullscreen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
selectedId.value = preferences.visualizer || 'default'
|
||||||
|
|
||||||
|
if (!visualizerStore.getVisualizerById(selectedId.value)) {
|
||||||
|
selectedId.value = 'default'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const freeUp = () => {
|
||||||
|
destroyVisualizer?.()
|
||||||
|
el.value && (el.value.innerHTML = '')
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => freeUp())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
#vizContainer {
|
||||||
|
.viz {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
positioN: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifacts {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
padding: 24px;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.artifacts {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits {
|
||||||
|
padding: 14px 28px 14px 14px;
|
||||||
|
background: rgba(0, 0, 0, .5);
|
||||||
|
width: fit-content;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin-left: .5rem;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fullscreen {
|
||||||
|
// :fullscreen pseudo support is kind of buggy, so we use a class instead.
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
|
||||||
|
.close {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,63 +0,0 @@
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
id="vizContainer"
|
|
||||||
ref="el"
|
|
||||||
:class="{ fullscreen: isFullscreen }"
|
|
||||||
data-testid="visualizer"
|
|
||||||
@dblclick="toggleFullscreen"
|
|
||||||
>
|
|
||||||
<CloseModalBtn class="close" @click="hide"/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import initVisualizer from '@/utils/visualizer'
|
|
||||||
import { eventBus, logger } from '@/utils'
|
|
||||||
|
|
||||||
import CloseModalBtn from '@/components/ui/BtnCloseModal.vue'
|
|
||||||
|
|
||||||
const el = ref<HTMLElement>()
|
|
||||||
const isFullscreen = ref(false)
|
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
|
||||||
isFullscreen.value ? document.exitFullscreen() : el.value?.requestFullscreen()
|
|
||||||
isFullscreen.value = !isFullscreen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const hide = () => eventBus.emit('TOGGLE_VISUALIZER')
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
try {
|
|
||||||
initVisualizer(el.value!)
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Failed to initialize visualizer', e)
|
|
||||||
// in e.g., DOM testing, the call will fail due to the lack of proper API support
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
#vizContainer {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&.fullscreen {
|
|
||||||
// :fullscreen pseudo support is kind of buggy, so we use a class instead.
|
|
||||||
background: var(--color-bg-primary);
|
|
||||||
|
|
||||||
.close {
|
|
||||||
opacity: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.close {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -6,7 +6,6 @@ export type EventName =
|
||||||
| 'FOCUS_SEARCH_FIELD'
|
| 'FOCUS_SEARCH_FIELD'
|
||||||
| 'PLAY_YOUTUBE_VIDEO'
|
| 'PLAY_YOUTUBE_VIDEO'
|
||||||
| 'INIT_EQUALIZER'
|
| 'INIT_EQUALIZER'
|
||||||
| 'TOGGLE_VISUALIZER'
|
|
||||||
| 'SEARCH_KEYWORDS_CHANGED'
|
| 'SEARCH_KEYWORDS_CHANGED'
|
||||||
|
|
||||||
| 'SONG_CONTEXT_MENU_REQUESTED'
|
| 'SONG_CONTEXT_MENU_REQUESTED'
|
||||||
|
|
|
@ -3,3 +3,4 @@ export * from './acceptedMediaTypes'
|
||||||
export * from './genres'
|
export * from './genres'
|
||||||
export * from './routes'
|
export * from './routes'
|
||||||
export * from './audio'
|
export * from './audio'
|
||||||
|
export * from './visualizers'
|
||||||
|
|
|
@ -90,6 +90,10 @@ export const routes: Route[] = [
|
||||||
path: '/genres/(?<name>\.+)',
|
path: '/genres/(?<name>\.+)',
|
||||||
screen: 'Genre'
|
screen: 'Genre'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/visualizer',
|
||||||
|
screen: 'Visualizer'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/song/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
|
path: '/song/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
|
||||||
screen: 'Queue',
|
screen: 'Queue',
|
||||||
|
|
38
resources/assets/js/config/visualizers.ts
Normal file
38
resources/assets/js/config/visualizers.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
export const visualizers: Visualizer[] = [
|
||||||
|
{
|
||||||
|
id: 'default',
|
||||||
|
name: 'Color Pills',
|
||||||
|
init: async (container) => (await import('@/visualizers/default')).init(container),
|
||||||
|
credits: {
|
||||||
|
author: 'Justin Windle (@soulwire)',
|
||||||
|
url: 'https://codepen.io/soulwire/pen/Dscga'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plane-mesh',
|
||||||
|
name: 'Plane Mesh',
|
||||||
|
init: async (container) => (await import('@/visualizers/plane-mesh')).init(container),
|
||||||
|
credits: {
|
||||||
|
author: 'Steven Marelly (@l1ve4code)',
|
||||||
|
url: 'https://github.com/l1ve4code/3d-music-visualizer'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waveform',
|
||||||
|
name: 'Waveform',
|
||||||
|
init: async (container) => (await import('@/visualizers/waveform')).init(container),
|
||||||
|
credits: {
|
||||||
|
author: 'Suboptimal Engineer (@SuboptimalEng)',
|
||||||
|
url: 'https://github.com/SuboptimalEng/gamedex/tree/main/audio-visualizer'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fluid-cube',
|
||||||
|
name: 'Fluid Cube',
|
||||||
|
init: async (container) => (await import('@/visualizers/fluid-cube')).init(container),
|
||||||
|
credits: {
|
||||||
|
author: 'Radik (@H2xDev)',
|
||||||
|
url: 'https://codepen.io/H2xDev/pen/rRRGbv'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
|
@ -15,6 +15,7 @@ export const audioService = {
|
||||||
source: null as unknown as MediaElementAudioSourceNode,
|
source: null as unknown as MediaElementAudioSourceNode,
|
||||||
element: null as unknown as HTMLMediaElement,
|
element: null as unknown as HTMLMediaElement,
|
||||||
preampGainNode: null as unknown as GainNode,
|
preampGainNode: null as unknown as GainNode,
|
||||||
|
analyzer: null as unknown as AnalyserNode,
|
||||||
|
|
||||||
bands: [] as Band[],
|
bands: [] as Band[],
|
||||||
|
|
||||||
|
@ -24,7 +25,10 @@ export const audioService = {
|
||||||
this.context = new AudioContext()
|
this.context = new AudioContext()
|
||||||
this.preampGainNode = this.context.createGain()
|
this.preampGainNode = this.context.createGain()
|
||||||
this.source = this.context.createMediaElementSource(this.element)
|
this.source = this.context.createMediaElementSource(this.element)
|
||||||
this.source.connect(this.preampGainNode)
|
this.analyzer = this.context.createAnalyser()
|
||||||
|
|
||||||
|
this.source.connect(this.analyzer)
|
||||||
|
this.analyzer.connect(this.preampGainNode)
|
||||||
|
|
||||||
const config = equalizerStore.getConfig()
|
const config = equalizerStore.getConfig()
|
||||||
|
|
||||||
|
|
|
@ -15,3 +15,4 @@ export * from './songStore'
|
||||||
export * from './themeStore'
|
export * from './themeStore'
|
||||||
export * from './userStore'
|
export * from './userStore'
|
||||||
export * from './genreStore'
|
export * from './genreStore'
|
||||||
|
export * from './visualizerStore'
|
||||||
|
|
|
@ -14,6 +14,7 @@ interface Preferences extends Record<string, any> {
|
||||||
showAlbumArtOverlay: boolean
|
showAlbumArtOverlay: boolean
|
||||||
lyricsZoomLevel: number | null
|
lyricsZoomLevel: number | null
|
||||||
theme?: Theme['id'] | null
|
theme?: Theme['id'] | null
|
||||||
|
visualizer?: Visualizer['id'] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferenceStore = {
|
const preferenceStore = {
|
||||||
|
@ -37,7 +38,8 @@ const preferenceStore = {
|
||||||
supportBarNoBugging: false,
|
supportBarNoBugging: false,
|
||||||
showAlbumArtOverlay: true,
|
showAlbumArtOverlay: true,
|
||||||
lyricsZoomLevel: 1,
|
lyricsZoomLevel: 1,
|
||||||
theme: null
|
theme: null,
|
||||||
|
visualizer: 'default'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
init (user: User): void {
|
init (user: User): void {
|
||||||
|
|
12
resources/assets/js/stores/visualizerStore.ts
Normal file
12
resources/assets/js/stores/visualizerStore.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { visualizers } from '@/config'
|
||||||
|
|
||||||
|
export const visualizerStore = {
|
||||||
|
get all () {
|
||||||
|
return visualizers
|
||||||
|
},
|
||||||
|
|
||||||
|
getVisualizerById (id: string) {
|
||||||
|
return visualizers.find(visualizer => visualizer.id === id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
10
resources/assets/js/types.d.ts
vendored
10
resources/assets/js/types.d.ts
vendored
|
@ -389,3 +389,13 @@ type Genre = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtraPanelTab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'
|
type ExtraPanelTab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'
|
||||||
|
|
||||||
|
type Visualizer = {
|
||||||
|
init: (container: HTMLElement) => Promise<Closure>
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
credits?: {
|
||||||
|
author: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
import Sketch from 'sketch-js'
|
import Sketch from 'sketch-js'
|
||||||
import { audioService } from '@/services'
|
import { audioService } from '@/services'
|
||||||
import { random, sample } from 'lodash'
|
import { random, sample } from 'lodash'
|
||||||
|
import { noop } from '@/utils'
|
||||||
// Audio visualization originally created by Justin Windle (@soulwire)
|
|
||||||
// as seen on https://codepen.io/soulwire/pen/Dscga
|
|
||||||
|
|
||||||
const NUM_PARTICLES = 128
|
const NUM_PARTICLES = 128
|
||||||
const NUM_BANDS = 128
|
const NUM_BANDS = 128
|
||||||
|
@ -48,13 +46,12 @@ class AudioAnalyser {
|
||||||
this.audio = audioService.element
|
this.audio = audioService.element
|
||||||
this.source = audioService.source
|
this.source = audioService.source
|
||||||
|
|
||||||
this.analyser = audioService.context.createAnalyser()
|
this.analyser = audioService.analyzer
|
||||||
this.analyser.smoothingTimeConstant = this.smoothing
|
this.analyser.smoothingTimeConstant = this.smoothing
|
||||||
this.analyser.fftSize = this.bandCount * 2
|
this.analyser.fftSize = this.bandCount * 2
|
||||||
|
|
||||||
this.bands = new Uint8Array(this.analyser.frequencyBinCount)
|
this.bands = new Uint8Array(this.analyser.frequencyBinCount)
|
||||||
|
|
||||||
this.source.connect(this.analyser)
|
|
||||||
this.update()
|
this.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,7 +146,7 @@ class Particle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (container: HTMLElement) => {
|
export const init = (container: HTMLElement) => {
|
||||||
const particles: Particle[] = []
|
const particles: Particle[] = []
|
||||||
|
|
||||||
Sketch.create({
|
Sketch.create({
|
||||||
|
@ -181,4 +178,6 @@ export default (container: HTMLElement) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return noop
|
||||||
}
|
}
|
116
resources/assets/js/visualizers/fluid-cube/index.ts
Normal file
116
resources/assets/js/visualizers/fluid-cube/index.ts
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
import { audioService } from '@/services'
|
||||||
|
import { logger } from '@/utils'
|
||||||
|
import shaders from './shaders'
|
||||||
|
|
||||||
|
export const init = async (container: HTMLElement) => {
|
||||||
|
const gl = document.createElement('canvas').getContext('webgl')!
|
||||||
|
const postctx = container.appendChild(document.createElement('canvas')).getContext('2d')!
|
||||||
|
const postprocess = postctx.canvas
|
||||||
|
const canvas = gl.canvas
|
||||||
|
const cubeSize = 15
|
||||||
|
|
||||||
|
const analyzer = audioService.analyzer
|
||||||
|
analyzer.smoothingTimeConstant = 0.2
|
||||||
|
analyzer.fftSize = 128
|
||||||
|
|
||||||
|
let spectrumData = new Uint8Array(analyzer.frequencyBinCount)
|
||||||
|
|
||||||
|
const compileShader = (type, source) => {
|
||||||
|
const shader = gl.createShader(type)!
|
||||||
|
gl.shaderSource(shader, source)
|
||||||
|
gl.compileShader(shader)
|
||||||
|
|
||||||
|
const status = gl.getShaderParameter(shader, gl.COMPILE_STATUS)
|
||||||
|
if (status) return shader
|
||||||
|
|
||||||
|
logger.error('shader compile error', gl.getShaderInfoLog(shader))
|
||||||
|
gl.deleteShader(shader)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProgram = function (vertexShader, fragmentShader) {
|
||||||
|
const program = gl.createProgram()!
|
||||||
|
gl.attachShader(program, vertexShader)
|
||||||
|
gl.attachShader(program, fragmentShader)
|
||||||
|
gl.linkProgram(program)
|
||||||
|
|
||||||
|
const status = gl.getProgramParameter(program, gl.LINK_STATUS)
|
||||||
|
if (status) return program
|
||||||
|
|
||||||
|
logger.error('program link error', gl.getProgramInfoLog(program))
|
||||||
|
gl.deleteProgram(program)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vertexShader = compileShader(gl.VERTEX_SHADER, shaders.vertex(cubeSize))
|
||||||
|
const fragmentShader = compileShader(gl.FRAGMENT_SHADER, shaders.fragment())
|
||||||
|
|
||||||
|
const program = createProgram(vertexShader, fragmentShader)!
|
||||||
|
|
||||||
|
const aPosition = gl.getAttribLocation(program, 'a_pos')
|
||||||
|
const uResolution = gl.getUniformLocation(program, 'u_res')
|
||||||
|
const uFrame = gl.getUniformLocation(program, 'u_frame')
|
||||||
|
const uSpectrumValue = gl.getUniformLocation(program, 'u_spectrumValue')
|
||||||
|
|
||||||
|
const vertices: number[] = []
|
||||||
|
const vertexBuffer = gl.createBuffer()
|
||||||
|
let frame = 0
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
frame++
|
||||||
|
|
||||||
|
analyzer.getByteFrequencyData(spectrumData)
|
||||||
|
|
||||||
|
// Transfer spectrum data to shader program
|
||||||
|
gl.uniform1iv(uSpectrumValue, spectrumData)
|
||||||
|
|
||||||
|
if (postprocess.width !== postprocess.offsetWidth || postprocess.height !== postprocess.offsetHeight) {
|
||||||
|
postprocess.width = postprocess.offsetWidth
|
||||||
|
postprocess.height = postprocess.offsetHeight
|
||||||
|
canvas.width = postprocess.width
|
||||||
|
canvas.height = postprocess.height
|
||||||
|
gl.uniform2fv(uResolution, [canvas.width, canvas.height])
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.uniform1f(uFrame, frame)
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||||
|
gl.drawArrays(gl.POINTS, 0, vertices.length / 3)
|
||||||
|
|
||||||
|
// Make Bloom
|
||||||
|
postctx.globalAlpha = 1
|
||||||
|
postctx.drawImage(canvas, 0, 0)
|
||||||
|
postctx.filter = 'blur(4px)'
|
||||||
|
postctx.globalCompositeOperation = 'screen'
|
||||||
|
postctx.drawImage(canvas, 0, 0)
|
||||||
|
postctx.globalCompositeOperation = 'source-over'
|
||||||
|
postctx.filter = 'blur(0)'
|
||||||
|
|
||||||
|
requestAnimationFrame(render)
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.clearColor(0, 0, 0, 1)
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||||
|
gl.useProgram(program)
|
||||||
|
gl.uniform2fv(uResolution, new Float32Array([canvas.width, canvas.height]))
|
||||||
|
|
||||||
|
for (let i = 0; i < cubeSize ** 3; i++) {
|
||||||
|
let x = (i % cubeSize)
|
||||||
|
let y = Math.floor(i / cubeSize) % cubeSize
|
||||||
|
let z = Math.floor(i / cubeSize ** 2)
|
||||||
|
x -= cubeSize / 2 - 0.5
|
||||||
|
y -= cubeSize / 2 - 0.5
|
||||||
|
z -= cubeSize / 2 - 0.5
|
||||||
|
|
||||||
|
vertices.push(x)
|
||||||
|
vertices.push(y)
|
||||||
|
vertices.push(z)
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.enableVertexAttribArray(aPosition)
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
|
||||||
|
gl.vertexAttribPointer(aPosition, 3, gl.FLOAT, false, 0, 0)
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)
|
||||||
|
|
||||||
|
render()
|
||||||
|
|
||||||
|
return () => gl?.getExtension('WEBGL_lose_context')?.loseContext()
|
||||||
|
}
|
110
resources/assets/js/visualizers/fluid-cube/shaders.ts
Normal file
110
resources/assets/js/visualizers/fluid-cube/shaders.ts
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
export default {
|
||||||
|
vertex: (cubeSize: number) => `
|
||||||
|
attribute vec3 a_pos;
|
||||||
|
uniform vec2 u_res;
|
||||||
|
uniform float u_frame;
|
||||||
|
uniform int u_spectrumValue[128];
|
||||||
|
varying float v_frame;
|
||||||
|
varying vec3 vv_pos;
|
||||||
|
void main () {
|
||||||
|
v_frame = u_frame;
|
||||||
|
float pi = 3.141592653589793;
|
||||||
|
float rad = u_frame / 2.0 / 180.0 * pi;
|
||||||
|
int spectrumIndex = 12 + int(mod(a_pos.x + ${Math.floor(cubeSize / 2)}.0, ${cubeSize}.0) + mod(a_pos.y + ${Math.floor(cubeSize / 2)}.0, ${cubeSize ** 2}.0) + (a_pos.z + ${Math.floor(cubeSize / 2)}.0) / ${cubeSize ** 2}.0);
|
||||||
|
float value = float(u_spectrumValue[spectrumIndex]) / 100.0;
|
||||||
|
vec3 v_pos = a_pos;
|
||||||
|
vec3 t = vec3(1, 1, 1);
|
||||||
|
|
||||||
|
vv_pos = v_pos;
|
||||||
|
float dist = abs(${Math.floor(cubeSize / 2)}.0 - sqrt(vv_pos.x * vv_pos.x + vv_pos.y * vv_pos.y + vv_pos.z * vv_pos.z));
|
||||||
|
|
||||||
|
t.x = v_pos.x * cos(rad) + v_pos.z * sin(rad);
|
||||||
|
t.y = v_pos.y;
|
||||||
|
t.z = - v_pos.x * sin(rad) + v_pos.z * cos(rad);
|
||||||
|
|
||||||
|
v_pos = t;
|
||||||
|
|
||||||
|
|
||||||
|
t.x = v_pos.x * cos(rad) - v_pos.y * sin(rad);
|
||||||
|
t.y = v_pos.x * sin(rad) + v_pos.y * cos(rad);
|
||||||
|
t.z = v_pos.z;
|
||||||
|
|
||||||
|
v_pos = t;
|
||||||
|
|
||||||
|
t.x = v_pos.x;
|
||||||
|
t.y = v_pos.y * cos(rad) - v_pos.z * sin(rad);
|
||||||
|
t.z = v_pos.y * sin(rad) + v_pos.z * cos(rad);
|
||||||
|
|
||||||
|
v_pos = t;
|
||||||
|
|
||||||
|
v_pos.z -= 20.0;
|
||||||
|
|
||||||
|
// Make reaction on spectrum
|
||||||
|
v_pos.z += value * dist;
|
||||||
|
v_pos.y += value / 100.0;
|
||||||
|
|
||||||
|
v_pos.x += sin(u_frame / 30.0 + v_pos.y / 4.0) * 1.2;
|
||||||
|
v_pos.y += cos(u_frame / 20.0 + v_pos.z / 5.0) * 1.0;
|
||||||
|
|
||||||
|
|
||||||
|
v_pos.x /= v_pos.z;
|
||||||
|
v_pos.y /= v_pos.z;
|
||||||
|
|
||||||
|
v_pos.x /= u_res.x / u_res.y;
|
||||||
|
|
||||||
|
gl_Position = vec4(v_pos.xy, 0.0, 1.0);
|
||||||
|
gl_PointSize = dist;
|
||||||
|
}`,
|
||||||
|
fragment: () => `
|
||||||
|
precision mediump float;
|
||||||
|
uniform vec4 u_color;
|
||||||
|
varying float v_frame;
|
||||||
|
varying vec3 vv_pos;
|
||||||
|
float hue2rgb(float f1, float f2, float hue) {
|
||||||
|
if (hue < 0.0)
|
||||||
|
hue += 1.0;
|
||||||
|
else if (hue > 1.0)
|
||||||
|
hue -= 1.0;
|
||||||
|
float res;
|
||||||
|
if ((6.0 * hue) < 1.0)
|
||||||
|
res = f1 + (f2 - f1) * 6.0 * hue;
|
||||||
|
else if ((2.0 * hue) < 1.0)
|
||||||
|
res = f2;
|
||||||
|
else if ((3.0 * hue) < 2.0)
|
||||||
|
res = f1 + (f2 - f1) * ((2.0 / 3.0) - hue) * 6.0;
|
||||||
|
else
|
||||||
|
res = f1;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 hsl2rgb(vec3 hsl) {
|
||||||
|
vec3 rgb;
|
||||||
|
|
||||||
|
if (hsl.y == 0.0) {
|
||||||
|
rgb = vec3(hsl.z); // Luminance
|
||||||
|
} else {
|
||||||
|
float f2;
|
||||||
|
|
||||||
|
if (hsl.z < 0.5)
|
||||||
|
f2 = hsl.z * (1.0 + hsl.y);
|
||||||
|
else
|
||||||
|
f2 = hsl.z + hsl.y - hsl.y * hsl.z;
|
||||||
|
|
||||||
|
float f1 = 2.0 * hsl.z - f2;
|
||||||
|
|
||||||
|
rgb.r = hue2rgb(f1, f2, hsl.x + (1.0/3.0));
|
||||||
|
rgb.g = hue2rgb(f1, f2, hsl.x);
|
||||||
|
rgb.b = hue2rgb(f1, f2, hsl.x - (1.0/3.0));
|
||||||
|
}
|
||||||
|
return rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 hsl2rgb(float h, float s, float l) {
|
||||||
|
return hsl2rgb(vec3(h, s, l));
|
||||||
|
}
|
||||||
|
void main () {
|
||||||
|
float dist = sqrt(vv_pos.x * vv_pos.x + vv_pos.y * vv_pos.y + vv_pos.z * vv_pos.z);
|
||||||
|
float i_frame = mod(v_frame + dist * 20.0, 360.0);
|
||||||
|
gl_FragColor = vec4(hsl2rgb((i_frame) / 360.0, 1.0, .5), 1.0);
|
||||||
|
}`
|
||||||
|
}
|
121
resources/assets/js/visualizers/plane-mesh/index.ts
Normal file
121
resources/assets/js/visualizers/plane-mesh/index.ts
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import shaders from './shaders'
|
||||||
|
import planeMeshParameters from './planeMeshParameters'
|
||||||
|
import { audioService } from '@/services'
|
||||||
|
|
||||||
|
export const init = (container: HTMLElement) => {
|
||||||
|
const uniforms = {
|
||||||
|
u_time: {
|
||||||
|
type: 'f',
|
||||||
|
value: 2.0
|
||||||
|
},
|
||||||
|
u_amplitude: {
|
||||||
|
type: 'f',
|
||||||
|
value: 4.0
|
||||||
|
},
|
||||||
|
u_data_arr: {
|
||||||
|
type: 'float[64]',
|
||||||
|
value: new Uint8Array()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyser = audioService.analyzer
|
||||||
|
|
||||||
|
analyser.fftSize = 1024
|
||||||
|
const dataArray = new Uint8Array(analyser.frequencyBinCount)
|
||||||
|
|
||||||
|
const width = container.clientWidth
|
||||||
|
const height = container.clientHeight
|
||||||
|
const scene = new THREE.Scene()
|
||||||
|
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xaaaaaa)
|
||||||
|
ambientLight.castShadow = false
|
||||||
|
|
||||||
|
const spotLight = new THREE.SpotLight(0xffffff)
|
||||||
|
spotLight.intensity = 0.9
|
||||||
|
spotLight.position.set(-10, 40, 20)
|
||||||
|
spotLight.castShadow = true
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(
|
||||||
|
85,
|
||||||
|
width / height,
|
||||||
|
1,
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
camera.position.z = 80
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer()
|
||||||
|
renderer.setSize(width, height)
|
||||||
|
renderer.setClearAlpha(0)
|
||||||
|
|
||||||
|
container.appendChild(renderer.domElement)
|
||||||
|
|
||||||
|
const planeGeometry = new THREE.PlaneGeometry(64, 64, 64, 64)
|
||||||
|
const planeMaterial = new THREE.ShaderMaterial({
|
||||||
|
uniforms,
|
||||||
|
vertexShader: shaders.vertex,
|
||||||
|
fragmentShader: shaders.fragment,
|
||||||
|
wireframe: true
|
||||||
|
})
|
||||||
|
|
||||||
|
planeMeshParameters.forEach(item => {
|
||||||
|
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial)
|
||||||
|
|
||||||
|
if (item.rotation.x == undefined) {
|
||||||
|
planeMesh.rotation.y = item.rotation.y
|
||||||
|
} else {
|
||||||
|
planeMesh.rotation.x = item.rotation.x
|
||||||
|
}
|
||||||
|
|
||||||
|
planeMesh.scale.x = item.scale
|
||||||
|
planeMesh.scale.y = item.scale
|
||||||
|
planeMesh.scale.z = item.scale
|
||||||
|
planeMesh.position.x = item.position.x
|
||||||
|
planeMesh.position.y = item.position.y
|
||||||
|
planeMesh.position.z = item.position.z
|
||||||
|
|
||||||
|
scene.add(planeMesh)
|
||||||
|
})
|
||||||
|
|
||||||
|
scene.add(ambientLight)
|
||||||
|
scene.add(spotLight)
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
analyser.getByteFrequencyData(dataArray)
|
||||||
|
uniforms.u_data_arr.value = dataArray
|
||||||
|
camera.rotation.z += 0.001
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
requestAnimationFrame(animate)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowResizeHandler = () => {
|
||||||
|
const width = container.clientWidth
|
||||||
|
const height = container.clientHeight
|
||||||
|
renderer.setSize(width, height)
|
||||||
|
camera.aspect = width / height
|
||||||
|
camera.updateProjectionMatrix()
|
||||||
|
renderer.domElement.width = width
|
||||||
|
renderer.domElement.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
const wheelHandler = event => {
|
||||||
|
const val = camera.position.z + event.deltaY / 100
|
||||||
|
camera.position.z = Math.min(Math.max(val, 0), 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', windowResizeHandler)
|
||||||
|
document.addEventListener('wheel', wheelHandler)
|
||||||
|
|
||||||
|
animate()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
renderer.domElement.remove()
|
||||||
|
renderer.dispose()
|
||||||
|
window.removeEventListener('resize', windowResizeHandler)
|
||||||
|
document.removeEventListener('wheel', wheelHandler)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
const planeMeshParameters = [
|
||||||
|
{
|
||||||
|
rotation: {
|
||||||
|
x: -Math.PI / 2
|
||||||
|
},
|
||||||
|
scale: 2,
|
||||||
|
position: {
|
||||||
|
x: 0,
|
||||||
|
y: 40,
|
||||||
|
z: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotation: {
|
||||||
|
x: Math.PI / 2
|
||||||
|
},
|
||||||
|
scale: 2,
|
||||||
|
position: {
|
||||||
|
x: 0,
|
||||||
|
y: -40,
|
||||||
|
z: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotation: {
|
||||||
|
y: Math.PI / 2
|
||||||
|
},
|
||||||
|
scale: 2,
|
||||||
|
position: {
|
||||||
|
x: 40,
|
||||||
|
y: 0,
|
||||||
|
z: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotation: {
|
||||||
|
y: -Math.PI / 2
|
||||||
|
},
|
||||||
|
scale: 2,
|
||||||
|
position: {
|
||||||
|
x: -40,
|
||||||
|
y: 0,
|
||||||
|
z: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotation: {
|
||||||
|
x: -Math.PI / 2
|
||||||
|
},
|
||||||
|
scale: 2,
|
||||||
|
position: {
|
||||||
|
x: 0,
|
||||||
|
y: 40,
|
||||||
|
z: -118
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotation: {
|
||||||
|
x: Math.PI / 2
|
||||||
|
},
|
||||||
|
scale: 2,
|
||||||
|
position: {
|
||||||
|
x: 0,
|
||||||
|
y: -40,
|
||||||
|
z: -118
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotation: {
|
||||||
|
y: Math.PI / 2
|
||||||
|
},
|
||||||
|
scale: 2,
|
||||||
|
position: {
|
||||||
|
x: 40,
|
||||||
|
y: 0,
|
||||||
|
z: -118
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotation: {
|
||||||
|
y: -Math.PI / 2
|
||||||
|
},
|
||||||
|
scale: 2,
|
||||||
|
position: {
|
||||||
|
x: -40,
|
||||||
|
y: 0,
|
||||||
|
z: -118
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default planeMeshParameters
|
36
resources/assets/js/visualizers/plane-mesh/shaders.ts
Normal file
36
resources/assets/js/visualizers/plane-mesh/shaders.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
const shaders = {
|
||||||
|
vertex: `
|
||||||
|
varying float x;
|
||||||
|
varying float y;
|
||||||
|
varying float z;
|
||||||
|
varying vec3 vUv;
|
||||||
|
uniform float u_time;
|
||||||
|
uniform float u_amplitude;
|
||||||
|
uniform float[64] u_data_arr;
|
||||||
|
void main() {
|
||||||
|
vUv = position;
|
||||||
|
x = abs(position.x);
|
||||||
|
y = abs(position.y);
|
||||||
|
float floor_x = round(x);
|
||||||
|
float floor_y = round(y);
|
||||||
|
float x_multiplier = (64.0 - x) / 4.0;
|
||||||
|
float y_multiplier = (64.0 - y) / 4.0;
|
||||||
|
z = sin(u_data_arr[int(floor_x)] / 40.0 + u_data_arr[int(floor_y)] / 40.0) * u_amplitude;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position.x, position.y, z, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragment: `
|
||||||
|
varying float x;
|
||||||
|
varying float y;
|
||||||
|
varying float z;
|
||||||
|
varying vec3 vUv;
|
||||||
|
uniform float u_time;
|
||||||
|
uniform float[64] u_data_arr;
|
||||||
|
void main() {
|
||||||
|
// gl_FragColor = vec4((u_data_arr[32])/205.0, 0, (u_data_arr[8])/205.0, 1.0);
|
||||||
|
gl_FragColor = vec4((64.0 - abs(x)) / 32.0, (32.0 - abs(y)) / 32.0, (abs(x + y) / 2.0) / 32.0, 1.0);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default shaders
|
55
resources/assets/js/visualizers/waveform/index.ts
Normal file
55
resources/assets/js/visualizers/waveform/index.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import SceneInit from './lib/SceneInit'
|
||||||
|
import shaders from './lib/shaders'
|
||||||
|
import { audioService } from '@/services'
|
||||||
|
|
||||||
|
export const init = (container: HTMLElement) => {
|
||||||
|
const sceneInit = new SceneInit(container)
|
||||||
|
|
||||||
|
const analyser = audioService.analyzer
|
||||||
|
analyser.fftSize = 512
|
||||||
|
let dataArray = new Uint8Array(analyser.frequencyBinCount)
|
||||||
|
|
||||||
|
const uniforms = {
|
||||||
|
u_time: {
|
||||||
|
type: 'f',
|
||||||
|
value: 1.0
|
||||||
|
},
|
||||||
|
u_amplitude: {
|
||||||
|
type: 'f',
|
||||||
|
value: 3.0
|
||||||
|
},
|
||||||
|
u_data_arr: {
|
||||||
|
type: 'float[64]',
|
||||||
|
value: dataArray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const planeGeometry = new THREE.PlaneGeometry(64, 64, 64, 64)
|
||||||
|
|
||||||
|
const planeCustomMaterial = new THREE.ShaderMaterial({
|
||||||
|
uniforms,
|
||||||
|
vertexShader: shaders.vertex,
|
||||||
|
fragmentShader: shaders.fragment,
|
||||||
|
wireframe: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const planeMesh = new THREE.Mesh(planeGeometry, planeCustomMaterial)
|
||||||
|
planeMesh.rotation.x = -Math.PI / 2 + Math.PI / 4
|
||||||
|
planeMesh.scale.x = 2
|
||||||
|
planeMesh.scale.y = 2
|
||||||
|
planeMesh.scale.z = 2
|
||||||
|
planeMesh.position.y = 8
|
||||||
|
sceneInit.scene.add(planeMesh)
|
||||||
|
|
||||||
|
const getByteFrequency = () => {
|
||||||
|
analyser.getByteFrequencyData(dataArray)
|
||||||
|
requestAnimationFrame(getByteFrequency)
|
||||||
|
}
|
||||||
|
|
||||||
|
getByteFrequency()
|
||||||
|
|
||||||
|
sceneInit.animate()
|
||||||
|
|
||||||
|
return () => sceneInit.destroy()
|
||||||
|
}
|
83
resources/assets/js/visualizers/waveform/lib/SceneInit.ts
Normal file
83
resources/assets/js/visualizers/waveform/lib/SceneInit.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
export default class SceneInit {
|
||||||
|
private readonly fov: number
|
||||||
|
private container: HTMLElement
|
||||||
|
private readonly camera: THREE.PerspectiveCamera
|
||||||
|
private clock: THREE.Clock
|
||||||
|
public scene: THREE.Scene
|
||||||
|
private renderer: THREE.WebGLRenderer
|
||||||
|
private uniforms: any
|
||||||
|
private readonly onWindowResize: () => void
|
||||||
|
private readonly onDocumentWheel: (e: WheelEvent) => void
|
||||||
|
|
||||||
|
constructor (container: HTMLElement, fov = 36) {
|
||||||
|
this.container = container
|
||||||
|
this.fov = fov
|
||||||
|
|
||||||
|
this.camera = new THREE.PerspectiveCamera(
|
||||||
|
this.fov,
|
||||||
|
this.container.clientWidth / this.container.clientHeight,
|
||||||
|
1,
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
|
||||||
|
this.camera.position.z = 128
|
||||||
|
|
||||||
|
this.clock = new THREE.Clock()
|
||||||
|
this.scene = new THREE.Scene()
|
||||||
|
|
||||||
|
this.uniforms = {
|
||||||
|
u_time: { type: 'f', value: 1.0 },
|
||||||
|
colorB: { type: 'vec3', value: new THREE.Color(0xfff000) },
|
||||||
|
colorA: { type: 'vec3', value: new THREE.Color(0xffffff) }
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: true
|
||||||
|
})
|
||||||
|
|
||||||
|
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
|
||||||
|
container.appendChild(this.renderer.domElement)
|
||||||
|
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7)
|
||||||
|
ambientLight.castShadow = false
|
||||||
|
this.scene.add(ambientLight)
|
||||||
|
|
||||||
|
const spotLight = new THREE.SpotLight(0xffffff, 0.55)
|
||||||
|
spotLight.castShadow = true
|
||||||
|
spotLight.position.set(0, 80, 10)
|
||||||
|
this.scene.add(spotLight)
|
||||||
|
|
||||||
|
this.onWindowResize = () => {
|
||||||
|
this.camera.aspect = this.container.clientWidth / this.container.clientHeight
|
||||||
|
this.camera.updateProjectionMatrix()
|
||||||
|
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onDocumentWheel = (event: WheelEvent) => {
|
||||||
|
const val = this.camera.position.z + event.deltaY / 100
|
||||||
|
this.camera.position.z = Math.min(Math.max(val, 84), 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', this.onWindowResize, false)
|
||||||
|
document.addEventListener('wheel', this.onDocumentWheel, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
animate () {
|
||||||
|
requestAnimationFrame(this.animate.bind(this))
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
this.uniforms.u_time.value += this.clock.getDelta()
|
||||||
|
this.renderer.render(this.scene, this.camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy () {
|
||||||
|
window.removeEventListener('resize', this.onWindowResize, false)
|
||||||
|
document.removeEventListener('wheel', this.onDocumentWheel, false)
|
||||||
|
this.renderer.domElement.remove()
|
||||||
|
this.renderer.dispose()
|
||||||
|
}
|
||||||
|
}
|
30
resources/assets/js/visualizers/waveform/lib/shaders.ts
Normal file
30
resources/assets/js/visualizers/waveform/lib/shaders.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
export default {
|
||||||
|
vertex: `
|
||||||
|
varying float x;
|
||||||
|
varying float y;
|
||||||
|
varying float z;
|
||||||
|
varying vec3 vUv;
|
||||||
|
uniform float u_time;
|
||||||
|
uniform float u_amplitude;
|
||||||
|
uniform float[64] u_data_arr;
|
||||||
|
void main() {
|
||||||
|
vUv = position;
|
||||||
|
x = abs(position.x);
|
||||||
|
y = abs(position.y);
|
||||||
|
float floor_x = round(x);
|
||||||
|
float floor_y = round(y);
|
||||||
|
z = sin(u_data_arr[int(floor_x)] / 50.0 + u_data_arr[int(floor_y)] / 20.0) * u_amplitude * 2.0;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position.x, position.y, z, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragment: `
|
||||||
|
varying float x;
|
||||||
|
varying float y;
|
||||||
|
varying float z;
|
||||||
|
varying vec3 vUv;
|
||||||
|
uniform float u_time;
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = vec4((32.0 - abs(x)) / 32.0, (32.0 - abs(y)) / 32.0, (abs(x + y) / 2.0) / 32.0, 1.0);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
17
yarn.lock
17
yarn.lock
|
@ -1291,6 +1291,18 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.3.tgz#ff5e2f1902969d305225a047c8a0fd5c915cebef"
|
resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.3.tgz#ff5e2f1902969d305225a047c8a0fd5c915cebef"
|
||||||
integrity sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==
|
integrity sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==
|
||||||
|
|
||||||
|
"@types/three@^0.144.0":
|
||||||
|
version "0.144.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/three/-/three-0.144.0.tgz#a154f40122dbc3668c5424a5373f3965c6564557"
|
||||||
|
integrity sha512-psvEs6q5rLN50jUYZ3D4pZMfxTbdt3A243blt0my7/NcL6chaCZpHe2csbCtx0SOD9fI/XnF3wnVUAYZGqCSYg==
|
||||||
|
dependencies:
|
||||||
|
"@types/webxr" "*"
|
||||||
|
|
||||||
|
"@types/webxr@*":
|
||||||
|
version "0.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.0.tgz#aae1cef3210d88fd4204f8c33385a0bbc4da07c9"
|
||||||
|
integrity sha512-IUMDPSXnYIbEO2IereEFcgcqfDREOgmbGqtrMpVPpACTU6pltYLwHgVkrnYv0XhWEcjio9sYEfIEzgn3c7nDqA==
|
||||||
|
|
||||||
"@types/yauzl@^2.9.1":
|
"@types/yauzl@^2.9.1":
|
||||||
version "2.10.0"
|
version "2.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
|
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
|
||||||
|
@ -6003,6 +6015,11 @@ text-table@^0.2.0:
|
||||||
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
||||||
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
|
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
|
||||||
|
|
||||||
|
three@^0.146.0:
|
||||||
|
version "0.146.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/three/-/three-0.146.0.tgz#fd80f0d128ab4bb821a02191ae241e4e6326f17a"
|
||||||
|
integrity sha512-1lvNfLezN6OJ9NaFAhfX4sm5e9YCzHtaRgZ1+B4C+Hv6TibRMsuBAM5/wVKzxjpYIlMymvgsHEFrrigEfXnb2A==
|
||||||
|
|
||||||
throttleit@^1.0.0:
|
throttleit@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"
|
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"
|
||||||
|
|
Loading…
Reference in a new issue