Rework website with bulma instead of bootstrap #6

Manually merged
tlater merged 43 commits from tlater/bulma2 into master 2022-08-14 00:26:49 +01:00
60 changed files with 6434 additions and 5503 deletions

View file

@ -6,6 +6,7 @@
(pug-mode
(tab-width . 2))
(scss-mode
(css-indent-offset . 2)))
(css-indent-offset . 2))
(auto-mode-alist . (("update-lockfile" . sh-mode))))

View file

@ -1,40 +0,0 @@
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"env": {
"es6": true,
"browser": true,
"jquery": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"indent": [
"error",
4
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"warn",
"always"
],
"no-console": [
"off"
],
"no-unused-vars": [
"warn",
{ "argsIgnorePattern": "^_" }
]
}
}

59
.eslintrc.yaml Normal file
View file

@ -0,0 +1,59 @@
root: true
parser: "@typescript-eslint/parser"
parserOptions:
project:
- ./tsconfig.json
plugins:
- "@typescript-eslint"
extends:
- eslint:recommended
- plugin:@typescript-eslint/recommended
- plugin:@typescript-eslint/recommended-requiring-type-checking
env:
es6: true
browser: true
# {
# "parser": "@typescript-eslint/parser",
# "plugins": [
# "@typescript-eslint"
# ],
# "env": {
# "es6": true,
# "browser": true,
# "jquery": true
# },
# "extends": [
# "eslint:recommended",
# "plugin:@typescript-eslint/recommended"
# ],
# "rules": {
# "indent": [
# "error",
# 4
# ],
# "linebreak-style": [
# "error",
# "unix"
# ],
# "quotes": [
# "error",
# "double"
# ],
# "semi": [
# "warn",
# "always"
# ],
# "no-console": [
# "off"
# ],
# "no-unused-vars": [
# "warn",
# { "argsIgnorePattern": "^_" }
# ]
# }
# }

1
.gitignore vendored
View file

@ -3,3 +3,4 @@
/dist/
/node_modules
/result
/package.json

View file

@ -11,7 +11,7 @@
},
"posthtml-favicons": {
"root": "src",
"outDir": "./dist/browser/",
"outDir": "./dist/",
"configuration": {
"appName": "tlater.net",
"appShortName": "tlater.net",

11
.prettierrc Normal file
View file

@ -0,0 +1,11 @@
# -*- yaml -*-
tabWidth: 4
overrides:
- files: "**/*.html"
options:
tabWidth: 2
- files: "**/*.scss"
options:
tabWidth: 2

View file

@ -4,29 +4,15 @@ Website templates bundled with parcel.
## Updating
Firstly, updating the npm dependencies needs:
When adding/removing packages from `package.yaml`, the corresponding
`package.json` and `package-lock.json` need to be updated. Use:
```sh
npm update --package-lock-only
```console
nix run .#update-lockfile
```
After that, the NixOS derivations can be updated by running
`generate.sh` in the `nix` subdirectory.
When ignoring semver is desired, use:
### Note
[Sharp](https://github.com/lovell/sharp/releases) is gloriously hard
to install, because it depends on the most cutting-edge patch version
of `vips`.
Check which version it needs in the `package.json` of sharp, and then
match this up which major sharp version matches up to which OS version
of vips.
Known versions:
| Sharp | vips | NixOS |
| ----: | ---------: | ----: |
| 28.* | 8.10.6 | |
| 27.* | 8.10.5 | 21.05 |
| 26.* | 8.10.0 | 20.09 |
```console
nix run .#update-lockfile -- --ignore-semver
```

View file

@ -1,26 +1,27 @@
{
"nodes": {
"flake-utils": {
"nix-filter": {
"locked": {
"lastModified": 1653893745,
"narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=",
"lastModified": 1653590866,
"narHash": "sha256-E4yKIrt/S//WfW5D9IhQ1dVuaAy8RE7EiCMfnbrOC78=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1",
"repo": "nix-filter",
"rev": "3e81a637cdf9f6e9b39aeb4d6e6394d1ad158e16",
"type": "github"
},
"original": {
"id": "flake-utils",
"type": "indirect"
"owner": "numtide",
"repo": "nix-filter",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1655200170,
"narHash": "sha256-/yzkX+10sJhYNIcTtZ5ObS+nh/HrJp01XLaubzbRDcU=",
"lastModified": 1659052185,
"narHash": "sha256-TUbwbzCbprtWB9EtXPM52cWuKETuCV3H+cMXjLRbwTw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9ff91ce2e4c5d70551d4c8fd8830931c6c6b26b8",
"rev": "9370544d849be8a07193e7611d02e6f6f1b10768",
"type": "github"
},
"original": {
@ -48,7 +49,7 @@
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nix-filter": "nix-filter",
"nixpkgs": "nixpkgs",
"npmlock2nix": "npmlock2nix"
}

View file

@ -3,6 +3,7 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05";
nix-filter.url = "github:numtide/nix-filter";
npmlock2nix = {
url = "github:nix-community/npmlock2nix";
flake = false;
@ -12,7 +13,7 @@
outputs = {
self,
nixpkgs,
flake-utils,
nix-filter,
npmlock2nix,
}: let
# At the moment, we only deploy to x86_64-linux. Update when we
@ -24,44 +25,25 @@
})
];
pkgs = import nixpkgs {inherit system overlays;};
package = import ./nix/package.nix {
inherit self pkgs;
nix-filter = nix-filter.lib;
};
in {
apps.${system} = import ./nix/utilities {inherit pkgs;};
packages.${system} = rec {
tlaternet-templates = pkgs.npmlock2nix.build {
src = self;
installPhase = ''
cp -r dist $out/
'';
postFixup = ''
${pkgs.rename}/bin/rename 's/.html$/.html.hbs/' $out/browser/*.html
'';
node_modules_attrs = {
buildInputs = with pkgs; [
pkg-config
python3
vips
glib
];
};
};
tlaternet-templates = package.package;
default = tlaternet-templates;
};
devShells.${system} = {
default = pkgs.npmlock2nix.shell {
src = self;
node_modules_attrs = {
buildInputs = with pkgs; [
pkg-config
python3
vips
glib
];
};
default = package.shell;
};
checks.${system} = import ./nix/checks.nix {
inherit self pkgs;
nix-filter = nix-filter.lib;
};
};
}

67
nix/checks.nix Normal file
View file

@ -0,0 +1,67 @@
{
self,
nix-filter,
pkgs,
}: let
inherit (builtins) removeAttrs;
inherit (pkgs.lib) concatStringsSep;
mkNodeCheck = {
buildInputs ? [],
checkCommands,
...
} @ attrs: let
extraAttrs = removeAttrs attrs ["buildInputs"];
in
self.packages.${pkgs.system}.default.overrideAttrs (old:
{
src = nix-filter {
root = self;
include = [
../package.json
../tsconfig.json
../.eslintrc.yaml
../.parcelrc
../.posthtmlrc
../.prettierrc
nix-filter.isDirectory
(nix-filter.matchExt "ts")
(nix-filter.matchExt "tsx")
(nix-filter.matchExt "html")
(nix-filter.matchExt "scss")
];
};
buildInputs = old.buildInputs ++ buildInputs;
checkPhase = ''
mkdir -p $out
${concatStringsSep "\n" (map (command: "${command} | tee $out/check.log") checkCommands)}
'';
doCheck = true;
dontBuild = true;
dontInstall = true;
}
// extraAttrs);
in {
style = mkNodeCheck {
checkCommands = [
"npm run style"
];
};
types = mkNodeCheck {
checkCommands = [
"npm run check"
];
};
lints = mkNodeCheck {
checkCommands = [
"npm run lint"
];
};
}

74
nix/package.nix Normal file
View file

@ -0,0 +1,74 @@
{
self,
nix-filter,
pkgs,
}: let
inherit (pkgs.lib) cleanSource;
packageJson =
pkgs.runCommand "package.json" {
nativeBuildInputs = with pkgs; [yj];
src = "";
} ''
cat ${self}/package.yaml | yj > $out
'';
prePatch = ''
ln -s ${packageJson} package.json;
'';
node_modules_attrs = {
packageJson = "${packageJson}";
# Dependencies that should be available in the node build
buildInputs = with pkgs; [
pkg-config
python3
vips
glib
];
};
# Dependencies that should be available outside of the node build
buildInputs = with pkgs; [
util-linux
];
in {
package = pkgs.npmlock2nix.build {
inherit buildInputs prePatch node_modules_attrs;
src = cleanSource self;
buildCommands = ["npm run build-dist"];
installPhase = ''
cp -r dist $out/
'';
};
shell = pkgs.npmlock2nix.shell {
inherit prePatch node_modules_attrs;
buildInputs =
buildInputs
++ (with pkgs; [
clang-tools
]);
src = nix-filter {
root = self;
include = [
"package.yaml"
"package-lock.json"
];
};
shellHook = ''
if [ -e package.json ]; then
unlink package.json
fi
ln -s ${packageJson} package.json
'';
};
}

20
nix/utilities/default.nix Normal file
View file

@ -0,0 +1,20 @@
{pkgs}: let
inherit (builtins) readFile;
update-lockfile = pkgs.writeShellApplication {
name = "update-lockfile";
runtimeInputs = with pkgs; [
git
yj
nodejs-14_x
direnv
nodePackages.npm-check-updates
];
text = readFile ./update-lockfile;
};
in {
update-lockfile = {
type = "app";
program = "${update-lockfile}/bin/update-lockfile";
};
}

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -eu
cd "$(git rev-parse --show-toplevel)"
if [ -L node_modules ]; then
unlink node_modules
fi
if [ -L package.json ]; then
unlink package.json
fi
yj < package.yaml > package.json
if [ "${1}" == "--ignore-semver" ]; then
npm-check-updates -u
fi
npm install --package-lock-only
rm -rf node_modules
rm package.json
direnv reload

8898
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,61 +0,0 @@
{
"name": "tlaternet",
"version": "1.0.0",
"description": "tlaternet web interface",
"author": "Tristan Maat <tm@tlater.net>",
"license": "MIT",
"private": true,
"dependencies": {
"@fortawesome/fontawesome-free": "^5.13.1",
"bootstrap": "^4.5.0",
"classnames": "^2.2.6",
"immutability-helper": "^3.1.1",
"jquery": "^3.5.1",
"popper.js": "^1.16.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-redux": "^7.2.0",
"redux": "^4.0.5",
"redux-act": "^1.8.0",
"three": "^0.127.0"
},
"devDependencies": {
"@babel/preset-env": "^7.18.2",
"@babel/preset-typescript": "^7.17.12",
"@parcel/transformer-sass": "^2.6.1",
"@parcel/validator-typescript": "^2.6.1",
"@types/jquery": "^3.5.14",
"@types/react": "^16.14.28",
"@types/react-dom": "^17.0.17",
"@types/react-redux": "^7.1.24",
"@types/three": "^0.127.1",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"eslint": "^7.32.0",
"parcel": "^2.6.1",
"posthtml-extend": "^0.6.3",
"posthtml-favicons": "^1.3.0",
"posthtml-include": "^1.7.4",
"posthtml-markdownit": "^1.1.0",
"sass": "^1.52.3",
"typescript": "^4.7.4",
"typescript-language-server": "^0.4.0"
},
"scripts": {
"build": "parcel build --no-autoinstall src/index.html",
"serve": "parcel serve --no-autoinstall src/index.html",
"watch": "parcel watch --no-autoinstall src/index.html",
"build-dist": "parcel build --no-cache --no-autoinstall src/index.html; rename '.html' '.html.hbs' dist/browser/*.html"
},
"targets": {
"browser": {
"engines": {
"browsers": [
">1%",
"not dead"
]
},
"distDir": "dist"
}
}
}

82
package.yaml Normal file
View file

@ -0,0 +1,82 @@
name: tlaternet
version: 1.0.0
description: tlaternet web interface
author: Tristan Maat <tm@tlater.net>
license: MIT
private: true
dependencies:
# Libraries
gl-matrix: ^3.4.3 # To help with 3D math in WebGL code
classnames: ^2.3.1 # To manage CSS class names in react code
# Fonts
hack-font: ^3.3.0
'@fontsource/arimo': ^4.5.8
'@fontsource/nunito': ^4.5.9
'@fortawesome/fontawesome-free': ^6.1.1
# Frameworks for static content
bulma: ^0.9.4
# React-redux stuff
react: ^18.2.0
react-dom: ^18.2.0
react-use-error-boundary: ^3.0.0 # TODO(tlater): Remove when react implement their own
redux: ^4.2.0
'@reduxjs/toolkit': ^1.8.3
react-redux: ^8.0.2
devDependencies:
# Parcel & plugins
parcel: ^2.6.2
'@parcel/transformer-sass': ^2.6.2
'@parcel/transformer-glsl': 2.6.2
# Build tools
typescript: ^4.7.4
sass: ^1.53.0
posthtml-extend: ^0.6.3
posthtml-favicons: ^1.4.0
posthtml-include: ^1.7.4
posthtml-markdownit: ^1.3.0
'@babel/preset-env': ^7.18.6
# Type shims
'@types/react-dom': ^18.0.6
'@types/react-redux': ^7.1.24
# Dev tools
npm-check-updates: ^16.0.5
prettier: ^2.7.1
typescript-language-server: ^0.11.2
typescript-eslint-language-service: ^5.0.0
eslint: ^8.21.0
'@typescript-eslint/parser': ^5.32.0
'@typescript-eslint/eslint-plugin': ^5.32.0
vscode-langservers-extracted: ^4.2.1
scripts:
# Dev workflow
build: parcel build --no-autoinstall
serve: parcel serve --no-autoinstall
watch: parcel watch --no-autoinstall
# Production build
build-dist: parcel build --no-cache --no-autoinstall && rename '.html' '.html.hbs' dist/*.html
# Checks
check: tsc --noEmit
style: prettier --check src
lint: eslint --max-warnings=0 --format unix src
# Parcel config
source: src/index.html
browserslist: '> 1%, not dead'

View file

@ -1,26 +1,19 @@
<extends src="./lib/html/base.html">
<block name="stylesheets">
<style>
.no-js .head-line .typed {
visibility: visible;
}
.head-line .typed {
visibility: hidden;
}
</style>
</block>
<block name="content">
<h1 class="head-line">
$&nbsp;<span class="typed">Welcome to tlater.net!</span>
<section class="section">
<div class="container">
<h1 class="title has-text-weight-normal is-family-monospace">
$&nbsp;<span id="typed-welcome"></span>
</h1>
<hr />
<div class="row">
<div class="col-md-6">
<div class="columns">
<div class="column content">
<!-- prettier-ignore -->
<markdown>
#### About Me
### About Me
Looks like you found my website. I suppose introductions are
in order.
@ -33,25 +26,22 @@
If not, well, this is also a great place to "meet" me. Have a
nosey!
#### This Website
### This Website
There is not a whole lot here at the moment.
You may find the following interesting though:
- A [little web app](~/src/music_sample.html) showing
off what WebGL can do in combination with the JavaScript
Audio interface.
</markdown>
<!-- Parcel isn't smart enough to pick up cross-page links if they're in markdown blocks -->
<ul>
<li>
A <a href="~/src/music_sample.html">little web app</a> showing off
what WebGL can do in combination with the JavaScript Audio
interface.
</li>
</ul>
</div>
<div class="col-md-6">
<div class="column content">
<!-- prettier-ignore -->
<markdown>
#### My Work
### My Work
I'm a software consultant working for
[Codethink](https://www.codethink.co.uk) in Manchester,
@ -84,9 +74,7 @@
</markdown>
</div>
</div>
</block>
<block name="footer">
<script type="module" src="./index.ts" defer></script>
</div>
</section>
</block>
</extends>

View file

@ -1,119 +0,0 @@
import jQuery from "jquery";
// Helpers
/**
* "Types" out a DOM element, emulating the way a human might.
*/
class Typer {
private element: JQuery;
private text: string;
private cursor: boolean;
private typed: number;
private min: number;
private max: number;
private blink_tick: number;
private blink_timeout: number;
private end?: number;
/**
* Create the typer.
* @param {HTMLElement} element - The element to type.
* @param {number} blink - The time between cursor blinks.
* @param {number} blink_timeout - How long the cursor should keep
* blinking for after the text
* finishes typing.
*/
constructor(element: JQuery<HTMLElement>, blink: number, blink_timeout: number) {
// Retrieve the current content and wipe it. We also make the
// element visible if it was hidden.
this.element = element;
this.text = this.element.html();
this.element.html("");
this.element.css("visibility", "visible");
this.cursor = false;
this.typed = 0;
this.min = 20;
this.max = 70;
this.blink_tick = blink;
this.blink_timeout = blink_timeout;
this.end = null;
}
/**
* Start typing.
*/
type() {
this._type();
this._blink();
}
/**
* Draw the current text line, i.e., anything that has been typed
* so far, and a cursor if it is currently supposed to be on.
* @private
*/
_draw() {
let text = this.text.slice(0, this.typed);
if (this.cursor) {
text += "\u2588";
}
window.requestAnimationFrame(() => this.element.html(text));
}
/**
* Type the next character, and prepare to draw the next one. If
* no new characters are to be drawn, set the end timestamp.
* @private
*/
_type() {
this.typed += 1;
this._draw();
if (this.typed != this.text.length)
setTimeout(this._type.bind(this), this._type_tick());
else {
this.end = Date.now();
}
}
/**
* Make the cursor change blink status, and prepare for the next
* blink.
* @private
*/
_blink() {
this.cursor = !this.cursor;
this._draw();
// As long as we are typing, keep blinking
if (this.typed != this.text.length)
setTimeout(this._blink.bind(this), this.blink_tick);
// Once typing ends, keep going for a little bit
else if (Date.now() - this.end < this.blink_timeout)
setTimeout(this._blink.bind(this), this.blink_tick);
// Make sure we get rid of the cursor in the end
else {
this.cursor = true;
setTimeout(this._blink.bind(this), this.blink_tick);
}
}
/**
* Calculate a "human" time for the next character to type.
* @private
*/
_type_tick() {
return Math.round(Math.random() * this.max) + this.min;
}
}
jQuery(($) => {
const typer = new Typer($(".head-line .typed").first(), 500, 3000);
typer.type();
});

View file

@ -1,10 +1,10 @@
<!DOCTYPE html>
<html class="no-js" lang="en">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="tlater.net web server" />
<meta name="author" contnet="Tristan Daniël Maat" />
<meta name="viewport" content="width=device-width initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="./icon.svg" type="image/x-icon" />
<link rel="stylesheet" href="~/src/lib/scss/main.scss" />
@ -13,28 +13,17 @@
<title>tlater.net</title>
</head>
<body class="d-flex flex-column">
<body>
<block name="navigation">
<include src="lib/html/navigation.html"></include>
</block>
<div class="container floating-container">
<include src="lib/html/message-flash.html"></include>
<block name="content"></block>
</div>
<script
type="text/javascript"
src="~/node_modules/jquery/dist/jquery.min.js"
defer
></script>
<script
type="module"
src="~/node_modules/bootstrap/dist/js/bootstrap.min.js"
defer
></script>
<script type="module" src="~/src/lib/js/main.ts" defer></script>
<block name="footer"></block>
<block name="footer">
<script type="module" src="lib/js/index.ts"></script>
</block>
</body>
</html>

View file

@ -1,11 +1,8 @@
<span>
{{#if flash}}
<div
class="alert alert-{{flash.type}} alert-dismissible fade show"
role="alert"
>
{{ flash.message }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<div class="notification is-{{flash.type}}">
<button class="delete" aria-label="Close"></button>
<span role="alert"> {{ flash.message }} </span>
</div>
{{/if}}
</span>

View file

@ -1,31 +1,23 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">tlater</a>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbar"
aria-controls="#navbar"
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item has-text-primary is-uppercase" href="/">tlater</a>
<a
class="navbar-burger"
role="button"
aria-label="menu"
aria-expanded="false"
aria-label="Toggle navigation"
data-target="main-navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div id="navbar" class="navbar-collapse collapse">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" href="~/src/mail.html">E-Mail</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://www.gitlab.com/tlater">GitLab</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://www.github.com/TLATER">GitHub</a>
</li>
</ul>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="main-navigation" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="~/src/mail.html"> E-Mail </a>
<a class="navbar-item" href="https://www.gitlab.com/tlater"> GitLab </a>
<a class="navbar-item" href="https://www.github.com/TLATER"> GitHub </a>
</div>
</div>
</nav>

67
src/lib/js/index.ts Normal file
View file

@ -0,0 +1,67 @@
function registerFlashCloseButtons() {
const flashButtons = document.querySelectorAll(".notification .delete");
for (const flashButton of flashButtons) {
if (flashButton.parentNode === null) {
throw new Error("invalid flash button");
}
const flash = flashButton.parentNode;
flash.addEventListener("click", () => {
if (flash.parentNode === null) {
throw new Error("invalid flash message");
}
flash.parentNode.removeChild(flash);
});
// In development, there won't be a web server hooked up to
// this to render the flash message, so we remove it entirely
if (process.env.NODE_ENV === "development") {
if (
flash.parentNode === null ||
flash.parentNode.parentNode === null
) {
return;
}
console.warn("Disabling flash message");
// Get the containing <span> element
const block = flash.parentNode;
flash.parentNode.parentNode.removeChild(block);
}
}
}
function registerNavCollapseButtons() {
const navbarButtons = document.getElementsByClassName("navbar-burger");
for (const navbarButton of navbarButtons) {
navbarButton.addEventListener("click", () => {
if (
!(navbarButton instanceof HTMLElement) ||
!navbarButton.dataset.target
) {
throw new Error("invalid navbar button");
}
const target = document.getElementById(navbarButton.dataset.target);
if (target === null) {
throw new Error("could not find navbar button target");
}
navbarButton.classList.toggle("is-active");
target.classList.toggle("is-active");
});
}
}
document.addEventListener("DOMContentLoaded", () => {
registerFlashCloseButtons();
registerNavCollapseButtons();
});
export {};

View file

@ -1,3 +0,0 @@
import jQuery from "jquery";
jQuery(($) => $("html").removeClass("no-js"));

View file

@ -1,99 +0,0 @@
@import "~/node_modules/bootstrap/scss/_functions";
@import "~/node_modules/bootstrap/scss/_variables";
@import "~/node_modules/bootstrap/scss/_mixins";
// Theme colors
$green: #2aa889;
$cyan: #99d1ce;
$dark: #11151c;
$colors: (
"green": $green,
"cyan": $cyan
);
$theme-colors: (
"primary": $primary,
"secondary": $secondary,
"success": $green,
"info": $cyan,
"warning": $warning,
"danger": $danger,
"light": $light,
"dark": $dark
);
// Site colors
$body-bg: #0f0f0f;
$body-color: #dddddd;
// Headers
$headings-font-family: "Nunito", $font-family-base;
h1 {
color: $cyan !important;
}
// Links
$link-hover-color: $green;
a:visited {
color: $green;
}
// hr
$hr-border-color: #dddddd;
// Navbar
$navbar-dark-color: rgba($white, .75);
$navbar-dark-hover-color: rgba($white, 90);
.navbar-dark {
border: 1px solid #080808;
}
.navbar-dark .navbar-brand {
color: $cyan !important;
text-transform: uppercase;
font-family: $headings-font-family;
&:hover {
color: $white !important;
}
}
// Optional
@import "~/node_modules/bootstrap/scss/_root";
@import "~/node_modules/bootstrap/scss/_reboot";
@import "~/node_modules/bootstrap/scss/_type";
@import "~/node_modules/bootstrap/scss/_images";
@import "~/node_modules/bootstrap/scss/_code";
@import "~/node_modules/bootstrap/scss/_grid";
@import "~/node_modules/bootstrap/scss/_tables";
@import "~/node_modules/bootstrap/scss/_forms";
@import "~/node_modules/bootstrap/scss/_buttons";
@import "~/node_modules/bootstrap/scss/_transitions";
@import "~/node_modules/bootstrap/scss/_dropdown";
@import "~/node_modules/bootstrap/scss/_button-group";
@import "~/node_modules/bootstrap/scss/_input-group";
@import "~/node_modules/bootstrap/scss/_custom-forms";
@import "~/node_modules/bootstrap/scss/_nav";
@import "~/node_modules/bootstrap/scss/_navbar";
@import "~/node_modules/bootstrap/scss/_card";
@import "~/node_modules/bootstrap/scss/_breadcrumb";
@import "~/node_modules/bootstrap/scss/_pagination";
@import "~/node_modules/bootstrap/scss/_badge";
@import "~/node_modules/bootstrap/scss/_jumbotron";
@import "~/node_modules/bootstrap/scss/_alert";
@import "~/node_modules/bootstrap/scss/_progress";
@import "~/node_modules/bootstrap/scss/_media";
@import "~/node_modules/bootstrap/scss/_list-group";
@import "~/node_modules/bootstrap/scss/_close";
@import "~/node_modules/bootstrap/scss/_toasts";
@import "~/node_modules/bootstrap/scss/_modal";
@import "~/node_modules/bootstrap/scss/_tooltip";
@import "~/node_modules/bootstrap/scss/_popover";
@import "~/node_modules/bootstrap/scss/_carousel";
@import "~/node_modules/bootstrap/scss/_spinners";
@import "~/node_modules/bootstrap/scss/_utilities";
@import "~/node_modules/bootstrap/scss/_print";

View file

@ -0,0 +1,45 @@
@use "sass:color";
@use "./_fonts";
@import "~/node_modules/bulma/sass/utilities/initial-variables.sass";
@import "~/node_modules/bulma/sass/utilities/functions.sass";
$black: #0f0f0f;
$grey-darker: #11151c;
$grey-light: #dddddd;
$white: #ffffff;
$red: #dc322f;
$orange: #d26937;
$yellow: #b58900;
$blue: #195466;
$cyan: #599cab;
$green: #2aa889;
$primary: #99d1ce;
$link: $green;
$link-hover: color.scale($green, $lightness: +10%);
$link-active: color.scale($green, $lightness: +10%);
$link-focus: color.scale($green, $lightness: +10%);
$input-color: $grey-light;
$input-placeholder-color: $grey-light; // Some opacity is applied to this
$weight-normal: 400;
$scheme-main: $black;
$family-sans-serif: Nunito, $family-sans-serif;
$family-monospace: Hack, $family-monospace;
$text: $grey-light;
$text-strong: $primary;
$label-color: $text;
$content-heading-color: $text;
$hr-background-color: $grey-light;
$hr-height: 1px;
$pre-background: $grey-darker;
@import "~/node_modules/bulma";
@import "./_navbar";

View file

@ -1,27 +1,46 @@
/* nunito-italic - latin */
@font-face {
font-family: 'Nunito';
font-style: italic;
font-weight: 400;
src: url('~/src/lib/fonts/nunito-v9-latin-italic.eot'); /* IE9 Compat Modes */
src: local('Nunito Italic'), local('Nunito-Italic'),
url('~/src/lib/fonts/nunito-v9-latin-italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('~/src/lib/fonts/nunito-v9-latin-italic.woff2') format('woff2'), /* Super Modern Browsers */
url('~/src/lib/fonts/nunito-v9-latin-italic.woff') format('woff'), /* Modern Browsers */
url('~/src/lib/fonts/nunito-v9-latin-italic.ttf') format('truetype'), /* Safari, Android, iOS */
url('~/src/lib/fonts/nunito-v9-latin-italic.svg#Nunito') format('svg'); /* Legacy iOS */
@use "~/node_modules/@fontsource/nunito/scss/mixins" as Nunito;
@use "~/node_modules/@fontsource/arimo/scss/mixins" as Arimo;
$weights: 300, 400, 500, 600, 700;
@each $weight in $weights {
@include Nunito.fontFace(
$weight: $weight,
$display: auto,
$style: normal,
$fontDir: "npm:@fontsource/nunito/files"
);
@include Nunito.fontFace(
$weight: $weight,
$display: auto,
$style: italic,
$fontDir: "npm:@fontsource/nunito/files"
);
}
/* nunito-regular - latin */
@include Arimo.fontFace(
$weight: 400,
$display: auto,
$style: normal,
$fontDir: "npm:@fontsource/arimo/files"
);
// Hack *does* come with its own CSS, but it's broken and hasn't seen
// a release since https://github.com/source-foundry/Hack/issues/467
// was resolved.
$variants: regular 400 normal, bold 700 normal, italic 400 italic,
bolditalic 700 italic;
@each $name, $weight, $style in $variants {
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 400;
src: url('~/src/lib/fonts/nunito-v9-latin-regular.eot'); /* IE9 Compat Modes */
src: local('Nunito Regular'), local('Nunito-Regular'),
url('~/src/lib/fonts/nunito-v9-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('~/src/lib/fonts/nunito-v9-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
url('~/src/lib/fonts/nunito-v9-latin-regular.woff') format('woff'), /* Modern Browsers */
url('~/src/lib/fonts/nunito-v9-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
url('~/src/lib/fonts/nunito-v9-latin-regular.svg#Nunito') format('svg'); /* Legacy iOS */
font-family: "Hack";
src: url("npm:hack-font/build/web/fonts/hack-#{$name}-subset.woff2?sha=3114f1256")
format("woff2"),
url("npm:hack-font/build/web/fonts/hack-#{$name}-subset.woff?sha=3114f1256")
format("woff");
font-weight: $weight;
font-style: $style;
}
}

View file

@ -1,7 +0,0 @@
.head-line {
margin-top: 1rem;
}
.tag-line {
font-size: 1rem;
}

21
src/lib/scss/_navbar.scss Normal file
View file

@ -0,0 +1,21 @@
.navbar.is-dark {
border: 1px solid #000000;
& .navbar-brand > .navbar-item {
font-family: Arimo;
&:hover {
background-color: $dark !important;
color: $white !important;
}
}
& .navbar-start > .navbar-item {
color: rgba($white, 0.75);
&:hover {
background-color: $dark !important;
color: $white !important;
}
}
}

134
src/lib/scss/_typed.scss Normal file
View file

@ -0,0 +1,134 @@
@use "sass:math";
@use "sass:list";
/// Animate a blinking cursor.
@mixin cursor($duration) {
$name: cursor-09d03260130069771b6ddc1cb415f39fdd27ddfab7b01ba91273398c2d245ae4;
// The number of times we need to blink is = the number of full
// seconds (500ms * 2) that fit in the total duration, rounded up,
// and doubled.
$iterations: math.ceil(math.div($duration, 1s)) * 2;
animation: $name ease-in-out 500ms $iterations alternate;
content: " ";
@keyframes #{$name} {
from {
content: " ";
}
to {
content: "";
}
}
}
/// Animate a piece of text as if it was being typed by a human.
@mixin typed($text, $duration) {
// We don't want a linearly typed set of text, which makes this
// singificantly more complex.
//
// CSS animations normally do not permit per-frame changes in
// duration (since the total animation time is fixed). This means we
// need to create multiple animations, and delay them so that they
// happen in the time sequence we want.
//
// We generate the raw values with _generate-animations, and then
// split up the result into the animation API.
$frames: str-length($text);
$animations: _generate-animations($frames, 1.2s);
animation-name: _unzip($animations, 1);
animation-delay: _unzip($animations, 3);
animation-fill-mode: forwards;
content: "";
// We need to type each character in separate animations, see above
// comment.
@each $name, $character in $animations {
@keyframes #{$name} {
from {
content: str-slice($text, 0, $character);
}
to {
content: str-slice($text, 0, $character + 1);
}
}
}
}
/// Unzip a nested set of lists, taking the nth value of each sublist.
@function _unzip($lists, $i) {
$out: ();
$sep: comma;
@each $sublist in $lists {
$out: list.append($out, list.nth($sublist, $i), $sep);
}
@return $out;
}
/// Compute the sum of all numbers in a list.
@function _sum($list) {
$out: 0;
@each $val in $list {
$out: $out + $val;
}
@return $out;
}
/// Produce a list from a shorter list by repeating it up until size
/// $length.
@function _round-robin($base, $length) {
$out: ();
$sep: list.separator($out);
@for $i from 0 through $length {
$out: list.append($out, list.nth($base, $i % list.length($base) + 1));
}
@return $out;
}
/// Generate the actual animation values.
///
/// This generates a nested list as:
///
/// (keyframe-name, index, start time)
///
/// The duration of each frame is taken from the internal $delays in a
/// round robin fashion, to give some amount of human-like variance to
/// the duration of each frame.
///
/// Start time is set to the time at which the frame should start to
/// achieve the desired frame-by-frame duration.
@function _generate-animations($number, $total_duration) {
$id: d66fa0449c0b4d4ca287f8c96428af928b2987b4d88b72b7d60152d9a55d9f29;
$out: ();
$sep: list.separator($out);
// A set of "human-like" delays for each typed character. In
// practice, my typing seems to be about 20-70ms, but it looks a bit
// nicer to increase all typing by 20ms to make the effect more
// noticeable.
//
// Numbers generated once with a random number generator, rather
// than using `math.random()`, since they end up in CSS verbatim,
// and the build would be non-reproducible if we didn't do it this
// way. Using `math.random() wouldn't change this dynamically each
// time the page loads anyway, so we don't really lose anything by
// pre-generating these numbers.
$delays: 69ms, 83ms, 49ms, 48ms, 52ms, 59ms, 40ms, 71ms, 80ms, 67ms;
@for $animation from 0 through $number {
$out: list.append(
$out,
(
type-#{$id}-#{$animation},
$animation,
_sum(_round_robin($delays, $animation))
),
$sep
);
}
@return $out;
}

View file

@ -1,8 +1,18 @@
@import 'custom-bootstrap';
/* @import 'fonts'; */
@import 'headings';
@import "./_custom-bulma";
@import "./_typed";
html, body {
height: 100%;
width: 100%;
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
#typed-welcome {
&::before {
@include typed("Welcome to tlater.net!", 1.2s);
}
&::after {
@include cursor(6s);
}
}

View file

@ -1,15 +1,17 @@
<extends src="./lib/html/base.html">
<block name="content">
<h1 class="head-line">Contact Me</h1>
<section class="section">
<div class="container">
<h1 class="title has-text-weight-normal">Contact Me</h1>
<div class="row">
<div class="col-md-6">
<div class="columns">
<div class="column">
<form id="sendmail" role="form" action="mail.html" method="post">
<div class="form-group">
<label div="control-label" for="mail">Email address</label>
<div class="field">
<label class="label" for="mail">Email address</label>
<input
id="mail"
class="form-control"
class="input"
type="email"
placeholder="Your address"
name="mail"
@ -17,11 +19,11 @@
/>
</div>
<div class="form-group">
<label div="control-label" for="subject">Subject</label>
<div class="field">
<label class="label" for="subject">Subject</label>
<input
id="subject"
class="form-control"
class="input"
type="text"
placeholder="E.g. There's a typo on your home page!"
name="subject"
@ -30,11 +32,11 @@
/>
</div>
<div class="form-group">
<label div="control-label" for="message">Message</label>
<div class="field">
<label class="label" for="message">Message</label>
<textarea
id="message"
class="form-control"
class="textarea"
type="text"
rows="6"
name="message"
@ -43,13 +45,16 @@
></textarea>
</div>
<button class="btn btn-primary" type="submit" form="sendmail">
Send
</button>
<div class="field">
<div class="control">
<button class="button is-link">Send</button>
</div>
</div>
</form>
</div>
<div class="col-md-6">
<div class="column content">
<!-- prettier-ignore -->
<markdown>
Any messages you enter here are directly forwarded to me. I aim to
respond within a day.
@ -61,5 +66,7 @@
</markdown>
</div>
</div>
</div>
</section>
</block>
</extends>

View file

@ -1,94 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import Controls from "./components/controls";
import Visualizer from "./components/visualizer";
import { State } from "./store";
type AudioState = {
audioContext: AudioContext;
audioSource: HTMLAudioElement;
audioSourceNode: MediaElementAudioSourceNode;
audioVolume: GainNode;
};
type MusicPlayerProps = {
playing: boolean;
muted: boolean;
source?: string;
};
class MusicPlayer extends React.Component<MusicPlayerProps, State> {
private audioState: AudioState;
constructor(props: MusicPlayerProps) {
super(props);
const context = new AudioContext();
const source = new Audio();
const sourceNode = context.createMediaElementSource(source);
const volume = context.createGain();
sourceNode.connect(volume);
volume.connect(context.destination);
this.audioState = {
audioContext: context,
audioSourceNode: sourceNode,
audioSource: source,
audioVolume: volume,
};
}
render() {
return (
<div id="player" style={{ height: "100%", width: "100%" }}>
<Visualizer
audioContext={this.audioState.audioContext}
audioSource={this.audioState.audioSourceNode}
/>
<Controls />
</div>
);
}
componentDidUpdate() {
const context = this.audioState.audioContext;
const source = this.audioState.audioSource;
const volume = this.audioState.audioVolume;
// First, set the audio source (if it changed)
if (this.props.source && source.src != this.props.source) {
source.src = this.props.source;
}
if (this.props.playing) {
source
.play()
.then(() => {
console.info("Started playing audio");
})
.catch((error) => {
console.error(`Could not play audio: ${error}`);
});
} else {
source.pause();
}
if (!this.props.muted) {
volume.gain.setValueAtTime(1, context.currentTime);
} else {
volume.gain.setValueAtTime(0, context.currentTime);
}
}
}
function mapStateToProps(state: State): MusicPlayerProps {
return {
playing: state.musicState.playing,
muted: state.musicState.muted,
source: state.musicState.source,
};
}
export default connect(mapStateToProps)(MusicPlayer);

View file

@ -0,0 +1,2 @@
declare const mseq: string;
export default mseq;

View file

@ -1,49 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import { State } from "../store";
import { Title } from "../store/music/types";
import Indicator from "./indicator";
type ControlProps = {
title: Title;
};
class Controls extends React.Component<ControlProps, State> {
render() {
return (
<div id="playerControls" className="container-fluid fixed-bottom">
<div className="align-items-center row p-2">
<Indicator></Indicator>
<div
id="playerText"
className="text-justify text-truncate col-6 playerControlsContent"
>
{this.props.title.name} - {this.props.title.album}
</div>
{this.props.title.name === "Journey" &&
this.props.title.artist === "Mseq" ? (
<div id="copyrightNotice" className="col text-center">
<a href="http://dig.ccmixter.org/files/Mseq/54702">Journey</a>
&nbsp;by Mseq (c) copyright 2016 Licensed under a Creative
Commons&nbsp;
<a href="http://creativecommons.org/licenses/by-nc/3.0/">
Attribution Noncommercial (3.0)
</a>
&nbsp; license. Ft: Admiral Bob,Texas Radio Fish
</div>
) : null}
</div>
</div>
);
}
}
function mapStateToProps(state: State): ControlProps {
return {
title: state.musicState.title,
};
}
export default connect(mapStateToProps)(Controls);

View file

@ -1,60 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import classNames from "classnames";
import { Dispatch, State } from "../store";
import { togglePlay } from "../store/music/types";
type IndicatorProps = {
muted: boolean;
playing: boolean;
};
type IndicatorDispatch = {
play: () => void;
};
type Props = IndicatorProps & IndicatorDispatch;
class Indicator extends React.Component<Props, State> {
click() {
this.props.play();
}
render() {
const classes = classNames({
btn: true,
"col-auto": true,
fas: true,
"fa-muted": this.props.muted,
"fa-play": this.props.playing,
"fa-pause": !this.props.playing,
});
return (
<button
type="button"
id="playerIndicator"
onClick={this.click.bind(this)}
className={classes}
></button>
);
}
}
function mapStateToProps(state: State): IndicatorProps {
return {
muted: state.musicState.muted,
playing: state.musicState.playing,
};
}
function mapDispatchToProps(dispatch: Dispatch): IndicatorDispatch {
return {
play: () => {
dispatch(togglePlay());
},
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Indicator);

View file

@ -1,220 +0,0 @@
import React from "react";
import * as three from "three";
import { State } from "../store";
type VisualizerProps = {
audioContext: AudioContext;
audioSource: AudioNode;
};
class CanvasDrawer {
private analyser: AnalyserNode;
private canvas: HTMLCanvasElement;
private analyserData: Float32Array;
private boxes: Array<three.Mesh>;
private camera: three.PerspectiveCamera;
private renderer: three.WebGLRenderer;
private scene: three.Scene;
private angle: number;
private animationFrame: number;
private lastTime: number;
constructor(analyser: AnalyserNode, canvas: HTMLCanvasElement) {
this.analyser = analyser;
this.canvas = canvas;
// Set up analyser data storage
this.analyserData = new Float32Array(analyser.frequencyBinCount);
// Initialize the scene
this.scene = new three.Scene();
// Make a bunch of boxes to represent the bars
this.boxes = Array(analyser.frequencyBinCount);
const width = 2 / analyser.frequencyBinCount;
for (let freq = 0; freq < analyser.frequencyBinCount; freq++) {
const geometry = new three.BoxGeometry(1, 1, 1);
const material = new three.MeshLambertMaterial({
color: new three.Color(0x99d1ce),
});
const cube = new three.Mesh(geometry, material);
cube.scale.set(width, 1e-6, width);
cube.position.set(-1 + freq * width, 0, 0);
this.scene.add(cube);
this.boxes[freq] = cube;
}
// Add lights for shadowing
const ambientLight = new three.AmbientLight(0xffffff, 0.4);
this.scene.add(ambientLight);
const directionalLight = new three.DirectionalLight(0xffffff, 1);
directionalLight.position.set(-1, 0.3, -1);
directionalLight.castShadow = true;
this.scene.add(directionalLight);
// Add a camera
this.angle = 3;
this.camera = new three.PerspectiveCamera(
70,
canvas.width / canvas.height,
0.01,
10
);
this.camera.lookAt(0, 0, 0);
this.scene.add(this.camera);
this.rotateCamera(1);
// Add a renderer
this.renderer = new three.WebGLRenderer({
antialias: true,
canvas: canvas,
powerPreference: "low-power",
});
this.renderer.setClearColor(new three.Color(0x0f0f0f));
this.renderer.setSize(canvas.width, canvas.height);
// Set up canvas resizing
window.addEventListener("resize", this.resize.bind(this));
// Run the first, set the first animation frame time and start requesting
// animation frames
this.resize();
this.lastTime = 0;
this.animationFrame = requestAnimationFrame(this.render.bind(this));
}
render(time: number) {
// Set our animation frame to 0, so that if we stop, we don't try to cancel a past animation frame
this.animationFrame = 0;
// Update elapsed time
const elapsed = time - this.lastTime;
this.lastTime = time;
const camera = this.camera;
const renderer = this.renderer;
const scene = this.scene;
this.scaleBoxes();
this.rotateCamera(elapsed);
renderer.render(scene, camera);
this.animationFrame = requestAnimationFrame(this.render.bind(this));
}
scaleBoxes() {
const analyser = this.analyser;
analyser.getFloatFrequencyData(this.analyserData);
for (let freq = 0; freq < analyser.frequencyBinCount; freq++) {
let height = analyser.maxDecibels / this.analyserData[freq];
if (height > 0.3) {
height -= 0.3;
} else {
height = 1e-6;
}
this.boxes[freq].scale.y = height;
}
}
rotateCamera(elapsed: number) {
if (this.angle >= Math.PI * 2) {
this.angle = 0;
} else {
this.angle += 0.1 * (elapsed / 1000);
}
const camera = this.camera;
const angle = this.angle;
camera.position.x = 1.01 * Math.sin(angle);
camera.position.z = 1.01 * Math.cos(angle);
/* camera.position.y = (1 - Math.abs(angle - 0.5) / 0.5); */
camera.lookAt(0, 0, 0);
}
resize() {
const canvas = this.canvas;
if (canvas.parentElement === null) {
throw Error("Could not access canvas parent for size calculation");
}
// Compute the height of all our siblings
let combinedHeight = 0;
for (let i = 0; i < canvas.parentElement.children.length; i++) {
const child = canvas.parentElement.children[i];
if (child != canvas) {
combinedHeight += child.clientHeight;
}
}
// The remaining space we want to fill
const remainingHeight = canvas.parentElement.clientHeight - combinedHeight;
canvas.height = remainingHeight;
canvas.width = canvas.parentElement.clientWidth;
this.camera.aspect = canvas.width / remainingHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(canvas.width, remainingHeight);
}
stop() {
if (this.animationFrame != 0) {
cancelAnimationFrame(this.animationFrame);
}
}
}
class Visualizer extends React.Component<VisualizerProps, State> {
private analyser: AnalyserNode;
private canvas: React.RefObject<HTMLCanvasElement>;
private drawer: CanvasDrawer;
constructor(props: VisualizerProps) {
super(props);
this.canvas = React.createRef();
}
render(): React.ReactNode {
return (
<canvas
id="visualizer"
ref={this.canvas}
style={{ width: "100%", height: "100%" }}
></canvas>
);
}
componentDidMount(): void {
if (this.canvas.current === null) {
throw Error("Failed to create canvas; aborting");
}
this.analyser = this.props.audioContext.createAnalyser();
this.analyser.fftSize = 2048;
this.analyser.smoothingTimeConstant = 0.8;
this.props.audioSource.connect(this.analyser);
this.drawer = new CanvasDrawer(this.analyser, this.canvas.current);
}
componentWillUnmount(): void {
this.drawer.stop();
this.props.audioSource.disconnect(this.analyser);
}
}
export default Visualizer;

View file

@ -0,0 +1,44 @@
import React from "react";
import Indicator from "../indicator/Indicator";
import { useAppSelector } from "../../hooks";
function Controls() {
const title = useAppSelector((state) => state.musicPlayer.title);
let titleLine = <div className="level-item">{title.name}</div>;
if (title.name === "Journey" && title.artist === "Mseq") {
titleLine = (
<div className="level-item is-size-7-mobile is-flex-shrink-1">
<div>
<a href="http://dig.ccmixter.org/files/Mseq/54702">
Journey
</a>
&nbsp;by Mseq (c) copyright 2016 Licensed under a Creative
Commons&nbsp;
<a href="http://creativecommons.org/licenses/by-nc/3.0/">
Attribution Noncommercial (3.0)
</a>
&nbsp; license. Ft: Admiral Bob,Texas Radio Fish
</div>
</div>
);
}
return (
<div className="notification is-primary">
<div className="level is-mobile">
<div className="level-left is-flex-shrink-1">
<Indicator />
{titleLine}
</div>
<div className="level-right is-hidden-mobile">
<div className="level-item">{title.artist}</div>
</div>
</div>
</div>
);
}
export default Controls;

View file

@ -0,0 +1,44 @@
import React from "react";
import classNames from "classnames";
import { useAppSelector, useAppDispatch } from "../../hooks";
import { togglePlay, PlayState } from "../musicplayer/musicPlayerSlice";
function Indicator() {
const playing = useAppSelector((state) => state.musicPlayer.playing);
const muted = useAppSelector((state) => state.musicPlayer.muted);
const dispatch = useAppDispatch();
const buttonClass = classNames({
button: true,
"is-primary": true,
"level-item": true,
"is-loading": playing === PlayState.Loading,
});
const iconClass = classNames({
fas: true,
"fa-2x": true,
"fa-muted": muted,
"fa-play": playing === PlayState.Paused,
"fa-pause": playing === PlayState.Playing,
});
return (
<button
type="button"
onClick={() => {
dispatch(togglePlay(null)).catch((e) => {
console.error(e);
});
}}
className={buttonClass}
>
<span className="icon is-medium">
<i className={iconClass}></i>
</span>
</button>
);
}
export default Indicator;

View file

@ -0,0 +1,17 @@
import React from "react";
import Controls from "../controls/Controls";
import Visualizer from "../visualizer/Visualizer";
function MusicPlayer() {
return (
<div className="is-flex-grow-1 is-flex is-flex-direction-column">
<Visualizer />
<div className="is-flex-grow-0">
<Controls />
</div>
</div>
);
}
export default MusicPlayer;

View file

@ -0,0 +1,157 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import { RootState, AppDispatch } from "../../store";
//************************
// Interface definitions *
//************************
interface MusicPlayerState {
muted: boolean;
playing: PlayState;
title: MusicPlayerTitle;
}
interface MusicPlayerTitle {
source: string;
artist: string;
name: string;
album: string;
length: number;
}
enum PlayState {
Playing = "Playing",
Paused = "Paused",
Loading = "Loading",
}
//*********************
// Music player logic *
//*********************
class MusicPlayer {
private context?: AudioContext;
private source: HTMLAudioElement;
private sourceNode?: MediaElementAudioSourceNode;
private volume?: GainNode;
private analyser?: AnalyserNode;
constructor() {
this.source = new Audio();
}
get audioAnalyser() {
return this.analyser;
}
set src(source: string) {
this.source.src = source;
}
togglePlay = async (
_: null,
{ getState }: { getState: () => RootState }
): Promise<PlayState> => {
if (this.context === undefined) {
this.context = new AudioContext();
this.sourceNode = this.context.createMediaElementSource(
this.source
);
this.volume = this.context.createGain();
this.analyser = this.context.createAnalyser();
this.analyser.fftSize = 2048;
this.analyser.smoothingTimeConstant = 0.8;
this.sourceNode.connect(this.analyser);
this.sourceNode.connect(this.volume);
this.volume.connect(this.context.destination);
}
const playing = getState().musicPlayer.playing;
switch (playing) {
case PlayState.Playing:
this.source.pause();
return PlayState.Paused;
case PlayState.Paused:
case PlayState.Loading:
// Chrome's extra cookie, it refuses to play if we
// don't resume after the first user interaction.
await this.context.resume();
return this.source.play().then(() => PlayState.Playing);
}
};
}
const player = new MusicPlayer();
//*************************
// Redux state management *
//*************************
const initialState: MusicPlayerState = {
muted: false,
playing: PlayState.Paused,
title: {
source: "",
artist: "",
name: "",
album: "",
length: 0,
},
};
export const musicPlayerSlice = createSlice({
name: "musicPlayer",
initialState,
reducers: {
setSource: (state, action: PayloadAction<MusicPlayerTitle>) => {
state.title = action.payload;
player.src = state.title.source;
},
},
extraReducers: (builder) => {
builder
.addCase(togglePlay.pending, (state) => {
// If we are currently paused or loading, then this is
// actually an async call, otherwise we just
// synchronously pause the music.
if (state.playing !== PlayState.Playing) {
state.playing = PlayState.Loading;
}
})
.addCase(togglePlay.fulfilled, (state, { payload }) => {
state.playing = payload;
})
.addCase(togglePlay.rejected, (state, { error }) => {
if (error.message !== undefined) {
console.error(`Could not play music: ${error.message}`);
}
state.playing = PlayState.Paused;
});
},
});
export const togglePlay = createAsyncThunk<
PlayState,
null,
{ dispatch: AppDispatch; state: RootState }
>("musicPlayer/togglePlay", player.togglePlay, {
condition: (_, { getState }) => {
const playing = getState().musicPlayer.playing;
if (playing == PlayState.Loading) {
// Block updates when we're loading
return false;
}
},
});
export const { setSource } = musicPlayerSlice.actions;
export { PlayState, player as musicPlayer };
export type { MusicPlayerState };
export default musicPlayerSlice.reducer;

View file

@ -0,0 +1,342 @@
import { Shader } from "./Shader";
import { mat4 } from "gl-matrix";
import { Cube } from "./cube";
import vertexSource from "./shaders/vertices.glsl";
import fragmentSource from "./shaders/fragments.glsl";
const ROTATION_SPEED = 0.0;
const BACKGROUND_COLOR = [0.0588235294118, 0.0588235294118, 0.0588235294118];
class RendererError extends Error {}
class Renderer {
private canvas: HTMLCanvasElement;
private overlay: HTMLSpanElement;
private analyser: AnalyserNode;
private analyserData: Uint8Array;
private lastFrameTime: number;
private dTime: number;
private nextAnimationFrame?: number;
private rotation: number;
private buffers: {
indices?: WebGLBuffer;
positions?: WebGLBuffer;
normals?: WebGLBuffer;
fft?: WebGLBuffer;
velocitiesRead?: WebGLBuffer;
velocitiesWrite?: WebGLBuffer;
};
constructor(
analyser: AnalyserNode,
canvas: HTMLCanvasElement,
overlay: HTMLSpanElement
) {
this.canvas = canvas;
this.overlay = overlay;
this.analyser = analyser;
this.analyserData = new Uint8Array(analyser.frequencyBinCount);
this.lastFrameTime = 0;
this.dTime = 0;
this.rotation = 0;
this.buffers = {};
}
resizeAndDraw(
gl: WebGL2RenderingContext,
shader: Shader,
observerData: ResizeObserverEntry | null
) {
if (this.canvas.parentElement === null) {
throw new Error("renderer has been removed from dom");
}
if (this.nextAnimationFrame) {
cancelAnimationFrame(this.nextAnimationFrame);
}
// Note: For this to work, it's *incredibly important* for the
// canvas to be overflowable by its parent, and its parent to
// have `overflow: hidden` set. If using a flexbox, this means
// that the canvas has to be `position: absolute`.
let width: number;
let height: number;
if (observerData !== null && observerData.devicePixelContentBoxSize) {
width = observerData.devicePixelContentBoxSize[0].inlineSize;
height = observerData.devicePixelContentBoxSize[0].blockSize;
} else {
// Fallback; the above API is even newer than
// ResizeObserver, and by setting the observerData to null
// we can manually resize at least once without going
// through the API.
if (this.canvas.parentElement === null) {
throw new Error("canvas parent disappeared");
}
// Note: This *requires* `box-sizing: border-box`
({ width, height } =
this.canvas.parentElement.getBoundingClientRect());
}
this.canvas.width = width;
this.canvas.height = height;
gl.viewport(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
this.updateProjection(gl, shader);
// ResizeObserver will call when we should draw, so do our own
// time calculation and draw the scene.
this.updateTime(performance.now());
this.drawScene(gl, shader);
}
updateTime(time: number) {
this.dTime = time - this.lastFrameTime;
this.lastFrameTime = time;
}
initializeScene() {
if (this.canvas.parentElement === null) {
throw new Error("canvas was not added to page");
}
const gl = this.canvas.getContext("webgl2");
if (gl === null) {
throw new RendererError("WebGL (2) is unsupported on this browser");
}
const shader = Shader.builder(gl)
.addShader(vertexSource, gl.VERTEX_SHADER)
.addShader(fragmentSource, gl.FRAGMENT_SHADER)
.addAttribute("aVertexPosition")
.addAttribute("aVertexNormal")
.addAttribute("aHeight")
.addUniforms("uProjectionMatrix")
.addUniforms("uModelViewMatrix")
.addUniforms("uNormalMatrix")
.build();
this.initGL(gl, shader);
this.updateProjection(gl, shader);
this.initBuffers(gl);
try {
const observer = new ResizeObserver((elements) => {
// We only observe one element
const element = elements[0];
this.resizeAndDraw(gl, shader, element);
});
observer.observe(this.canvas.parentElement);
} catch (error) {
// If the browser does not support ResizeObserver, we
// simply don't resize. Resizing is hard enough, just use
// a modern browser.
if (error instanceof ReferenceError) {
console.warn(
"Browser does not support `ResizeObserver`. Canvas resizing will be disabled."
);
} else throw error;
}
this.resizeAndDraw(gl, shader, null);
}
updateProjection(gl: WebGLRenderingContext, shader: Shader) {
const projectionMatrix = mat4.create();
mat4.perspective(
projectionMatrix,
(45 * Math.PI) / 180,
gl.canvas.clientWidth / gl.canvas.clientHeight,
0.1,
100.0
);
gl.uniformMatrix4fv(
shader.getUniform("uProjectionMatrix"),
false,
projectionMatrix
);
}
initBuffers(gl: WebGLRenderingContext) {
// Scale down the unit cube before we use it
Cube.vertices = Cube.vertices.map(
(num: number) => num / this.analyser.frequencyBinCount
);
// Position buffer
const positionBuffer = gl.createBuffer();
if (positionBuffer === null) {
throw new Error("could not initialize position buffer");
}
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, Cube.vertices, gl.STATIC_DRAW);
this.buffers.positions = positionBuffer;
// Index buffer
const indexBuffer = gl.createBuffer();
if (indexBuffer === null) {
throw new Error("could not initialize index buffer");
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, Cube.indices, gl.STATIC_DRAW);
this.buffers.indices = indexBuffer;
// Surface normal buffer
const normalBuffer = gl.createBuffer();
if (normalBuffer === null) {
throw new Error("could not initialize normal buffer");
}
gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
gl.bufferData(gl.ARRAY_BUFFER, Cube.normals, gl.STATIC_DRAW);
this.buffers.normals = normalBuffer;
// fft data buffer
const fftBuffer = gl.createBuffer();
if (fftBuffer === null) {
throw new Error("could not initialize fft buffer");
}
// No need to initialize this buffer here since we will be
// updating it as soon as we start rendering anyway.
this.buffers.fft = fftBuffer;
}
initGL(gl: WebGLRenderingContext, shader: Shader) {
gl.useProgram(shader.program);
gl.clearColor(
BACKGROUND_COLOR[0],
BACKGROUND_COLOR[1],
BACKGROUND_COLOR[2],
1.0
);
gl.clearDepth(1.0);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
}
updateMatrices(gl: WebGLRenderingContext, shader: Shader) {
this.rotation += (this.dTime / 1000.0) * ROTATION_SPEED;
const modelViewMatrix = mat4.create();
mat4.translate(modelViewMatrix, modelViewMatrix, [
0.0,
0.025,
-((this.analyser.frequencyBinCount / gl.canvas.clientWidth) * 3),
]);
mat4.rotateX(modelViewMatrix, modelViewMatrix, Math.PI / 16);
mat4.rotateY(modelViewMatrix, modelViewMatrix, this.rotation);
mat4.translate(modelViewMatrix, modelViewMatrix, [-1.0, 0.0, 0.0]);
gl.uniformMatrix4fv(
shader.getUniform("uModelViewMatrix"),
false,
modelViewMatrix
);
const normalMatrix = mat4.create();
mat4.invert(normalMatrix, modelViewMatrix);
mat4.transpose(normalMatrix, normalMatrix);
gl.uniformMatrix4fv(
shader.getUniform("uNormalMatrix"),
false,
normalMatrix
);
}
updateBuffers(gl: WebGL2RenderingContext, shader: Shader) {
if (
this.buffers.indices === undefined ||
this.buffers.positions === undefined ||
this.buffers.normals === undefined ||
this.buffers.fft === undefined
) {
throw new Error("failed to create buffers before rendering");
}
// Update cube buffers
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.positions);
gl.vertexAttribPointer(
shader.getAttribute("aVertexPosition"),
3,
gl.FLOAT,
false,
0,
0
);
gl.enableVertexAttribArray(shader.getAttribute("aVertexPosition"));
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.buffers.indices);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.normals);
gl.vertexAttribPointer(
shader.getAttribute("aVertexNormal"),
3,
gl.FLOAT,
false,
0,
0
);
gl.enableVertexAttribArray(shader.getAttribute("aVertexNormal"));
// Update fft
this.analyser.getByteFrequencyData(this.analyserData);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.fft);
gl.bufferData(gl.ARRAY_BUFFER, this.analyserData, gl.STREAM_DRAW);
gl.vertexAttribPointer(
shader.getAttribute("aHeight"),
1,
gl.UNSIGNED_BYTE,
false,
0,
0
);
gl.vertexAttribDivisor(shader.getAttribute("aHeight"), 1);
gl.enableVertexAttribArray(shader.getAttribute("aHeight"));
}
drawScene(gl: WebGL2RenderingContext, shader: Shader) {
this.updateMatrices(gl, shader);
this.updateBuffers(gl, shader);
let cpuTime = 0;
if (process.env.NODE_ENV === "development") {
cpuTime = Math.round(performance.now() - this.lastFrameTime);
}
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawElementsInstanced(
gl.TRIANGLES,
36,
gl.UNSIGNED_SHORT,
0,
this.analyser.frequencyBinCount
);
if (process.env.NODE_ENV === "development") {
const gpuTime = Math.round(performance.now() - this.lastFrameTime);
const dTime = Math.round(this.dTime);
this.overlay.innerText = `${dTime}ms (${cpuTime}ms / ${gpuTime}ms)`;
}
this.nextAnimationFrame = requestAnimationFrame((time) => {
this.updateTime(time);
this.drawScene(gl, shader);
});
}
}
export { Renderer, RendererError };

View file

@ -0,0 +1,183 @@
type ShaderType =
| WebGLRenderingContext["VERTEX_SHADER"]
| WebGLRenderingContext["FRAGMENT_SHADER"];
interface ShaderSource {
source: string;
kind: ShaderType;
}
type ShaderAttributes = Map<string, number>;
type ShaderUniforms = Map<string, WebGLUniformLocation>;
class ShaderError extends Error {}
class Shader {
private program_: WebGLProgram;
private attributes_: ShaderAttributes;
private uniforms_: ShaderUniforms;
constructor(
program: WebGLProgram,
attributes: ShaderAttributes,
uniforms: ShaderUniforms
) {
this.program_ = program;
this.attributes_ = attributes;
this.uniforms_ = uniforms;
}
static builder(gl: WebGLRenderingContext): ShaderBuilder {
return new ShaderBuilder(gl);
}
get program(): WebGLProgram {
return this.program_;
}
public getAttribute(name: string): number {
const attribute = this.attributes_.get(name);
if (attribute === undefined) {
throw new ShaderError(`undefined shader attribute: ${name}`);
}
return attribute;
}
public getUniform(name: string): WebGLUniformLocation {
const uniform = this.uniforms_.get(name);
if (uniform === undefined) {
throw new ShaderError(`undefined shader uniform: ${name}`);
}
return uniform;
}
get uniforms(): ShaderUniforms {
return this.uniforms_;
}
}
class ShaderBuilder {
private gl: WebGLRenderingContext;
private sources: Array<ShaderSource>;
private attributes: Array<string>;
private uniforms: Array<string>;
public constructor(gl: WebGLRenderingContext) {
this.gl = gl;
this.sources = new Array<ShaderSource>();
this.attributes = new Array<string>();
this.uniforms = new Array<string>();
}
public addShader(source: string, kind: ShaderType): ShaderBuilder {
this.sources.push({ source, kind });
return this;
}
public addAttribute(name: string): ShaderBuilder {
this.attributes.push(name);
return this;
}
public addUniforms(name: string): ShaderBuilder {
this.uniforms.push(name);
return this;
}
public build(): Shader {
// Load, compile and link shader sources
const shaders = this.sources.map(({ source, kind }) => {
return this.loadShader(source, kind);
});
const shaderProgram = this.gl.createProgram();
if (shaderProgram === null) {
throw new ShaderError("failed to create shader program");
}
for (const shader of shaders) {
this.gl.attachShader(shaderProgram, shader);
}
this.gl.linkProgram(shaderProgram);
if (!this.gl.getProgramParameter(shaderProgram, this.gl.LINK_STATUS)) {
let message = "failed to link shader program";
const log = this.gl.getProgramInfoLog(shaderProgram);
if (log !== null) {
message = `failed to link shader program: ${log}`;
}
throw new ShaderError(message);
}
// Find attribute and uniform locations
const attributes = this.attributes.reduce((acc, attribute) => {
const attributeLocation = this.gl.getAttribLocation(
shaderProgram,
attribute
);
if (attributeLocation === -1) {
throw new ShaderError(
`shader attribute '${attribute}' could not be found`
);
}
return new Map<string, number>([
...acc,
[attribute, attributeLocation],
]);
}, new Map<string, number>());
const uniforms = this.uniforms.reduce((acc, uniform) => {
const uniformLocation = this.gl.getUniformLocation(
shaderProgram,
uniform
);
if (uniformLocation === null) {
throw new ShaderError(
`shader uniform '${uniform}' could not be found`
);
}
return new Map<string, WebGLUniformLocation>([
...acc,
[uniform, uniformLocation],
]);
}, new Map<string, WebGLUniformLocation>());
// Build actual shader object
return new Shader(shaderProgram, attributes, uniforms);
}
private loadShader(source: string, kind: ShaderType): WebGLShader {
const shader = this.gl.createShader(kind);
if (shader === null) {
throw new ShaderError(`failed to initialize shader "${source}"`);
}
this.gl.shaderSource(shader, source);
this.gl.compileShader(shader);
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
let message = `failed to compile shader "${source}"`;
const log = this.gl.getShaderInfoLog(shader);
if (log !== null) {
message = `failed to compile shader "${source}": ${log}`;
}
this.gl.deleteShader(shader);
throw new ShaderError(message);
}
return shader;
}
}
export { Shader, ShaderError };

View file

@ -0,0 +1,139 @@
import React, { useCallback, useState } from "react";
import { Renderer, RendererError } from "./Renderer";
import { ShaderError } from "./Shader";
import { useAppSelector } from "../../hooks";
import { PlayState, musicPlayer } from "../musicplayer/musicPlayerSlice";
function Visualizer() {
const playing = useAppSelector((state) => state.musicPlayer.playing);
const rendererState = useState<Renderer | null>(null);
let renderer = rendererState[0];
const setRenderer = rendererState[1];
const [renderError, setRenderError] = useState<JSX.Element | null>(null);
const visualizer = useCallback(
(visualizer: HTMLDivElement | null) => {
// TODO(tlater): Clean up state management. This is all
// but trivial; there's seemingly no good place to keep
// these big api objects (WebGLRenderingcontext or
// AudioContext).
//
// It's tricky, too, because obviously react expects to be
// in control of the DOM, and be allowed to delete our
// canvas and create a new one.
//
// For the moment, this works, but it's a definite hack.
if (renderer) {
return;
}
// Until we start playing music, there is nothing to render.
if (playing !== PlayState.Playing) {
return;
}
if (musicPlayer.audioAnalyser === undefined) {
throw new Error("MusicPlayer analyser was not set up on time");
}
// If we're rendering an error message, we won't be
// setting up the visualizer.
//
// Also, nonintuitively, renderError will be null here on
// subsequent iterations, so we can't rely on it to
// identify errors.
if (visualizer === null) {
return;
}
const canvas = visualizer.children[0];
const overlay = visualizer.children[1];
if (
!(canvas instanceof HTMLCanvasElement) ||
!(overlay instanceof HTMLSpanElement)
) {
throw new Error(
"react did not create our visualizer div correctly"
);
}
if (renderer === null) {
renderer = new Renderer(
musicPlayer.audioAnalyser,
canvas,
overlay
);
setRenderer(renderer);
}
try {
renderer.initializeScene();
} catch (error) {
// Log so we don't lose the stack trace
console.log(error);
if (error instanceof ShaderError) {
setRenderError(
<span>
Failed to compile shader; This is a bug, feel free
to contact me with this error message:
<pre>
<code className="has-text-danger">
{error.message}
</code>
</pre>
</span>
);
} else if (error instanceof RendererError) {
setRenderError(
<span>
This browser does not support WebGL 2, sadly. This
demo uses WebGL and specifically instanced drawing,
so unfortunately this means it can't run on your
browser/device.
</span>
);
} else if (error instanceof Error) {
setRenderError(
<span>
Something went very wrong; apologies, either your
browser is not behaving or there's a serious bug.
You can contact me with this error message:
<pre>
<code className="has-text-danger">
{error.message}
</code>
</pre>
</span>
);
} else {
setRenderError(
<span>
Something went very wrong; apologies, either your
browser is not behaving or there's a serious bug.
</span>
);
}
}
},
[playing, renderer, musicPlayer.audioAnalyser]
);
if (renderError === null) {
return (
<div
ref={visualizer}
className="is-flex-grow-1 is-clipped is-relative"
>
<canvas className="is-block is-absolute is-border-box"></canvas>
<span className="is-bottom-left"></span>
</div>
);
} else {
return renderError;
}
}
export default Visualizer;

View file

@ -0,0 +1,84 @@
/** * A hand-written 3d model of a cube.
*
* If this ever needs to be more than this, consider moving it to a
* proper .obj model.
*/
const Cube = {
// prettier-ignore
vertices: new Float32Array([
-1.0, -1.0, 1.0,
1.0, -1.0, 1.0,
1.0, 1.0, 1.0,
-1.0, 1.0, 1.0,
-1.0, -1.0, -1.0,
-1.0, 1.0, -1.0,
1.0, 1.0, -1.0,
1.0, -1.0, -1.0,
-1.0, 1.0, -1.0,
-1.0, 1.0, 1.0,
1.0, 1.0, 1.0,
1.0, 1.0, -1.0,
-1.0, -1.0, -1.0,
1.0, -1.0, -1.0,
1.0, -1.0, 1.0,
-1.0, -1.0, 1.0,
1.0, -1.0, -1.0,
1.0, 1.0, -1.0,
1.0, 1.0, 1.0,
1.0, -1.0, 1.0,
-1.0, -1.0, -1.0,
-1.0, -1.0, 1.0,
-1.0, 1.0, 1.0,
-1.0, 1.0, -1.0,
]),
// prettier-ignore
indices: new Uint16Array([
0, 1, 2, 0, 2, 3,
4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10, 11,
12, 13, 14, 12, 14, 15,
16, 17, 18, 16, 18, 19,
20, 21, 22, 20, 22, 23,
]),
// prettier-ignore
normals: new Float32Array([
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0
]),
};
export { Cube };

View file

@ -0,0 +1,12 @@
#version 300 es
// FRAGMENT SHADER
//
// Basic fragment shader, just passes along colors, we don't do much
// with textures or anything else complex in this project.
precision highp float;
flat in vec4 vColor;
out vec4 color;
void main() { color = vColor; }

View file

@ -0,0 +1,2 @@
declare const fragments: string;
export default fragments;

View file

@ -0,0 +1,50 @@
#version 300 es
// VERTEX SHADER
//
// Takes vertices of a unit cube, scales them up along Y according to
// aHeight, and colors them with basic diffuse shading.
#define CLEAR_COLOR vec4(0.0588235294118, 0.0588235294118, 0.0588235294118, 1.0)
#define BASE_COLOR vec3(0.6, 0.819607843137, 0.807843137255)
#define AMBIENT_LIGHT vec3(0.3, 0.3, 0.3)
#define LIGHT_DIRECTION normalize(vec3(0.85, 0.8, 0.75))
#define LIGHT_COLOR vec3(1.0, 1.0, 1.0)
precision highp float;
layout(location = 0) in vec4 aVertexPosition;
layout(location = 1) in vec3 aVertexNormal;
layout(location = 2) in float aHeight;
flat out vec4 vColor;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
void main() {
// The X position of each vertex depends on its cube's instance;
// they should align to the X axis.
float instanceX =
float(gl_InstanceID * 2) * abs(aVertexPosition.x) + aVertexPosition.x;
// To scale the boxes by their frequencies, scale vertex Y by the
// frequency.
float vertexY = aVertexPosition.y * aHeight;
gl_Position = uProjectionMatrix * uModelViewMatrix *
vec4(instanceX, vertexY, aVertexPosition.zw);
if (aHeight == 0.0) {
// Don't render cubes that don't currently have a height
// (frequency = 0)
vColor = CLEAR_COLOR;
} else {
// Properly shade and color any other cubes
vec4 transformedNormal = uNormalMatrix * vec4(aVertexNormal, 1.0);
float directionalLight =
max(dot(transformedNormal.xyz, LIGHT_DIRECTION), 0.0);
vec3 appliedColor =
BASE_COLOR * (directionalLight * LIGHT_COLOR + AMBIENT_LIGHT);
vColor = vec4(appliedColor.rgb, 1.0);
}
}

View file

@ -0,0 +1,2 @@
declare const vertices: string;
export default vertices;

5
src/music/hooks.ts Normal file
View file

@ -0,0 +1,5 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View file

@ -1,27 +1,30 @@
import React from "react";
import ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./store";
import MusicPlayer from "./MusicPlayer";
import { setSource, setTitle } from "./store/music/types";
// @ts-ignore - mp3 files have no types.
import mseq from "./Mseq_-_Journey.mp3";
import store from "./store";
import MusicPlayer from "./features/musicplayer/MusicPlayer";
import { setSource } from "./features/musicplayer/musicPlayerSlice";
import mseq from "./assets/Mseq_-_Journey.mp3";
const rootElement = document.getElementById("playerUI");
ReactDOM.render(
if (rootElement === null) {
throw Error("DOM seems to have failed to load. Something went very wrong.");
}
const root = createRoot(rootElement);
root.render(
<Provider store={store}>
<MusicPlayer />
</Provider>,
rootElement
</Provider>
);
store.dispatch(setSource(mseq));
store.dispatch(
setTitle({
name: "Journey",
setSource({
source: mseq,
artist: "Mseq",
name: "Journey",
album: "Unknown album",
length: 192052244,
})

View file

@ -1,6 +1,18 @@
@import url("~/node_modules/@fortawesome/fontawesome-free/scss/fontawesome");
@import url("~/node_modules/@fortawesome/fontawesome-free/scss/solid");
$fa-font-path: "npm:@fortawesome/fontawesome-free/webfonts";
#playerControls {
background-color: #11151c;
@import "~/node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
@import "~/node_modules/@fortawesome/fontawesome-free/scss/solid";
.is-border-box {
box-sizing: border-box !important;
}
.is-absolute {
position: absolute !important;
}
.is-bottom-left {
bottom: 0;
left: 0;
position: absolute !important;
}

15
src/music/player.ts Normal file
View file

@ -0,0 +1,15 @@
class Player {
constructor() {
console.info("Test");
}
}
let player: Player | null = null;
export default () => {
if (player === null) {
player = new Player();
}
return player;
};

13
src/music/store.ts Normal file
View file

@ -0,0 +1,13 @@
import { configureStore } from "@reduxjs/toolkit";
import musicPlayerReducer from "./features/musicplayer/musicPlayerSlice";
const store = configureStore({
reducer: {
musicPlayer: musicPlayerReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

View file

@ -1,20 +0,0 @@
import { createStore, combineReducers } from "redux";
import { MusicState } from "./music/types";
import { musicStateReducer } from "./music/reducers";
export interface State {
musicState: MusicState;
}
const rootReducer = combineReducers<State>({
musicState: musicStateReducer,
});
export const store = createStore(
rootReducer,
// @ts-ignore - These properties are set by the devtools extension
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
export type Dispatch = typeof store.dispatch;

View file

@ -1,45 +0,0 @@
import { createReducer } from "redux-act";
import update from "immutability-helper";
import {
Title,
MusicState,
setTitle,
toggleMute,
togglePlay,
setSource,
} from "./types";
const defaultTitle: Title = {
name: "Untitled",
artist: "Unknown Artist",
album: "Unknown Album",
length: 0,
};
const initialState: MusicState = {
muted: false,
playing: false,
title: defaultTitle,
playTime: 0,
};
export const musicStateReducer = createReducer<MusicState>(
{
[setTitle]: (state: MusicState, title: Title): MusicState => {
return update(state, {
title: { $set: title },
});
},
[togglePlay]: (state: MusicState): MusicState => {
return update(state, { $toggle: ["playing"] });
},
[toggleMute]: (state: MusicState): MusicState => {
return update(state, { $toggle: ["muted"] });
},
[setSource]: (state: MusicState, source: string): MusicState => {
return update(state, { source: { $set: source } });
},
},
initialState
);

View file

@ -1,35 +0,0 @@
import { Action, createAction } from "redux-act";
export interface Title {
name: string;
artist: string;
album: string;
/**
* The length of the title in nanoseconds.
*/
length: number;
}
export interface MusicState {
muted: boolean;
playing: boolean;
title: Title;
playTime: number;
source?: string;
}
export const setTitle: (_title: Title) => Action<null, null> = createAction(
"set currently playing title"
);
export const setPlayTime: (_time: number) => Action<null, null> = createAction(
"set the play time"
);
export const toggleMute: () => Action<null, null> = createAction("toggle mute");
export const togglePlay: () => Action<null, null> = createAction("toggle play");
export const setSource: (_source: string) => Action<null, null> = createAction(
"set the title"
);

View file

@ -1,10 +1,13 @@
<extends src="./lib/html/base.html">
<block name="stylesheets">
<link rel="stylesheet" , href="music/music.scss" />
<link rel="stylesheet" href="music/music.scss" />
</block>
<block name="footer">
<div id="playerUI" class="container-fluid flex-grow-1"></div>
<script type="module" src="./music/index.tsx"></script>
<block name="content">
<div id="playerUI" class="is-flex-grow-1 is-flex"></div>
</block>
<block name="footer" type="append">
<script type="module" src="music/index.tsx"></script>
</block>
</extends>

View file

@ -1,8 +1,16 @@
{
"compilerOptions": {
"strictNullChecks": true,
"strictPropertyInitialization": true,
"esModuleInterop": true,
"jsx": "react"
"jsx": "react",
"isolatedModules": true,
"target": "es2015",
"moduleResolution": "node",
"plugins": [
{
"name": "typescript-eslint-language-service"
}
]
}
}