Introduction
This is day 1 of Wes Bos's JavaScript 30 challenge where I create a drum kit to play sounds when keys are pressed. In the tutorial, I created the components using RxJS, Angular standalone components and removed the NgModules.
In this blog post, I describe how the drum component (parent) uses RxJS fromEvent to listen to keydown event, discard unwanted keys and play sound when "A", "S", "D", "F", "G", "H", "J", "K" or "L" is hit. When the correct key is pressed, the parent updates the subject that drum kit components (children) subscribe to. Then, the child with the matching key plays the corresponding sound to make a tune.
Create a new Angular project
ng generate application day1-javascript-drum-kit
Bootstrap AppComponent
First, I convert AppComponent
into standalone component such that I can bootstrap AppComponent
and inject providers in main.ts.
// app.component.ts
import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { DrumComponent } from './drum';
@Component({
selector: 'app-root',
imports: [
DrumComponent,
],
template: '<app-drum></app-drum>',
styles: [`
:host {
display: block;
height: 100vh;
}
`],
standalone: true,
})
export class AppComponent {
title = 'RxJS Drum Kit';
constructor(private titleService: Title) {
this.titleService.setTitle(this.title);
}
}
In Component decorator, I put standalone: true
to convert AppComponent
into a standalone component.
Instead of importing DrumComponent
in AppModule
, I import DrumComponent
(that is also a standalone component) in the imports array because the inline template references it.
// main.ts
import { enableProdMode, inject } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
import { AppComponent } from './app/app.component';
import { browserWindowProvider, windowProvider } from './app/core/services';
import { environment } from './environments/environment';
bootstrapApplication(AppComponent, {
providers: [
{
provide: APP_BASE_HREF,
useFactory: () => inject(PlatformLocation).getBaseHrefFromDOM(),
},
browserWindowProvider,
windowProvider,
]
})
.catch(err => console.error(err));
browserWindowProvider
and windowProvider
are providers from core
folder and I will show the source codes later.
Second, I delete AppModule
because it is not used anymore.
Add window service to listen to keydown event
In order to detect key down on native Window, I write a window service to inject to ScrollComponent to listen to keydown event. The sample code is from Brian Love's blog post here.
// core/services/window.service.ts
import { isPlatformBrowser } from "@angular/common";
import { ClassProvider, FactoryProvider, InjectionToken, PLATFORM_ID } from '@angular/core';
/* Create a new injection token for injecting the window into a component. */
export const WINDOW = new InjectionToken('WindowToken');
/* Define abstract class for obtaining reference to the global window object. */
export abstract class WindowRef {
get nativeWindow(): Window | Object {
throw new Error('Not implemented.');
}
}
/* Define class that implements the abstract class and returns the native window object. */
export class BrowserWindowRef extends WindowRef {
constructor() {
super();
}
override get nativeWindow(): Object | Window {
return window;
}
}
/* Create an factory function that returns the native window object. */
function windowFactory(browserWindowRef: BrowserWindowRef, platformId: Object): Window | Object {
if (isPlatformBrowser(platformId)) {
return browserWindowRef.nativeWindow;
}
return new Object();
}
/* Create a injectable provider for the WindowRef token that uses the BrowserWindowRef class. */
export const browserWindowProvider: ClassProvider = {
provide: WindowRef,
useClass: BrowserWindowRef
};
/* Create an injectable provider that uses the windowFactory function for returning the native window object. */
export const windowProvider: FactoryProvider = {
provide: WINDOW,
useFactory: windowFactory,
deps: [ WindowRef, PLATFORM_ID ]
};
I export browserWindowProvider
and windowProvider
to inject both providers in main.ts
.
Declare Drum and DrumKey components
I declare standalone components, DrumComponent
and DrumKeyComponent
, to create a drum kit. To verify they are standalone, standalone: true
is specified in the Component decorator.
src/app
├── app.component.ts
├── core
│ └── services
│ ├── index.ts
│ └── window.service.ts
├── drum
│ ├── drum.component.ts
│ └── index.ts
├── drum-key
│ ├── drum-key.component.ts
│ └── index.ts
├── helpers
│ ├── get-asset-path.ts
│ ├── get-host-native-element.ts
│ └── index.ts
├── interfaces
│ ├── index.ts
│ └── key.interface.ts
└── services
├── drum.service.ts
└── index.ts
// get-asset-path.ts
import { APP_BASE_HREF } from '@angular/common';
import { inject } from '@angular/core';
export const getFullAssetPath = () => {
const baseHref = inject(APP_BASE_HREF);
const isEndWithSlash = baseHref.endsWith('/');
return `${baseHref}${isEndWithSlash ? '' : '/'}assets/`;
}
// get-host-native-element.ts
import { ElementRef, inject } from '@angular/core';
export const getHostNativeElement =
() => inject<ElementRef<HTMLElement>>(ElementRef<HTMLElement>).nativeElement;
getFullAssetPath and getHostNativeElement are helper functions that inject application base href and host native element in the construction phase of the components.
// drum.component.ts
import { NgFor } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject } from '@angular/core';
import { filter, fromEvent, map } from 'rxjs';
import { WINDOW } from '../core/services';
import { DrumKeyComponent } from '../drum-key/drum-key.component';
import { getFullAssetPath, getHostNativeElement } from '../helpers';
import { DrumService } from '../services';
const getImageUrl = () => {
const imageUrl = `${getFullAssetPath()}images/background.jpg`;
return `url('${imageUrl}')`;
}
const getEntryStore = () => {
const getEntryStore = inject(DrumService);
return getEntryStore.getEntryStore();
};
const windowKeydownSubscription = () => {
const drumService = inject(DrumService);
const allowedKeys = getEntryStore().allowedKeys;
return fromEvent(inject<Window>(WINDOW), 'keydown')
.pipe(
filter(evt => evt instanceof KeyboardEvent),
map(evt => evt as KeyboardEvent),
map(({ key }) => key.toUpperCase()),
filter(key => allowedKeys.includes(key)),
).subscribe((key) => drumService.playSound(key));
}
@Component({
imports: [
NgFor,
DrumKeyComponent,
],
standalone: true,
selector: 'app-drum',
template: `
<div class="keys">
<app-drum-key *ngFor="let entry of entries" [entry]="entry" class="key"></app-drum-key>
</div>
`,
styles: [`...omitted due to brevity...`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DrumComponent implements OnInit, OnDestroy {
entries = getEntryStore().entries;
hostElement = getHostNativeElement();
imageUrl = getImageUrl();
subscription = windowKeydownSubscription();
ngOnInit(): void {
this.hostElement.style.backgroundImage = this.imageUrl;
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
}
DrumComponent
imports DrumKeyComponent
and NgFor
to render different drum keys. NgFor
is required because inline template uses ng-for
directive to create a drum kit. windowKeydownSubscription
uses RxJS to create an Observable to observe keydown event and subscribe the Observable to return an instance of Subscription.
// drum-key.component.ts
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild, inject } from '@angular/core';
import { filter, fromEvent, map } from 'rxjs';
import { getFullAssetPath, getHostNativeElement } from '../helpers';
import { Key } from '../interfaces';
import { DrumService } from '../services';
const getSoundFileFn = () => {
const assetPath = getFullAssetPath();
return (description: string) => `${assetPath}sounds/${description}.wav`;
}
const drumKeyTranstionEnd = () =>
fromEvent(getHostNativeElement(), 'transitionend')
.pipe(
filter(evt => evt instanceof TransitionEvent),
map(evt => evt as TransitionEvent),
filter(evt => evt.propertyName === 'transform')
);
@Component({
standalone: true,
selector: 'app-drum-key',
template: `
<ng-container>
<kbd>{{ entry.key }}</kbd>
<span class="sound">{{ entry.description }}</span>
<audio [src]="soundFile" #audio></audio>
</ng-container>
`,
styles: [`...omitted due to brevity...`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DrumKeyComponent implements OnDestroy {
@Input()
entry!: Key;
@ViewChild('audio', { static: true })
audio: ElementRef<HTMLAudioElement> | undefined;
@HostBinding('class.playing') isPlaying = false;
cdr = inject(ChangeDetectorRef);
playSoundSubscription = inject(DrumService).playDrumKey$
.pipe(filter(key => key === this.entry.key))
.subscribe(() => this.playSound());
transitionSubscription = drumKeyTranstionEnd()
.subscribe(() => {
this.isPlaying = false;
this.cdr.markForCheck();
});
getSoundFile = getSoundFileFn();
get soundFile() {
return this.getSoundFile(this.entry.description);
}
playSound() {
if (!this.audio) {
return;
}
const nativeElement = this.audio.nativeElement;
nativeElement.currentTime = 0;
nativeElement.play();
this.isPlaying = true;
this.cdr.markForCheck();
}
ngOnDestroy(): void {
this.playSoundSubscription.unsubscribe();
this.transitionSubscription.unsubscribe();
}
}
DrumKeyComponent
constructs playSoundSubscription
and transitionSubscription
subscriptions to play the actual sound and display a yellow border until the sound ends. Using inject operator, I construct these subscriptions outside of constructor and ngOnInit.
Declare drum service to pass data from Drum to DrumKey component
When DrumComponent
observes the correct key is pressed, the key must emit to DrumKeyComponent
to perform CSS animation and play sound. The data is emit to Subject that is encapsulated in DrumService
.
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class DrumService {
private readonly playDrumKey = new Subject<string>();
readonly playDrumKey$ = this.playDrumKey.asObservable();
playSound(key: string) {
this.playDrumKey.next(key);
}
getEntryStore() {
const entries: Key[] = [
{
key: 'A',
description: 'clap'
},
{
key: 'S',
description: 'hihat'
},
{
key: 'D',
description: 'kick'
},
{
key: 'F',
description: 'openhat'
},
{
key: 'G',
description: 'boom'
},
{
key: 'H',
description: 'ride'
},
{
key: 'J',
description: 'snare'
},
{
key: 'K',
description: 'tom'
},
{
key: 'L',
description: 'tink'
}
];
return {
entries,
allowedKeys: entries.map(entry => entry.key),
}
}
}
Use RxJS and Angular to implement key down observable
Declare subscription instance member, assign the result of windowKeydownSubscription to it and unsubscribe in ngDestroy()
subscription = windowKeydownSubscription();
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
// drum.component.ts
const windowKeydownSubscription = () => {
const drumService = inject(DrumService);
const allowedKeys = getEntryStore().allowedKeys;
return fromEvent(inject<Window>(WINDOW), 'keydown')
.pipe(
filter(evt => evt instanceof KeyboardEvent),
map(evt => evt as KeyboardEvent),
map(({ key }) => key.toUpperCase()),
filter(key => allowedKeys.includes(key)),
).subscribe((key) => drumService.playSound(key));
}
- fromEvent(inject(WINDOW), 'keydown') - observe keydown event on native window
- filter(evt => evt instanceof KeyboardEvent) - filter event is an instance of KeyboardEvent
- map(evt => evt as KeyboardEvent) - cast event to KeyboardEvent
- map(({ key }) => key.toUpperCase()) - convert key to uppercase
- filter(key => allowedKeys.includes(key)) - validate key can play sound
- subscribe((key) => drumService.playSound(key)) - subscribe the observable to play the wav file Use RxJS and Angular to implement play sound file
// drum-key.component.ts
const drumKeyTranstionEnd = () =>
fromEvent(getHostNativeElement(), 'transitionend')
.pipe(
filter(evt => evt instanceof TransitionEvent),
map(evt => evt as TransitionEvent),
filter(evt => evt.propertyName === 'transform')
);
playSoundSubscription = inject(DrumService).playDrumKey$
.pipe(filter(key => key === this.entry.key))
.subscribe(() => this.playSound());
transitionSubscription = drumKeyTranstionEnd()
.subscribe(() => {
this.isPlaying = false;
this.cdr.markForCheck();
});
Let's demystify playSoundSubscription
- inject(DrumService).playDrumKey$ - observe playDrumKey$ observable of DrumService
- filter(key => key === this.entry.key) - compare component's key and the key pressed, and they are the same
- subscribe(() => this.playSound()) - play the wav file
Let's demystify drumKeyTranstionEnd and transitionSubscription
- fromEvent(getHostNativeElement(), 'transitionend')- observe transition event of the host element
- filter(evt => evt instanceof TransitionEvent) - filter event is an instance of TransitionEvent
- map(evt => evt as TransitionEvent) - cast event to TransitionEvent
- filter(evt => evt.propertyName === 'transform') - filter the event property is transform
- subscribe(() => { this.isPlaying = false; this.cdr.markForCheck(); }) - subscribe the observable to update host class to display yellow border until the sound stops
This is it, we have created a drum kit that plays sound after pressing key.
Final Thoughts
In this post, I show how to use RxJS and Angular standalone components to create a drum kit. The application has the following characteristics after using Angular 15's new features:
- The application does not have NgModules and constructor boilerplate codes.
- Apply inject operator to inject services in const functions outside of component classes. The component classes are shorter and become easy to comprehend.
- In DrumKeyComponent, I assign subscriptions to instance members directly and don't have to implement OnInit lifecycle hook.
This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.
Resources:
- Github Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day1-javascript-drum-kit
- Live demo: https://railsstudent.github.io/ng-rxjs-30/day1-javascript-drum-kit/
- Wes Bos's JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30