noise-maker/src/App.vue

184 lines
4.4 KiB
Vue

import {PlayingState} from "@/common";
<!--suppress HtmlFormInputWithoutLabel -->
<template>
<div id="app">
<h1>noisemaker</h1>
<div class="channels-wrapper">
<div class="channels">
<template v-for="i in N_CHANNELS">
<Channel :name="names[i - 1]" :url="urls[i - 1]" :volume="volumes[i-1]" :playing="playing"
:key="i" @loadingState="(state) => {$set(states, i - 1, state)}"
class="channel" ref="channels"/>
</template>
</div>
<div class="channel-urls">
<div class="url-input" v-for="i in N_CHANNELS">
<label :for="`url-input-${names[i-1]}`">
{{names[i - 1]}}
</label>
<input :id="`url-input-${names[i-1]}`" type="text" v-model="urls[i - 1]"/>
</div>
<button class="channel-urls-clear-button" @click="urls = Array(N_CHANNELS).fill('')">CLEAR</button>
</div>
</div>
<div class="controls">
<button @click="start" :disabled="!startEnabled">START</button>
</div>
</div>
</template>
<!--suppress JSUnusedLocalSymbols, JSMethodCanBeStatic -->
<script lang="ts">
import {Component, Vue} from "vue-property-decorator";
import Channel from "@/components/Channel.vue";
import {LoadingState, PlayingState} from "@/common";
@Component({
components: {
Channel,
},
})
export default class App extends Vue {
private readonly N_CHANNELS = 6;
private readonly LFO_PERIOD = 30_000;
private readonly LFO_DEPTH = 33;
private readonly LFO_OFFSET = 66;
private playing: PlayingState = PlayingState.STOPPED;
private names = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa", "lambda", "mu"];
private urls = Array(this.N_CHANNELS).fill("");
private volumes = Array(this.N_CHANNELS).fill(100);
private states = Array(this.N_CHANNELS).fill(LoadingState.UNLOADED);
private animateVolumeStart?: Date;
private animateVolumeInterval?: number;
private readonly defaultMedia = [
"https://www.youtube.com/watch?v=jX6kn9_U8qk",
"https://www.youtube.com/watch?v=E77jmtut1Zc",
"https://youtu.be/OW7TH2U4hps"
];
private mounted() {
this.defaultMedia.forEach((url, idx) => {
this.$set(this.urls, idx, url);
});
}
private start() {
this.playing = PlayingState.PLAYING;
this.animateVolume();
}
private animateVolume() {
clearInterval(this.animateVolumeInterval);
this.animateVolumeStart = new Date();
this.animateVolumeInterval = setInterval(() => {
if (this.animateVolumeStart) {
const delta = new Date().getTime() - this.animateVolumeStart.getTime();
this.volumes = [...Array(this.N_CHANNELS).keys()]
.map((idx) => {
const offset = idx * (1 / this.N_CHANNELS);
const progress = delta / this.LFO_PERIOD + offset;
return Math.sin(progress * 2 * Math.PI) * this.LFO_DEPTH + this.LFO_OFFSET;
});
}
}, 1000 / 60);
}
private get startEnabled() {
return this.states.every((state) => state !== LoadingState.LOADING);
}
}
</script>
<style>
html, body {
font-family: monospace;
font-size: 14px;
padding: 0;
margin: 0;
}
input[type="text"] {
font-family: monospace;
padding: 4px;
background: white;
border: 1px solid black;
}
button {
background: white;
border: 1px solid black;
box-shadow: 2px 2px #272727;
font-size: 1.5rem;
}
h1 {
font-size: 20px;
margin: 1rem 0;
font-weight: normal;
text-transform: uppercase;
letter-spacing: 15px;
}
#app {
display: flex;
flex-direction: column;
align-items: center;
}
.channels-wrapper {
display: flex;
width: 80%;
flex-wrap: wrap;
}
.channels {
display: flex;
padding: 0 1rem 1rem 0;
justify-content: space-between;
flex-grow: 1;
}
.channel {
margin: 0 1rem;
}
.channel-urls {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
flex-grow: 1;
}
.channel-urls-clear-button {
max-width: 5em;
}
.url-input {
display: flex;
width: 100%;
align-items: center;
margin: .2rem 0;
}
.url-input label {
display: inline-block;
width: 64px;
text-align: center;
}
.url-input input {
flex-grow: 1;
}
.controls {
padding: 2rem 0;
}
</style>