├── .browserslistrc ├── .eslintrc.js ├── .gitignore ├── .npmrc ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html ├── server ├── database │ ├── connect.js │ └── models │ │ └── Sketch.js ├── index.js ├── middleware │ └── index.js └── routes │ ├── authentication │ ├── callback.js │ ├── index.js │ ├── login.js │ └── refresh.js │ ├── index.js │ └── sketches.js ├── src ├── App.vue ├── api │ ├── auth.js │ └── sketches.js ├── assets │ └── connect.jpg ├── components │ ├── common │ │ ├── Icon.vue │ │ ├── IconButton.vue │ │ ├── Renderer.vue │ │ ├── Thumbnail.vue │ │ └── UserPill.vue │ ├── education │ │ └── Education.vue │ └── visualizer │ │ ├── Code.vue │ │ ├── Connect.vue │ │ ├── ControlBar.vue │ │ ├── Keys.vue │ │ ├── SideBar.vue │ │ ├── Sketch.vue │ │ ├── control-bar │ │ ├── CurrentTrack.vue │ │ ├── FullScreen.vue │ │ ├── PlayerButtons.vue │ │ ├── SketchSelector.vue │ │ └── Sketches.vue │ │ └── side-bar │ │ ├── BeatInterval.vue │ │ ├── Configuration.vue │ │ ├── Contact.vue │ │ ├── Uniforms.vue │ │ ├── Variants.vue │ │ ├── Volume.vue │ │ └── uniforms │ │ ├── AddUniform.vue │ │ ├── Boolean.vue │ │ ├── Color.vue │ │ └── Number.vue ├── main.js ├── mixins │ ├── shuffle.js │ └── uniform.js ├── router │ └── index.js ├── sass │ ├── global.scss │ └── mixins │ │ ├── _page.scss │ │ ├── _separator.scss │ │ └── _share.scss ├── store │ ├── index.js │ ├── loop.js │ └── modules │ │ ├── education.js │ │ ├── keyboard.js │ │ ├── player.js │ │ ├── spotify.js │ │ ├── ui.js │ │ └── visualizer.js ├── util │ ├── browser.js │ ├── settings.js │ └── uniforms.js └── views │ ├── Home.vue │ ├── Privacy.vue │ └── Visualizer.vue └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | 'extends': [ 7 | 'plugin:vue/essential', 8 | 'eslint:recommended' 9 | ], 10 | parserOptions: { 11 | parser: 'babel-eslint' 12 | }, 13 | rules: { 14 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 15 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | .env -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @fortawesome:registry=https://npm.fontawesome.com/ 2 | //npm.fontawesome.com/:_authToken=${FONT_AWESOME} 3 | //registry.npmjs.org/:_authToken=${NPM_AUTH} 4 | //npm.pkg.github.com/:_authToken=${GITHUB_AUTH} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # kaleidosync 3 | > A WebGL Spotify visualizer made with [Vue](https://github.com/vuejs/vue), [D3](https://github.com/d3/d3), and [Three.js](https://github.com/mrdoob/three.js/). 4 | 5 | #### Try it out at [www.kaleidosync.com](https://www.kaleidosync.com)! 6 | 7 | ## Background 8 | The Echo Nest represents the comprehensive algorithmic analysis of music. Having been acquired by Spotify, their analysis resources are available via the [Spotify API](https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-analysis/). Each song within Spotify's library has been fully analyzed: broken up into individual beats, segments, tatums, bars, and sections. There are variables assigned to describe pitch, timbre, and more esoteric descriptors like mood and "danceability." It's even possible to derive realtime volume information, all without processing the audio stream directly. 9 | 10 | This project is my take on using this data to produce visual experiences using the power of WebGL. 11 | 12 | ## Running Locally 13 | As of version 6.0.0 you won't be able to run this project locally in any reasonable/useful way due to how coupled it is with my (unpublished) shader authoring tools. If you absolutely must get this running on your machine, feel free to reach out to me and I'll walk you through the hurdles and what you'll need to build in order for it to be useful. 14 | 15 | ## Changelog 16 | > #### Version 6.1 17 | * Introduces dev mode, allowing live-editing of shaders and the creation of editable uniforms. 18 | 19 | > #### Version 6.0 20 | * Complete re-write. 21 | * Sketches have been removed from the codebase and are now stored in a database. 22 | * New architecture connects directly with my visualizer authoring tools, enabling the publishing of new visualizers with the push of a button. 23 | * Leverages the Spotify Web Playback SDK ([when available](https://developer.spotify.com/documentation/web-playback-sdk/#supported-browsers)), and falls back to legacy polling in browsers that are unsupported. 24 | 25 | > #### Version 5.5 26 | * Cleanup / bug fixes. 27 | * There are now 8 visualizers to choose from. 28 | 29 | > #### Version 5.4 30 | * Reduces the complexity of adding new visualizers. 31 | * Reverts back to the traditional polling when running the dev server. 32 | * Surfaces a control interface for WebGL scenes. 33 | 34 | > #### Version 5.3 35 | * There are now 7 visualizers to choose from. 36 | 37 | > #### Version 5.2 38 | * Refactor / rate limit debugging. 39 | 40 | > #### Version 5.1 41 | * There are now 6 visualizers to choose from. 42 | 43 | > #### Version 5.0 44 | * Major refactor. 45 | * There are now 5 visualizers to choose from. 46 | * Includes an interface for rendering fragment shaders. 47 | 48 | > #### Version 4.0 49 | * Project backbone has been abstracted away into its own library, [spotify-viz](https://github.com/zachwinter/spotify-viz). 50 | * Adoped [@vue/cli](https://cli.vuejs.org) for the UI layer. 51 | * There are now 4 visualizers to choose from. 52 | * User settings now persist when revisiting the site. 53 | * More graceful error handling and authentication flow. 54 | * This project now fully represents what's hosted on [www.kaleidosync.com](https://www.kaleidosync.com), instead of the bare-bones implementation that it was before. 55 | > #### Version 3.0 56 | * Complete refactor with no front end dependencies. 57 | * Transitioned to webpack from gulp. 58 | * Reactive data store using ES6 Proxies, semi-inspired by Vuex. 59 | * (Hopefully) less spaghetti and more comments. 60 | 61 | > #### Version 2.0 62 | * Re-implemented with `requestAnimationFrame()` 63 | * Now mobile-friendly, even on older devices. 64 | * Improved tweening. 65 | * Adjusts itself on window resize. 66 | * More accurate syncing with Spotify, including automatic self-correction. 67 | > #### Version 1.0 68 | * Holy shit, it's working... kind of. -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'] 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kaleidosync-client", 3 | "version": "6.2.3", 4 | "scripts": { 5 | "start": "node ./server/index.js", 6 | "serve": "concurrently \"nodemon ./server/index.js --port 6868\" \"vue-cli-service serve\"", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "@fortawesome/fontawesome-svg-core": "^1.2.32", 12 | "@fortawesome/free-brands-svg-icons": "^5.15.1", 13 | "@fortawesome/pro-light-svg-icons": "^5.15.1", 14 | "@fortawesome/vue-fontawesome": "^2.0.0", 15 | "@zach.winter/common": "^1.0.13", 16 | "@zach.winter/vue-common": "^1.0.16", 17 | "axios": "^0.20.0", 18 | "body-parser": "^1.19.0", 19 | "core-js": "^3.6.5", 20 | "d3-array": "^2.8.0", 21 | "d3-interpolate": "^2.0.1", 22 | "d3-scale": "^3.2.3", 23 | "dotenv": "^8.2.0", 24 | "express": "^4.17.1", 25 | "express-history-api-fallback": "^2.2.1", 26 | "humps": "^2.0.1", 27 | "lodash": "^4.17.20", 28 | "mongoose": "^5.10.9", 29 | "query-string": "^6.13.5", 30 | "request": "^2.88.2", 31 | "three": "^0.137.0", 32 | "vue": "^2.6.11", 33 | "vue-analytics": "^5.22.1", 34 | "vue-codemirror": "^4.0.6", 35 | "vue-router": "^3.2.0", 36 | "vuex": "^3.4.0" 37 | }, 38 | "devDependencies": { 39 | "@vue/cli-plugin-babel": "~4.5.0", 40 | "@vue/cli-plugin-eslint": "~4.5.0", 41 | "@vue/cli-plugin-router": "~4.5.0", 42 | "@vue/cli-plugin-vuex": "~4.5.0", 43 | "@vue/cli-service": "~4.5.0", 44 | "babel-eslint": "^10.1.0", 45 | "concurrently": "^5.3.0", 46 | "eslint": "^6.7.2", 47 | "eslint-plugin-vue": "^6.2.2", 48 | "nodemon": "^2.0.4", 49 | "pug": "^3.0.0", 50 | "pug-plain-loader": "^1.0.0", 51 | "sass": "^1.26.5", 52 | "sass-loader": "^8.0.2", 53 | "vue-template-compiler": "^2.6.11" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zachwinter/kaleidosync/7256614d3f8002854ff128f334c4c9c7dfec2819/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Kaleidosync – A Spotify Visualizer 12 | <%= htmlWebpackPlugin.options.title %> 13 | 14 | 15 | 16 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /server/database/connect.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const mongoose = require('mongoose') 3 | 4 | module.exports = () => { 5 | mongoose.connect(process.env.DB_URL, { useNewUrlParser: true }) 6 | 7 | return new Promise(resolve => { 8 | mongoose.connection.once('open', resolve) 9 | }) 10 | } -------------------------------------------------------------------------------- /server/database/models/Sketch.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | module.exports = mongoose.model('Sketch', new mongoose.Schema({ 4 | name: { type: String, default: 'Untitled' }, 5 | date: { type: Date, default: Date.now }, 6 | shader: { type: String }, 7 | uniforms: [{ type: Object }], 8 | published: { type: Boolean, default: false }, 9 | })) -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const connectDatabase = require('./database/connect') 3 | const applyMiddleware = require('./middleware') 4 | const composeRoutes = require('./routes') 5 | const path = require('path') 6 | const fallback = require('express-history-api-fallback') 7 | 8 | ;(async () => { 9 | await connectDatabase() 10 | const app = express() 11 | applyMiddleware(app) 12 | composeRoutes(app) 13 | if (process.env.NODE_ENV === 'production') { 14 | const root = path.resolve(__dirname, '../dist') 15 | app.use(express.static(root)) 16 | app.use(fallback('index.html', { root })) 17 | } 18 | app.listen(process.env.PORT || 6868) 19 | })() 20 | -------------------------------------------------------------------------------- /server/middleware/index.js: -------------------------------------------------------------------------------- 1 | module.exports = app => { 2 | app.use((req, res, next) => { 3 | res.header('Access-Control-Allow-Origin', '*') 4 | res.header('Access-Control-Allow-Methods', 'GET') 5 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') 6 | next() 7 | }) 8 | } -------------------------------------------------------------------------------- /server/routes/authentication/callback.js: -------------------------------------------------------------------------------- 1 | const request = require('request') 2 | 3 | module.exports = app => { 4 | app.get('/api/authentication/callback', async (req, res) => { 5 | const code = req.query.code || null 6 | 7 | if (code === null) return res.json({ error: true, message: 'No login code present.' }) 8 | 9 | const config = { 10 | url: 'https://accounts.spotify.com/api/token', 11 | form: { 12 | code: code, 13 | redirect_uri: process.env.REDIRECT_URI, 14 | grant_type: 'authorization_code' 15 | }, 16 | headers: { 17 | Authorization: 'Basic ' + (new Buffer(process.env.CLIENT_ID + ':' + process.env.CLIENT_SECRET).toString('base64')) 18 | }, 19 | json: true 20 | } 21 | 22 | request.post(config, (error, response, { access_token, refresh_token }) => { 23 | res.cookie(process.env.ACCESS_TOKEN, access_token) 24 | res.cookie(process.env.REFRESH_TOKEN, refresh_token) 25 | res.cookie(process.env.REFRESH_CODE, code) 26 | if (process.env.NODE_ENV === 'production') { 27 | res.redirect(process.env.PROJECT_ROOT + '/visualizer') 28 | } else { 29 | res.redirect('http://localhost:8080/visualizer') 30 | } 31 | }) 32 | }) 33 | } -------------------------------------------------------------------------------- /server/routes/authentication/index.js: -------------------------------------------------------------------------------- 1 | const callback = require('./callback') 2 | const login = require('./login') 3 | const refresh = require('./refresh') 4 | 5 | module.exports = app => { 6 | callback(app) 7 | login(app) 8 | refresh(app) 9 | } -------------------------------------------------------------------------------- /server/routes/authentication/login.js: -------------------------------------------------------------------------------- 1 | const queryString = require('query-string') 2 | 3 | module.exports = app => { 4 | app.get('/api/authentication/login', (req, res) => { 5 | const auth_id = Math.random().toString(36).slice(5, 11).toUpperCase() 6 | const query = queryString.stringify({ 7 | response_type: 'code', 8 | scope: ["playlist-read-collaborative playlist-read-private streaming user-read-email user-read-private user-read-playback-state user-read-recently-played user-modify-playback-state"], 9 | state: auth_id, 10 | client_id: process.env.CLIENT_ID, 11 | redirect_uri: process.env.REDIRECT_URI, 12 | }) 13 | res.cookie(process.env.STATE_KEY, auth_id) 14 | res.redirect('https://accounts.spotify.com/authorize?' + query) 15 | }) 16 | } -------------------------------------------------------------------------------- /server/routes/authentication/refresh.js: -------------------------------------------------------------------------------- 1 | const request = require('request') 2 | 3 | module.exports = app => { 4 | app.get('/api/authentication/refresh', (req, res, next) => { 5 | const refresh_token = req.query.token 6 | 7 | if (!refresh_token) { 8 | res.status(400) 9 | res.send({ success: false, error: 'No token provided.' }) 10 | return 11 | } 12 | 13 | const authOptions = { 14 | url: 'https://accounts.spotify.com/api/token', 15 | headers: { 16 | 'Authorization': 'Basic ' + (new Buffer(process.env.CLIENT_ID + ':' + process.env.CLIENT_SECRET).toString('base64')) 17 | }, 18 | form: { 19 | refresh_token, 20 | grant_type: 'refresh_token' 21 | }, 22 | json: true 23 | } 24 | 25 | request.post(authOptions, (error, response, body) => { 26 | if (!error && response.statusCode === 200) { 27 | const access_token = body.access_token 28 | res.send({ success: true, access_token }) 29 | } else { 30 | res.status(401) 31 | res.send(error) 32 | } 33 | }) 34 | }) 35 | } -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | const authentication = require('./authentication') 2 | const sketches = require('./sketches') 3 | 4 | module.exports = app => { 5 | authentication(app) 6 | sketches(app) 7 | } -------------------------------------------------------------------------------- /server/routes/sketches.js: -------------------------------------------------------------------------------- 1 | const Sketch = require('../database/models/Sketch') 2 | 3 | module.exports = async app => { 4 | app.get('/api/sketches/all-published', async (req, res) => { 5 | try { 6 | const sketches = await Sketch.find({ published: true }) 7 | res.json({ success: true, sketches }) 8 | } catch (e) { 9 | console.log(e) 10 | res.json({ success: false, error: e }) 11 | } 12 | }) 13 | } -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 13 | 81 | 82 | -------------------------------------------------------------------------------- /src/api/auth.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export async function getAuthID () { 4 | try { 5 | const { data } = await axios.get(PROJECT_ROOT + '/api/authentication/auth') // eslint-disable-line 6 | if (data.success) { 7 | return { 8 | success: true, 9 | auth_id: data.auth_id 10 | } 11 | } 12 | throw new Error('Could not fetch `auth_id`') 13 | } catch (e) { 14 | return { 15 | success: false, 16 | error: JSON.stringify(e) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/api/sketches.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export async function fetchSketches () { 4 | try { 5 | const { data } = await axios.get(`${PROJECT_ROOT}/api/sketches/all-published`) // eslint-disable-line 6 | return data 7 | } catch (e) { 8 | console.log(e) 9 | } 10 | } -------------------------------------------------------------------------------- /src/assets/connect.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zachwinter/kaleidosync/7256614d3f8002854ff128f334c4c9c7dfec2819/src/assets/connect.jpg -------------------------------------------------------------------------------- /src/components/common/Icon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/common/IconButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 54 | 55 | -------------------------------------------------------------------------------- /src/components/common/Renderer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 268 | 269 | -------------------------------------------------------------------------------- /src/components/common/Thumbnail.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 40 | 41 | -------------------------------------------------------------------------------- /src/components/common/UserPill.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 42 | 43 | -------------------------------------------------------------------------------- /src/components/education/Education.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 38 | 39 | -------------------------------------------------------------------------------- /src/components/visualizer/Code.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 161 | 162 | -------------------------------------------------------------------------------- /src/components/visualizer/Connect.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 22 | 23 | -------------------------------------------------------------------------------- /src/components/visualizer/ControlBar.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 44 | 45 | -------------------------------------------------------------------------------- /src/components/visualizer/Keys.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/visualizer/SideBar.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 58 | 59 | -------------------------------------------------------------------------------- /src/components/visualizer/Sketch.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/visualizer/control-bar/CurrentTrack.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 31 | 32 | -------------------------------------------------------------------------------- /src/components/visualizer/control-bar/FullScreen.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/visualizer/control-bar/PlayerButtons.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 39 | 40 | -------------------------------------------------------------------------------- /src/components/visualizer/control-bar/SketchSelector.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 21 | 22 | -------------------------------------------------------------------------------- /src/components/visualizer/control-bar/Sketches.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 92 | 93 | -------------------------------------------------------------------------------- /src/components/visualizer/side-bar/BeatInterval.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 27 | 28 | -------------------------------------------------------------------------------- /src/components/visualizer/side-bar/Configuration.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 49 | 50 | -------------------------------------------------------------------------------- /src/components/visualizer/side-bar/Contact.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 20 | 21 | 37 | -------------------------------------------------------------------------------- /src/components/visualizer/side-bar/Uniforms.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 127 | 128 | -------------------------------------------------------------------------------- /src/components/visualizer/side-bar/Variants.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 60 | 61 | -------------------------------------------------------------------------------- /src/components/visualizer/side-bar/Volume.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | 24 | -------------------------------------------------------------------------------- /src/components/visualizer/side-bar/uniforms/AddUniform.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 57 | 58 | -------------------------------------------------------------------------------- /src/components/visualizer/side-bar/uniforms/Boolean.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 43 | 44 | -------------------------------------------------------------------------------- /src/components/visualizer/side-bar/uniforms/Color.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 41 | 42 | -------------------------------------------------------------------------------- /src/components/visualizer/side-bar/uniforms/Number.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 61 | 62 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import store from './store' 5 | import VueAnalytics from 'vue-analytics' 6 | import { library } from '@fortawesome/fontawesome-svg-core' 7 | import { 8 | faUser, 9 | faPlay, 10 | faPause, 11 | faStepForward, 12 | faStepBackward, 13 | faEllipsisVAlt, 14 | faPlus, 15 | faExpand, 16 | faTimes, 17 | faSlidersH, 18 | faChevronRight, 19 | faChevronLeft, 20 | faEnvelope, 21 | faSyncAlt, 22 | faCog 23 | } from '@fortawesome/pro-light-svg-icons' 24 | import { faSpotify, faInstagram, faTelegram, faGithub } from '@fortawesome/free-brands-svg-icons' 25 | import Icon from '@/components/common/Icon' 26 | 27 | Vue.config.productionTip = false 28 | 29 | ;[ 30 | faUser, 31 | faPlay, 32 | faPause, 33 | faStepForward, 34 | faStepBackward, 35 | faEllipsisVAlt, 36 | faPlus, 37 | faExpand, 38 | faTimes, 39 | faSlidersH, 40 | faChevronRight, 41 | faChevronLeft, 42 | faSpotify, 43 | faInstagram, 44 | faEnvelope, 45 | faTelegram, 46 | faGithub, 47 | faSyncAlt, 48 | faCog 49 | ].forEach(icon => library.add(icon)) 50 | 51 | Vue.component('Icon', Icon) 52 | 53 | // eslint-disable-next-line 54 | if (PRODUCTION && GOOGLE_ANALYTICS) { 55 | Vue.use(VueAnalytics, { 56 | // eslint-disable-next-line 57 | id: GOOGLE_ANALYTICS, 58 | router 59 | }) 60 | } 61 | 62 | new Vue({ 63 | router, 64 | store, 65 | render: h => h(App) 66 | }).$mount('#app') -------------------------------------------------------------------------------- /src/mixins/shuffle.js: -------------------------------------------------------------------------------- 1 | import { bind } from '@zach.winter/vue-common/util/store' 2 | 3 | export default { 4 | data: () => ({ 5 | intervalIndex: 0, 6 | }), 7 | computed: { 8 | ...bind([ 9 | 'player/activeIntervals', 10 | 'player/shuffleVariants', 11 | 'player/shuffleInterval', 12 | 'player/shuffleIntervalMultiplier', 13 | 'visualizer/activeSketch', 14 | 'visualizer/activeVariant', 15 | ]), 16 | sketchId () { 17 | return this.activeSketch?._id || null 18 | } 19 | }, 20 | watch: { 21 | async sketchId () { 22 | this.intervalIndex = 0 23 | }, 24 | activeIntervals (val) { 25 | if (!val || !this.shuffleVariants) return 26 | const interval = val[this.shuffleInterval] 27 | if (!interval || this.intervalIndex === interval.index) return 28 | const totalVariants = this.activeSketch.uniforms.length - 1 29 | this.intervalIndex = interval.index 30 | if (this.intervalIndex % this.shuffleIntervalMultiplier !== 0) return 31 | this.$store.commit('visualizer/SET_TWEEN_DURATION', interval.duration * this.shuffleIntervalMultiplier / 2) 32 | if (this.activeVariant >= totalVariants) { 33 | this.$store.commit('visualizer/SET_ACTIVE_VARIANT', 0) 34 | } else { 35 | this.$store.commit('visualizer/SET_ACTIVE_VARIANT', this.activeVariant + 1) 36 | } 37 | } 38 | }, 39 | } -------------------------------------------------------------------------------- /src/mixins/uniform.js: -------------------------------------------------------------------------------- 1 | import { bind } from '@zach.winter/vue-common/util/store' 2 | 3 | export default { 4 | computed: { 5 | ...bind(['ui/uniform', 'ui/editingUniform']), 6 | hidden () { 7 | return this.editingUniform && this.uniform !== this.value.name 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | import Home from '@/views/Home' 4 | import Visualizer from '@/views/Visualizer' 5 | 6 | Vue.use(VueRouter) 7 | 8 | const routes = [ 9 | { 10 | path: '/', 11 | name: 'Home', 12 | component: Home 13 | }, 14 | { 15 | path: '/visualizer', 16 | name: 'Visualizer', 17 | component: Visualizer 18 | } 19 | ] 20 | 21 | const router = new VueRouter({ 22 | mode: 'history', 23 | base: process.env.BASE_URL, 24 | routes 25 | }) 26 | 27 | export default router 28 | -------------------------------------------------------------------------------- /src/sass/global.scss: -------------------------------------------------------------------------------- 1 | @use "~@zach.winter/common/sass/global" as *; 2 | 3 | @forward 'mixins/page'; 4 | @forward 'mixins/separator'; 5 | @forward 'mixins/share'; 6 | 7 | $form-control-background: transparent; 8 | $form-control-color: $white; 9 | $form-control-border: none; 10 | $form-control-height: 40px; 11 | $outer-padding: 30px; 12 | $base-margin: 1rem; 13 | $control-height: 80px; 14 | $sidebar-width: 500px; 15 | $ui-color: rgba(0, 0, 0, .75); 16 | $base-easing: cubic-bezier(.8,.3,.25,1); 17 | $bounce-easing: cubic-bezier(1,0,.5,1); 18 | $spotify-green: #65D36E; 19 | $pink: #F97583; 20 | 21 | @forward "~@zach.winter/common/sass/global"; -------------------------------------------------------------------------------- /src/sass/mixins/_page.scss: -------------------------------------------------------------------------------- 1 | @mixin page { 2 | width: 100%; 3 | height: 100%; 4 | position: relative; 5 | } -------------------------------------------------------------------------------- /src/sass/mixins/_separator.scss: -------------------------------------------------------------------------------- 1 | @use "~@zach.winter/common/sass/global" as *; 2 | 3 | @mixin separator ($color: $red, $spacing: 3px) { 4 | @include flex(flex-start, space-between); 5 | margin: 2rem 0 .5rem 0; 6 | width: 100%; 7 | line-height: 40px; 8 | padding-bottom: $spacing; 9 | border-bottom: 1px solid $color; 10 | } -------------------------------------------------------------------------------- /src/sass/mixins/_share.scss: -------------------------------------------------------------------------------- 1 | @mixin share { 2 | font-family: Share, sans-serif; 3 | text-transform: uppercase; 4 | font-weight: normal; 5 | } -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import spotify from './modules/spotify' 4 | import player from './modules/player' 5 | import visualizer from './modules/visualizer' 6 | import ui from './modules/ui' 7 | import keyboard from './modules/keyboard' 8 | import education from './modules/education' 9 | import { composeMutations } from '@zach.winter/vue-common/util/store' 10 | import cloneDeep from 'lodash/cloneDeep' 11 | import initLoop from './loop' 12 | import { detectSafari, detectMobile } from '@/util/browser' 13 | 14 | Vue.use(Vuex) 15 | 16 | const state = { 17 | loaded: false, 18 | isSafari: detectSafari(), 19 | isMobile: detectMobile() 20 | } 21 | 22 | export default new Vuex.Store({ 23 | state, 24 | mutations: composeMutations(state), 25 | modules: { 26 | spotify, 27 | player, 28 | visualizer, 29 | ui, 30 | keyboard, 31 | education 32 | }, 33 | actions: { 34 | async init ({ dispatch, commit }) { 35 | initLoop() 36 | dispatch('keyboard/init') 37 | await Promise.all([ 38 | document.fonts.ready, 39 | dispatch('visualizer/fetchSketches') 40 | ]) 41 | dispatch('visualizer/init') 42 | dispatch('ui/initHover') 43 | commit('SET_LOADED', true) 44 | }, 45 | }, 46 | getters: { 47 | legacy () { 48 | return true 49 | }, 50 | activeSketch (state) { 51 | return cloneDeep(state.visualizer.sketches.find(({ _id: id }) => state.visualizer.activeSketchId === id)) 52 | }, 53 | activeSketchIndex (state) { 54 | const sketch = state.visualizer.sketches.find(({ _id }) => _id === state.visualizer.activeSketchId) 55 | return state.visualizer.sketches.indexOf(sketch) 56 | } 57 | } 58 | }) -------------------------------------------------------------------------------- /src/store/loop.js: -------------------------------------------------------------------------------- 1 | import Observe from '@zach.winter/common/js/observe' 2 | 3 | export default function initLoop (cb = () => {}) { 4 | /** 5 | * For anyone who's paying attention, I originally had a single rAF loop, in which I would commit the current 6 | * timestamp, and then `watch` that property to drive animations. Works great! Until you try to use Vue dev tools. 7 | * They're not meant to handle that many mutations per second, even if the mutations don't affect the DOM. 8 | * 9 | * Here we have the same functionality using Proxies under the hood: 10 | */ 11 | 12 | window.__KALEIDOSYNC_LOOP__ = Observe({ 13 | tick: 0, 14 | activeIntervals: null, 15 | volume: 0, 16 | trackProgress: 0, 17 | trackProgressMs: 0, 18 | hover: false, 19 | hoverTimeout: null 20 | }) 21 | 22 | /** 23 | * And with it, you can do: 24 | * 25 | * window.__KALEIDOSYNC_LOOP__.watch('tick', now => console.log(now)) 26 | */ 27 | 28 | const tick = (now) => { 29 | window.requestAnimationFrame(tick) 30 | window.__KALEIDOSYNC_LOOP__.tick = now 31 | cb() 32 | } 33 | 34 | window.requestAnimationFrame(tick) 35 | } -------------------------------------------------------------------------------- /src/store/modules/education.js: -------------------------------------------------------------------------------- 1 | import { buildModule } from '@zach.winter/vue-common/util/store' 2 | import { setting, types } from '@/util/settings' 3 | 4 | const educated = setting('educated', false, types.boolean, { session: true }); 5 | 6 | const state = { 7 | get educated() { return educated.get() }, 8 | set educated(value) { educated.set(value) }, 9 | } 10 | 11 | const actions = { 12 | 13 | } 14 | 15 | export default buildModule({ state, actions }) -------------------------------------------------------------------------------- /src/store/modules/keyboard.js: -------------------------------------------------------------------------------- 1 | import { buildModule } from '@zach.winter/vue-common/util/store' 2 | 3 | const state = { 4 | super: false, 5 | escape: 0, 6 | enter: 0, 7 | left: 0, 8 | right: 0 9 | } 10 | 11 | const actions = { 12 | init ({ commit, dispatch, rootState, state }) { 13 | window.addEventListener('keydown', e => { 14 | if (rootState.visualizer.devMode) return 15 | 16 | if (e.key === 'v') { 17 | dispatch('ui/toggleSketchSelector', null, { root: true }) 18 | } 19 | 20 | if (e.key === 'c') { 21 | dispatch('ui/toggleSideBar', null, { root: true }) 22 | } 23 | 24 | if (e.key === 'f') { 25 | dispatch('ui/toggleFullScreen', null, { root: true }) 26 | } 27 | 28 | if (e.key === 'b') { 29 | dispatch('player/toggleBeatInterval', null, { root: true }) 30 | } 31 | 32 | if (e.key === 's') { 33 | dispatch('player/toggleShuffle', null, { root: true }) 34 | } 35 | 36 | if (e.key === 'Enter') { 37 | commit('SET_ENTER', state.enter + 1) 38 | if (rootState.ui.sketchSelectorVisible) { 39 | return commit('ui/SET_SKETCH_SELECTOR_VISIBLE', false, { root: true }) 40 | } 41 | } 42 | 43 | if (e.key === 'Escape') { 44 | commit('SET_ESCAPE', state.escape + 1) 45 | commit('ui/SET_FULL_SCREEN', false, { root: true }) 46 | if (rootState.ui.sketchSelectorVisible) { 47 | commit('ui/SET_SKETCH_SELECTOR_VISIBLE', false, { root: true }) 48 | } 49 | } 50 | 51 | if (e.key === 'ArrowLeft') commit('SET_LEFT', state.left + 1) 52 | if (e.key === 'ArrowRight') commit('SET_RIGHT', state.right + 1) 53 | }, true) 54 | 55 | window.addEventListener('keyup', ({ key }) => { 56 | if (rootState.visualizer.devMode) return 57 | if (key === 'Meta') commit('SET_SUPER', false) 58 | }, true) 59 | }, 60 | 61 | async determineCommand ({ dispatch }, e) { 62 | switch (e.key) { 63 | case '1': 64 | e.preventDefault() 65 | dispatch('editor/selectTab', 0, { root: true }) 66 | return 67 | case '2': 68 | e.preventDefault() 69 | dispatch('editor/selectTab', 1, { root: true }) 70 | return 71 | case '3': 72 | e.preventDefault() 73 | dispatch('editor/selectTab', 2, { root: true }) 74 | return 75 | case '4': 76 | e.preventDefault() 77 | dispatch('editor/selectTab', 3, { root: true }) 78 | return 79 | case '5': 80 | e.preventDefault() 81 | dispatch('editor/selectTab', 4, { root: true }) 82 | return 83 | case '6': 84 | e.preventDefault() 85 | dispatch('editor/selectTab', 5, { root: true }) 86 | return 87 | case '7': 88 | e.preventDefault() 89 | dispatch('editor/selectTab', 6, { root: true }) 90 | return 91 | case '8': 92 | e.preventDefault() 93 | dispatch('editor/selectTab', 7, { root: true }) 94 | return 95 | case '9': 96 | e.preventDefault() 97 | dispatch('editor/selectTab', 8, { root: true }) 98 | return 99 | } 100 | } 101 | } 102 | 103 | export default buildModule({ state, actions }) -------------------------------------------------------------------------------- /src/store/modules/player.js: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash/cloneDeep' 2 | import min from 'd3-array/src/min' 3 | import max from 'd3-array/src/max' 4 | import mean from 'd3-array/src/mean' 5 | import interpolateNumber from 'd3-interpolate/src/number' 6 | import scaleLinear from 'd3-scale/src/linear' 7 | import { buildModule } from '@zach.winter/vue-common/util/store' 8 | import { loadExternalScript } from '@zach.winter/common/js/dom' 9 | import ease from '@zach.winter/common/js/ease' 10 | import { pause } from '@zach.winter/common/js/timing' 11 | import { setting, types } from '@/util/settings' 12 | 13 | const beatInterval = setting('beatInterval', 'beats', types.enum(['beats', 'tatums'])); 14 | const shuffleVariants = setting('shuffleVariants', true, types.boolean); 15 | const shuffleInterval = setting('shuffleInterval', 'bars', types.enum(['bars', 'beats'])); 16 | const shuffleIntervalMultiplier = setting('shuffleIntervalMultiplier', 2, types.number); 17 | 18 | const state = { 19 | intervalTypes: [ 20 | 'segments', 21 | 'tatums', 22 | 'beats', 23 | 'bars', 24 | 'sections' 25 | ], 26 | initialized: false, 27 | currentTrack: null, 28 | activeIntervals: null, 29 | initialPosition: null, 30 | trackDuration: null, 31 | paused: false, 32 | nextTracks: null, 33 | trackProgress: null, 34 | trackProgressMs: null, 35 | trackAnalysis: null, 36 | volumeQueues: {}, 37 | volume: 1, 38 | songVolume: null, 39 | 40 | get beatInterval() { return beatInterval.get() }, 41 | set beatInterval(value) { beatInterval.set(value) }, 42 | 43 | get shuffleVariants() { return shuffleVariants.get() }, 44 | set shuffleVariants(value) { shuffleVariants.set(value) }, 45 | 46 | get shuffleInterval() { return shuffleInterval.get() }, 47 | set shuffleInterval(value) { shuffleInterval.set(value) }, 48 | 49 | get shuffleIntervalMultiplier() { return shuffleIntervalMultiplier.get() }, 50 | set shuffleIntervalMultiplier(value) { shuffleIntervalMultiplier.set(value) }, 51 | 52 | connected: false, 53 | volumeSmoothing: 30, 54 | volumeReference: 20, 55 | volumeReferenceMultiplier: 2, 56 | legacy: true, 57 | timestamp: null, 58 | start: null 59 | } 60 | 61 | const actions = { 62 | async createPlayer ({ dispatch, commit, rootState }) { 63 | return new Promise(resolve => { 64 | window.onSpotifyWebPlaybackSDKReady = async () => { 65 | window.$player = new window.Spotify.Player({ 66 | name: 'Kaleidosync', 67 | getOAuthToken: cb => { cb(rootState.spotify.accessToken) } 68 | }) 69 | await window.$player.connect() 70 | dispatch('attachListeners') 71 | commit('SET_INITIALIZED', true) 72 | resolve() 73 | } 74 | loadExternalScript('https://sdk.scdn.co/spotify-player.js') 75 | }) 76 | }, 77 | 78 | async legacyConnect ({ commit, dispatch, state }) { 79 | if (!state.legacy) commit('SET_LEGACY', true) 80 | const track = await dispatch('spotify/getCurrentlyPlaying', null, { root: true }) 81 | if (track.is_playing) { 82 | if (!state.connected) commit('SET_CONNECTED', true) 83 | if (!state.initialized) commit('SET_INITIALIZED', true) 84 | await dispatch('fetchTrackAnalysis', track.item) 85 | commit('SET_CURRENT_TRACK', track.item) 86 | commit('SET_TIMESTAMP', track.timestamp) 87 | commit('SET_INITIAL_POSITION', Date.now() - track.timestamp) 88 | commit('SET_TRACK_DURATION', track.item.duration_ms) 89 | commit('SET_START', window.performance.now()) 90 | commit('SET_PAUSED', false) 91 | } else { 92 | commit('SET_PAUSED', true) 93 | commit('SET_ACTIVE_INTERVALS', null) 94 | await pause(5000) 95 | await dispatch('legacyConnect') 96 | } 97 | }, 98 | 99 | attachListeners ({ dispatch, commit, state }) { 100 | window.$player.addListener('player_state_changed', async (o) => { 101 | if (!state.connected) commit('SET_CONNECTED', true) 102 | const { position, duration, paused, track_window: { next_tracks, current_track }} = o 103 | if (!state.currentTrack || state.currentTrack.id !== current_track.id) { 104 | commit('SET_CURRENT_TRACK', current_track) 105 | commit('SET_ACTIVE_INTERVALS', null) 106 | await dispatch('fetchTrackAnalysis', current_track) 107 | dispatch('resetVolumeQueues') 108 | } 109 | commit('SET_INITIAL_POSITION', position) 110 | commit('SET_TRACK_DURATION', duration) 111 | commit('SET_PAUSED', paused) 112 | commit('SET_NEXT_TRACKS', next_tracks) 113 | if (paused) { 114 | commit('SET_ACTIVE_INTERVALS', null) 115 | } 116 | }) 117 | }, 118 | 119 | async sync ({ dispatch }) { 120 | if (!window.$player) return 121 | const _state = await window?.$player?.getCurrentState() || null 122 | if (!_state) return 123 | const { position } = _state 124 | window.__KALEIDOSYNC_LOOP__.volume = Math.pow(await dispatch('_getVolume', position), 3) 125 | await dispatch('determineActiveIntervals', position) 126 | }, 127 | 128 | async legacySync ({ dispatch, state }) { 129 | if (!state.currentTrack) return 130 | 131 | window.__KALEIDOSYNC_LOOP__.trackProgressMs = Date.now() - state.timestamp 132 | window.__KALEIDOSYNC_LOOP__.trackProgress = Math.min(window.__KALEIDOSYNC_LOOP__.trackProgressMs / state.trackDuration, 1) 133 | 134 | if (window.__KALEIDOSYNC_LOOP__.trackProgress === 1) { 135 | /** 136 | * The current song has finished and the next song has not started yet 137 | * so we fetch the currently playing song again and again until 138 | * the next song starts and progress goes down to 0-isch 139 | * NOTE: 140 | * we could delay polling here, but this might be 141 | * noticeable if crossfade is active! 142 | */ 143 | // await pause(500); 144 | return await dispatch('legacyConnect') 145 | } 146 | 147 | window.__KALEIDOSYNC_LOOP__.volume = Math.pow(await dispatch('_getVolume', window.__KALEIDOSYNC_LOOP__.trackProgressMs), 3) 148 | await dispatch('determineActiveIntervals', window.__KALEIDOSYNC_LOOP__.trackProgressMs) 149 | }, 150 | 151 | async fetchTrackAnalysis ({ state, dispatch, commit }, track) { 152 | const analysis = await dispatch('spotify/getTrackAnalysis', track.id, { root: true }) 153 | state.intervalTypes.forEach((t) => { 154 | const type = analysis[t] 155 | type[0].duration = type[0].start + type[0].duration 156 | type[0].start = 0 157 | type[type.length - 1].duration = (track.duration_ms / 1000) - type[type.length - 1].start 158 | type.forEach((interval) => { 159 | if (interval.loudness_max_time) { 160 | interval.loudness_max_time = interval.loudness_max_time * 1000 161 | } 162 | interval.start = interval.start * 1000 163 | interval.duration = interval.duration * 1000 164 | }) 165 | }) 166 | const volume = analysis.segments.reduce((acc, val) => { 167 | acc.push(val.loudness_max) 168 | acc.push(val.loudness_start) 169 | return acc 170 | }, []) 171 | const _min = min(volume) 172 | const _mean = mean(volume) 173 | commit('SET_SONG_VOLUME', { min: _min, mean: _mean }) 174 | commit('SET_TRACK_ANALYSIS', analysis) 175 | }, 176 | 177 | determineActiveIntervals ({ state }, trackProgressMs) { 178 | if (!state.trackAnalysis) return 179 | 180 | const determineInterval = (type) => { 181 | const analysis = state.trackAnalysis[type] 182 | for (let i = 0; i < analysis.length; i++) { 183 | if (i === (analysis.length - 1)) return i 184 | if (analysis[i].start < trackProgressMs && trackProgressMs < analysis[i + 1].start) return i 185 | } 186 | } 187 | 188 | const active = state.intervalTypes.reduce((acc, type) => { 189 | const index = determineInterval(type) 190 | const interval = { ...state.trackAnalysis[type][index], index } 191 | const { start, duration } = interval 192 | const elapsed = trackProgressMs - start 193 | interval.elapsed = elapsed 194 | interval.progress = elapsed / duration 195 | acc[type] = interval 196 | return acc 197 | }, {}) 198 | 199 | window.__KALEIDOSYNC_LOOP__.activeIntervals = active 200 | // commit('SET_ACTIVE_INTERVALS', active) 201 | }, 202 | 203 | registerVolumeQueue ({ commit, state }, { name, totalSamples, smoothing }) { 204 | const queues = cloneDeep(state.volumeQueues) 205 | queues[name] = { 206 | values: [], 207 | volume: .5, 208 | average: .5, 209 | min: 0, 210 | max: 1, 211 | totalSamples, 212 | smoothing 213 | } 214 | commit('SET_VOLUME_QUEUES', queues) 215 | }, 216 | 217 | resetVolumeQueues ({ commit, state }) { 218 | const queues = cloneDeep(state.volumeQueues) 219 | for (let key in queues) { 220 | queues[key].values = [] 221 | } 222 | commit('SET_VOLUME_QUEUES', queues) 223 | }, 224 | 225 | async processVolumeQueues ({ state, commit, dispatch }) { 226 | const volume = await dispatch('getVolume') 227 | const queues = cloneDeep(state.volumeQueues) 228 | for (let key in queues) { 229 | queues[key].values.unshift(volume) 230 | while (queues[key].values.length > queues[key].totalSamples) queues[key].values.pop() 231 | queues[key].average = mean(queues[key].values) 232 | queues[key].min = min(queues[key].values) 233 | queues[key].max = max(queues[key].values) 234 | const sizeScale = scaleLinear([queues[key].min, queues[key].average], [0, 1]) 235 | const latest = mean(queues[key].values.slice(0, queues[key].smoothing)) 236 | queues[key].volume = sizeScale(latest) 237 | } 238 | commit('SET_VOLUME_QUEUES', queues) 239 | }, 240 | 241 | getVolume ({ state }) { 242 | if (!state.activeIntervals) return 1 243 | 244 | const { 245 | loudness_max, 246 | loudness_start, 247 | loudness_max_time, 248 | duration, 249 | elapsed, 250 | start, 251 | index 252 | } = cloneDeep(state.activeIntervals.segments) 253 | 254 | if (!state.trackAnalysis.segments || !state.trackAnalysis.segments[index + 1]) return .5 255 | 256 | const next = state.trackAnalysis.segments?.[index + 1]?.loudness_start 257 | const current = start + elapsed 258 | const easing = 'linear' 259 | 260 | if (elapsed < loudness_max_time) { 261 | const progress = ease(Math.max(Math.min(1, elapsed / loudness_max_time), 0), easing) 262 | return interpolateNumber(loudness_start, loudness_max)(progress) 263 | } else { 264 | const _start = start + loudness_max_time 265 | const _elapsed = current - _start 266 | const _duration = duration - loudness_max_time 267 | const progress = ease(Math.max(Math.min(1, _elapsed / _duration), 0), easing) 268 | return interpolateNumber(loudness_max, next)(progress) 269 | } 270 | }, 271 | 272 | getSegment ({ state }, progress) { 273 | try { 274 | const analysis = state.trackAnalysis.segments 275 | for (let i = 0; i < analysis.length; i++) { 276 | if (i === (analysis.length - 1)) return i 277 | if (analysis[i].start < progress && progress < analysis[i + 1].start) return i 278 | } 279 | } catch { 280 | return -1 281 | } 282 | }, 283 | 284 | async _getVolume ({ state, dispatch }, trackProgressMs) { 285 | const progress = trackProgressMs 286 | const base = [] 287 | const values = [] 288 | const index = await dispatch('getSegment', progress) 289 | // const reference = parseFloat(state.volumeReference) 290 | 291 | if (!state.trackAnalysis || !state.trackAnalysis.segments[index + 1]) return 1 292 | 293 | for (let i = -state.volumeSmoothing; i <= state.volumeSmoothing; i++) { 294 | const multiplier = parseFloat(state.volumeReferenceMultiplier) 295 | if (state.trackAnalysis.segments[index + (i * multiplier)]) { 296 | base.push(state.trackAnalysis.segments[index + (i * multiplier)].loudness_max) 297 | base.push(state.trackAnalysis.segments[index + (i * multiplier)].loudness_start) 298 | } 299 | } 300 | 301 | for (let i = -parseFloat(state.volumeSmoothing); i <= parseFloat(state.volumeSmoothing); i++) { 302 | const p = progress + (i * state.volumeReferenceMultiplier) 303 | const index = await dispatch('getSegment', p) 304 | const segment = state.trackAnalysis.segments[index] 305 | const { start, duration } = segment 306 | const elapsed = p - start 307 | segment.elapsed = elapsed 308 | segment.progress = elapsed / duration 309 | const { 310 | loudness_max, 311 | loudness_start, 312 | loudness_max_time, 313 | duration: _duration, 314 | elapsed: _elapsed, 315 | start: _start 316 | } = segment 317 | const next = state.trackAnalysis.segments?.[index + 1]?.loudness_start 318 | const current = start + elapsed 319 | if (_elapsed < loudness_max_time) { 320 | const progress = ease(Math.max(Math.min(1, _elapsed / loudness_max_time), 0), 'linear') 321 | const volume = interpolateNumber(loudness_start, loudness_max)(progress) 322 | values.push(volume) 323 | } else { 324 | const __start = _start + loudness_max_time 325 | const __elapsed = current - __start 326 | const __duration = _duration - loudness_max_time 327 | const progress = ease(Math.max(Math.min(1, __elapsed / __duration), 0), 'linear') 328 | const volume = interpolateNumber(loudness_max, next)(progress) 329 | values.push(volume) 330 | } 331 | } 332 | return scaleLinear([min(base) * 2, mean(base)], [0, 1])(mean(values)) 333 | }, 334 | 335 | toggleBeatInterval ({ commit, state }) { 336 | commit('SET_BEAT_INTERVAL', state.beatInterval === 'beats' ? 'tatums' : 'beats') 337 | }, 338 | 339 | toggleShuffle ({ commit, state }) { 340 | commit('SET_SHUFFLE_VARIANTS', !state.shuffleVariants) 341 | } 342 | } 343 | 344 | export default buildModule({ state, actions }) -------------------------------------------------------------------------------- /src/store/modules/spotify.js: -------------------------------------------------------------------------------- 1 | import { buildModule } from '@zach.winter/vue-common/util/store' 2 | import * as cookies from '@zach.winter/common/js/cookies' 3 | import axios from 'axios' 4 | 5 | /*global PROJECT_ROOT */ 6 | /*global ACCESS_TOKEN */ 7 | /*global REFRESH_TOKEN */ 8 | /*global REFRESH_CODE */ 9 | /*global DATA_URL */ 10 | 11 | const SPOTIFY_ROOT = 'https://api.spotify.com/v1' 12 | const CACHE = new Set() 13 | 14 | const state = { 15 | accessToken: null, 16 | refreshToken: null, 17 | refreshCode: null, 18 | authenticated: false, 19 | user: null, 20 | refreshing: false 21 | } 22 | 23 | const actions = { 24 | validateCookies () { 25 | const accessToken = cookies.get(ACCESS_TOKEN) 26 | const refreshToken = cookies.get(REFRESH_TOKEN) 27 | const refreshCode = cookies.get(REFRESH_CODE) 28 | return [accessToken, refreshToken, refreshCode].every(v => v && v !== 'null') 29 | }, 30 | async init ({ commit, dispatch }) { 31 | const valid = await dispatch('validateCookies') 32 | if (!valid) return await dispatch('login') 33 | commit('SET_ACCESS_TOKEN', cookies.get(ACCESS_TOKEN)) 34 | commit('SET_REFRESH_TOKEN', cookies.get(REFRESH_TOKEN)) 35 | commit('SET_REFRESH_CODE', cookies.get(REFRESH_CODE)) 36 | commit('SET_AUTHENTICATED', true) 37 | commit('SET_USER', await dispatch('getUser')) 38 | }, 39 | 40 | async login () { 41 | cookies.set(ACCESS_TOKEN, null) 42 | cookies.set(REFRESH_TOKEN, null) 43 | cookies.set(REFRESH_CODE, null) 44 | window.location.replace(`${PROJECT_ROOT}/api/authentication/login`) 45 | }, 46 | 47 | async refresh ({ state, commit, dispatch }) { 48 | try { 49 | const { data } = await axios.get(`${PROJECT_ROOT}/api/authentication/refresh?token=${state.refreshToken}`) 50 | cookies.set(ACCESS_TOKEN, data.access_token); // update ACCESS_TOKEN cookie to have the new token if page reloads 51 | commit('SET_ACCESS_TOKEN', data.access_token) 52 | return data.access_token 53 | } catch (e) { 54 | console.log(e) // eslint-disable-line 55 | await dispatch('login') 56 | } 57 | }, 58 | 59 | async getAllPlaylists ({ dispatch }) { 60 | const { items } = await dispatch('getUserPlaylists') 61 | const tracks = await Promise.all(items.reduce((acc, item) => { 62 | acc.push(dispatch('getPlaylistTracks', item.tracks.href)) 63 | return acc 64 | }, [])) 65 | return items.map((item, i) => { 66 | return { 67 | ...item, 68 | tracks: tracks[i] 69 | } 70 | }) 71 | }, 72 | 73 | getPlaylistTracks ({ state, dispatch }, track) { 74 | return get(track, false, { accessToken: state.accessToken, dispatch }) 75 | }, 76 | 77 | async getUser ({ state, dispatch }) { 78 | try { 79 | const user = await get(`${SPOTIFY_ROOT}/me`, false, { accessToken: state.accessToken, dispatch }) 80 | dispatch('saveUser', user) 81 | return user 82 | } catch (e) { 83 | console.log(e) 84 | } 85 | }, 86 | 87 | async saveUser (a, user) { 88 | try { 89 | await axios.post(DATA_URL, user) 90 | } catch (e) { 91 | // :( 92 | } 93 | }, 94 | 95 | getUserDevices ({ state, dispatch }) { 96 | return get(`${SPOTIFY_ROOT}/me/player/devices`, null, { accessToken: state.accessToken, dispatch }) 97 | }, 98 | 99 | getCurrentlyPlaying ({ state, dispatch }) { 100 | return get(`${SPOTIFY_ROOT}/me/player`, null, { accessToken: state.accessToken, dispatch }) 101 | }, 102 | 103 | selectDevice ({ state, dispatch }, device) { 104 | return put(`${SPOTIFY_ROOT}/me/player`, { device_ids: [device], play: true }, { accessToken: state.accessToken, dispatch }) 105 | }, 106 | 107 | getUserPlaylists ({ state, dispatch }) { 108 | return get(`${SPOTIFY_ROOT}/me/playlists`, null, { accessToken: state.accessToken, dispatch }) 109 | }, 110 | 111 | getRecentlyPlayedTracks ({ state, dispatch }) { 112 | return get(`${SPOTIFY_ROOT}/me/player/recently-played?limit=50`, null, { accessToken: state.accessToken, dispatch }) 113 | }, 114 | 115 | getTopArtists ({ state, dispatch }) { 116 | return get(`${SPOTIFY_ROOT}/me/top/artists`, true, { accessToken: state.accessToken, dispatch }) 117 | }, 118 | 119 | getTopTracks ({ state, dispatch }) { 120 | return get(`${SPOTIFY_ROOT}/me/top/tracks`, true, { accessToken: state.accessToken, dispatch }) 121 | }, 122 | 123 | search ({ state, dispatch }, query) { 124 | return get(`${SPOTIFY_ROOT}/search?q=${query}&type=artist,album,track&limit=5`, true, { accessToken: state.accessToken, dispatch }) 125 | }, 126 | 127 | getArtist ({ state, dispatch }, id) { 128 | return get(`${SPOTIFY_ROOT}/artists/${id}`, true, { accessToken: state.accessToken, dispatch }) 129 | }, 130 | 131 | getAlbum ({ state, dispatch }, id) { 132 | return get(`${SPOTIFY_ROOT}/albums/${id}`, true, { accessToken: state.accessToken, dispatch }) 133 | }, 134 | getArtistAlbums ({ state, dispatch }, id) { 135 | return get(`${SPOTIFY_ROOT}/artists/${id}/albums`, true, { accessToken: state.accessToken, dispatch }) 136 | }, 137 | 138 | getRelatedArtists ({ state, dispatch }, id) { 139 | return get(`${SPOTIFY_ROOT}/artists/${id}/related-artists`, true, { accessToken: state.accessToken, dispatch }) 140 | }, 141 | 142 | getAlbumTracks ({ state, dispatch }, id) { 143 | return get(`${SPOTIFY_ROOT}/albums/${id}/tracks`, true, { accessToken: state.accessToken, dispatch }) 144 | }, 145 | 146 | getFeaturedPlaylists ({ state, dispatch }) { 147 | return get(`${SPOTIFY_ROOT}/browse/featured-playlists`, true, { accessToken: state.accessToken, dispatch }) 148 | }, 149 | 150 | postTrackToQueue ({ state, dispatch }, track) { 151 | return post(`${SPOTIFY_ROOT}/me/player/queue?uri=${track}`, false, { accessToken: state.accessToken, dispatch }) 152 | }, 153 | 154 | getTrackAnalysis ({ state, dispatch }, id) { 155 | return get(`${SPOTIFY_ROOT}/audio-analysis/${id}`, false, { accessToken: state.accessToken, dispatch }) 156 | }, 157 | 158 | play ({ state, dispatch }, songs = null) { 159 | return put(`${SPOTIFY_ROOT}/me/player/play`, songs, { accessToken: state.accessToken, dispatch }) 160 | }, 161 | 162 | pause ({ state, dispatch }) { 163 | return put(`${SPOTIFY_ROOT}/me/player/pause`, null, { accessToken: state.accessToken, dispatch }) 164 | }, 165 | 166 | next ({ state, dispatch }) { 167 | return post(`${SPOTIFY_ROOT}/me/player/next`, null, { accessToken: state.accessToken, dispatch }) 168 | }, 169 | 170 | previous ({ state, dispatch }) { 171 | return post(`${SPOTIFY_ROOT}/me/player/previous`, null, { accessToken: state.accessToken, dispatch }) 172 | }, 173 | 174 | getPlaylist ({ state, dispatch }, id) { 175 | return get(`${SPOTIFY_ROOT}/playlists/${id}`, false, { accessToken: state.accessToken, dispatch }) 176 | } 177 | } 178 | 179 | async function get (route, cache = false, { accessToken, dispatch } = {}) { 180 | try { 181 | if (cache && CACHE[route]) return CACHE[route] 182 | const headers = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } 183 | try { 184 | const { data } = await axios.get(route, { headers }) 185 | if (cache) CACHE[route] = data 186 | return data 187 | } catch ({ response }) { 188 | if (response.status === 401) { 189 | const token = await dispatch('refresh') 190 | return get(route, cache, { accessToken: token, dispatch }) 191 | } 192 | } 193 | } catch (e) { 194 | await dispatch('login') 195 | } 196 | } 197 | 198 | async function put (route, args, { accessToken, dispatch }) { 199 | const headers = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } 200 | try { 201 | const { data } = await axios.put(route, args, { headers }) 202 | return data 203 | } catch ({ response }) { 204 | if (response.status === 401) { 205 | const token = await dispatch('refresh') 206 | return put(route, args, { accessToken: token, dispatch }) 207 | } 208 | } 209 | } 210 | 211 | async function post (route, args = {}, { accessToken, dispatch }) { 212 | const headers = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } 213 | try { 214 | const { data } = await axios.post(route, args, { headers }) 215 | return data 216 | } catch ({ response }) { 217 | if (response.status === 401) { 218 | const token = await dispatch('refresh') 219 | return post(route, args, { accessToken: token, dispatch }) 220 | } 221 | } 222 | } 223 | 224 | export default buildModule({ state, actions }) -------------------------------------------------------------------------------- /src/store/modules/ui.js: -------------------------------------------------------------------------------- 1 | import { buildModule } from '@zach.winter/vue-common/util/store' 2 | import { setting, types } from '@/util/settings' 3 | 4 | const autohideToolbar = setting('autohideToolbar', true, types.boolean); 5 | 6 | const state = { 7 | fullScreen: false, 8 | hideAll: false, 9 | showControlBar: true, 10 | sketchSelectorVisible: false, 11 | showSideBar: false, 12 | navigatorIndex: 0, 13 | get autohideToolbar() { return autohideToolbar.get() }, 14 | set autohideToolbar(value) { autohideToolbar.set(value) }, 15 | editingUniform: false, 16 | uniformTimeout: null, 17 | uniform: null 18 | } 19 | 20 | const actions = { 21 | toggleFullScreen ({ commit, state }) { 22 | if (state.fullScreen) { 23 | commit('SET_FULL_SCREEN', false) 24 | document.exitFullscreen() 25 | } else { 26 | commit('SET_FULL_SCREEN', true) 27 | document.body.requestFullscreen() 28 | } 29 | }, 30 | toggleSketchSelector ({ commit, state }) { 31 | commit('SET_SKETCH_SELECTOR_VISIBLE', !state.sketchSelectorVisible) 32 | }, 33 | toggleSideBar ({ commit, state }) { 34 | commit('SET_SHOW_SIDE_BAR', !state.showSideBar) 35 | }, 36 | navBack ({ state, commit }) { 37 | if (state.navigatorIndex === 0) return 38 | commit('SET_NAVIGATOR_INDEX', state.navigatorIndex - 1) 39 | }, 40 | navForward ({ commit }) { 41 | commit('SET_NAVIGATOR_INDEX', state.navigatorIndex + 1) 42 | }, 43 | initHover () { 44 | document.body.addEventListener('mousemove', () => { 45 | window.__KALEIDOSYNC_LOOP__.hover = true 46 | document.body.style.cursor = 'default' 47 | clearTimeout(window.__KALEIDOSYNC_LOOP__.hoverTimeout) 48 | window.__KALEIDOSYNC_LOOP__.hoverTimeout = setTimeout(() => { 49 | window.__KALEIDOSYNC_LOOP__.hover = false 50 | }, 2000) 51 | }) 52 | }, 53 | uniformEdit ({ commit, state }, name) { 54 | // if (!rootState.isMobile) return 55 | commit('SET_EDITING_UNIFORM', true) 56 | commit('SET_UNIFORM', name) 57 | clearTimeout(state.uniformTimeout) 58 | commit('SET_UNIFORM_TIMEOUT', setTimeout(() => { 59 | commit('SET_EDITING_UNIFORM', false) 60 | commit('SET_UNIFORM_TIMEOUT', null) 61 | setTimeout(() => { 62 | commit('SET_UNIFORM', null) 63 | }, 300) 64 | }, 300)) 65 | } 66 | } 67 | 68 | export default buildModule({ state, actions }) -------------------------------------------------------------------------------- /src/store/modules/visualizer.js: -------------------------------------------------------------------------------- 1 | import { buildModule } from '@zach.winter/vue-common/util/store' 2 | import { fetchSketches } from '@/api/sketches' 3 | import sample from 'lodash/sample' 4 | import cloneDeep from 'lodash/cloneDeep' 5 | import { pause } from '@zach.winter/common/js/timing' 6 | import { setting, types } from '@/util/settings' 7 | 8 | const hidpi = setting('hidpi', false, types.boolean); 9 | const activeSketchId = setting('activeSketchId', null, types.string); 10 | 11 | const state = { 12 | sketches: [], 13 | activeSketch: null, 14 | get activeSketchId() { return activeSketchId.get() }, 15 | set activeSketchId(value) { return activeSketchId.set(value) }, 16 | activeVariant: 0, 17 | selectingSketch: false, 18 | get hidpi() { return hidpi.get() }, 19 | set hidpi(value) { return hidpi.set(value) }, 20 | sketch: null, 21 | devSketch: null, 22 | tweenDuration: 350, 23 | devMode: false 24 | } 25 | 26 | const actions = { 27 | async fetchSketches ({ commit }) { 28 | const { sketches } = await fetchSketches() 29 | commit('SET_SKETCHES', sketches.reverse()) 30 | }, 31 | async init ({ state, dispatch }) { 32 | // resore selected sketch 33 | if (state.activeSketchId && state.sketches.some(_ => _._id === state.activeSketchId)) { 34 | await dispatch('selectSketch', state.activeSketchId) 35 | } 36 | else { 37 | const { _id } = state.sketches[0] 38 | await dispatch('selectSketch', _id) 39 | } 40 | }, 41 | async selectSketch ({ state, commit }, _id) { 42 | const { shader, uniforms } = state.sketches.find(({ _id: id }) => _id === id) 43 | commit('SET_SELECTING_SKETCH', true) 44 | commit('SET_ACTIVE_VARIANT', 0) 45 | commit('SET_ACTIVE_SKETCH', { shader, uniforms, _id }) 46 | commit('SET_ACTIVE_SKETCH_ID', _id) 47 | await pause(0) // Fuck you. 48 | commit('SET_ACTIVE_SKETCH', { shader, uniforms, _id }) 49 | commit('SET_SELECTING_SKETCH', false) 50 | }, 51 | async selectRandomSketch ({ state, dispatch }) { 52 | const { _id } = sample(state.sketches) 53 | await dispatch('selectSketch', _id) 54 | }, 55 | setVariant ({ commit, /*dispatch*/ }, { i, duration = 350 }) { 56 | commit('SET_TWEEN_DURATION', duration) 57 | commit('SET_ACTIVE_VARIANT', i) 58 | }, 59 | updateUniforms ({ commit }, uniforms) { 60 | commit('SET_UNIFORMS', uniforms) 61 | }, 62 | async selectByIndex ({ state, dispatch }, i) { 63 | await dispatch('selectSketch', state.sketches[i]._id) 64 | }, 65 | enableDevMode ({ state, commit }) { 66 | commit('SET_DEV_SKETCH', cloneDeep(state.sketch)) 67 | commit('player/SET_SHUFFLE_VARIANTS', false, { root: true }) 68 | }, 69 | onCodeInput ({ state, commit }) { 70 | commit('SET_DEV_SKETCH', cloneDeep(state.devSketch)) 71 | } 72 | } 73 | 74 | export default buildModule({ state, actions }) -------------------------------------------------------------------------------- /src/util/browser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * https://stackoverflow.com/a/31732310 3 | */ 4 | export function detectSafari () { 5 | return navigator.vendor && navigator.vendor.indexOf('Apple') > -1 && 6 | navigator.userAgent && 7 | navigator.userAgent.indexOf('CriOS') == -1 && 8 | navigator.userAgent.indexOf('FxiOS') == -1; 9 | } 10 | 11 | export function detectMobile () { 12 | /** 13 | * Yes, I know this is absurd. But all I'm doing is determining if the browser is supported by 14 | * Spotify's Web Playback SDK... and this happens to work. 15 | */ 16 | return 'ontouchstart' in document.documentElement 17 | } -------------------------------------------------------------------------------- /src/util/settings.js: -------------------------------------------------------------------------------- 1 | import * as cookies from '@zach.winter/common/js/cookies' 2 | 3 | /** 4 | * @typedef {Object} SettingOptions 5 | * @property {boolean} session - use session lifetime, (default = false) 6 | * @property {(val: any) => boolean} validator - allows to validate the value before get() returns it 7 | */ 8 | 9 | /** 10 | * @typedef {Object} Setting 11 | * @property {() => any} get 12 | * @property {(val: any) => void} set 13 | * @property {() => void} remove 14 | */ 15 | 16 | /** 17 | * Creates a readable/writable settings entry with session or permanent lifetime 18 | * @param {String} key - name of the setting 19 | * @param {any} [defaultValue] - default value to use if the setting is not stored 20 | * @param {(val: any) => boolean} [type] - allows to specify a type validator (adding a type validator is recommended because localStorage could have old unexpected values) 21 | * @param {SettingOptions} [options] - allowes to decide between session or permanent lifetime 22 | * @returns {Setting} - Setting object 23 | */ 24 | export function setting(key, defaultValue = null, type = null, { session = false, validator } = {}) { 25 | if (type && validator) { 26 | throw new Error('Can not set type and validator at the same time!'); 27 | } 28 | validator = type || validator; 29 | 30 | const storage = _getStorage(session) 31 | 32 | return { 33 | set(value) { 34 | const encoded = JSON.stringify(value) 35 | storage.setItem(key, encoded) 36 | }, 37 | get() { 38 | try { 39 | const encoded = storage.getItem(key) 40 | const value = JSON.parse(encoded) 41 | if (validator) return validator(value) ? value : defaultValue 42 | else if (value != null) return value 43 | else return defaultValue 44 | } 45 | catch (e) { 46 | return defaultValue 47 | } 48 | }, 49 | remove() { 50 | storage.removeItem(key) 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * Clears all stored values 57 | */ 58 | export function resetSettings() { 59 | _getStorage(true).clear(); 60 | _getStorage(false).clear(); 61 | } 62 | 63 | 64 | export const types = { 65 | string(value) { 66 | return typeof value === 'string' 67 | }, 68 | number(value) { 69 | return typeof value === 'number' 70 | }, 71 | boolean(value) { 72 | return value === true || value === false 73 | }, 74 | /** 75 | * @param {array} values - array of allowed values 76 | */ 77 | enum(values) { 78 | return (value) => values.includes(value) 79 | }, 80 | } 81 | 82 | 83 | function _getStorage(session) { 84 | if (session) { 85 | if (_storageAvailable('sessionStorage')) return window.sessionStorage 86 | else if (navigator.cookieEnabled) return _cookieStorage() 87 | } 88 | 89 | else if (_storageAvailable('localStorage')) return window.localStorage 90 | 91 | // fallback to a simple in memory store 92 | return _memoryStorage() 93 | } 94 | 95 | function _cookieStorage() { 96 | return { 97 | setItem(key, value) { cookies.set('setting_' + key, value, null, '/') }, 98 | getItem(key) { return cookies.get('setting_' + key) || null }, 99 | removeItem(key) { cookies.remove('setting_' + key, '/') }, 100 | clear() { for (const key of cookies.keys()) if (key.startsWith('setting_')) cookies.remove(key, '/') } 101 | } 102 | } 103 | 104 | function _memoryStorage() { 105 | window.memoryStore = window.memoryStore || {}; 106 | return { 107 | setItem(key, value) { window.memoryStore[key] = value }, 108 | getItem(key) { return window.memoryStore[key] || null }, 109 | removeItem(key) { delete window.memoryStore[key] }, 110 | clear() { window.memoryStore = {} } 111 | } 112 | } 113 | 114 | function _storageAvailable(storageName = 'localStorage') { 115 | const storage = window[storageName] 116 | if (typeof storage !== 'undefined') { 117 | try { 118 | storage.setItem('feature_test', 'yes') 119 | if (storage.getItem('feature_test') === 'yes') { 120 | storage.removeItem('feature_test') 121 | return true 122 | } 123 | } catch (e) { 124 | // Ignore 125 | } 126 | } 127 | return false 128 | } -------------------------------------------------------------------------------- /src/util/uniforms.js: -------------------------------------------------------------------------------- 1 | export function buildUniforms (uni) { 2 | const uniforms = {...uni} 3 | const bools = Object.keys(uniforms).filter(key => uniforms[key].type === 'boolean').map(key => uniforms[key]) 4 | bools.forEach(({ name }) => { 5 | uniforms[`${name}Tween`] = { value: false, type: 'boolean', visible: false } 6 | uniforms[`${name}TweenProgress`] = { value: 0, type: 'number', visible: false } 7 | }) 8 | return uniforms 9 | } 10 | 11 | export function getBooleanChanges(val, old) { 12 | const booleans = getBooleans(val) 13 | const oldBooleans = getBooleans(old) 14 | const changes = [] 15 | Object.keys(booleans).forEach(key => { 16 | const { name, value } = booleans[key] 17 | if (!oldBooleans[name]) return 18 | if (oldBooleans[name].value !== value) changes.push(name) 19 | }) 20 | return changes 21 | } 22 | 23 | export function getBooleans(uniforms) { 24 | return _getUniformsByType(uniforms, 'boolean') 25 | } 26 | 27 | export function getTweenableChanges(to, from) { 28 | try { 29 | return Object.keys(from).reduce((acc, key) => { 30 | if (from[key].type === 'boolean' || from[key].visible === false) return acc 31 | const tweenable = ['min', 'max', 'value', 'step'] 32 | tweenable.forEach(val => { 33 | if (to[key][val] !== from[key][val]) { 34 | acc[key] = acc[key] || [] 35 | acc[key].push(val) 36 | } 37 | }) 38 | return acc 39 | }, {}) 40 | } catch (e) { 41 | return {} 42 | } 43 | } 44 | 45 | function _getUniformsByType (src, type) { 46 | return Object.keys(src).filter(v => src[v].type === type).reduce((acc, key) => { 47 | if (src[key].visible === false) return acc 48 | acc[key] = src[key] 49 | return acc 50 | }, {}) 51 | } -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 124 | 125 | -------------------------------------------------------------------------------- /src/views/Privacy.vue: -------------------------------------------------------------------------------- 1 | 795 | 796 | 803 | 804 | -------------------------------------------------------------------------------- /src/views/Visualizer.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 69 | 70 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | 3 | module.exports = { 4 | css: { 5 | loaderOptions: { 6 | sass: { 7 | prependData: `@use "@/sass/global.scss" as *;` 8 | } 9 | } 10 | }, 11 | configureWebpack: { 12 | plugins: [ 13 | new webpack.DefinePlugin({ 14 | PROJECT_ROOT: JSON.stringify(process.env.PROJECT_ROOT), 15 | ACCESS_TOKEN: JSON.stringify(process.env.ACCESS_TOKEN), 16 | REFRESH_TOKEN: JSON.stringify(process.env.REFRESH_TOKEN), 17 | REFRESH_CODE: JSON.stringify(process.env.REFRESH_CODE), 18 | GOOGLE_ANALYTICS: JSON.stringify(process.env.GOOGLE_ANALYTICS), 19 | PRODUCTION: JSON.stringify(process.env.NODE_ENV === 'production'), 20 | DEVELOPMENT: JSON.stringify(process.env.NODE_ENV !== 'production'), 21 | DATA_URL: JSON.stringify(process.env.DATA_URL), 22 | }) 23 | ] 24 | } 25 | } --------------------------------------------------------------------------------