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

8750
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,92 +1,80 @@
<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>
</h1>
<section class="section">
<div class="container">
<h1 class="title has-text-weight-normal is-family-monospace">
$&nbsp;<span id="typed-welcome"></span>
</h1>
<hr />
<hr />
<div class="row">
<div class="col-md-6">
<markdown>
#### About Me
<div class="columns">
<div class="column content">
<!-- prettier-ignore -->
<markdown>
Looks like you found my website. I suppose introductions are
in order.
### About Me
My name's Tristan, I'm an avid Dutch-South African software
consultant working in the UK. You probably either met me at an
open source conference, a hackathon, a badminton session or at
a roleplaying table.
Looks like you found my website. I suppose introductions are
in order.
If not, well, this is also a great place to "meet" me. Have a
nosey!
My name's Tristan, I'm an avid Dutch-South African software
consultant working in the UK. You probably either met me at an
open source conference, a hackathon, a badminton session or at
a roleplaying table.
#### This Website
If not, well, this is also a great place to "meet" me. Have a
nosey!
There is not a whole lot here at the moment.
### This Website
You may find the following interesting though:
</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>
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>
</div>
<div class="column content">
<!-- prettier-ignore -->
<markdown>
### My Work
I'm a software consultant working for
[Codethink](https://www.codethink.co.uk) in Manchester,
UK. Our specializaiton is open source software, so this has
allowed me to directly contribute to a number of open source
projects, notably
[BuildStream](https://www.gitlab.com/buildstream/buildstream),
an integration tool for large software stacks.
I've given a couple of talks on it, as well:
- Build meetup 2017
- Build meetup 2018
- Build meetup 2019
Outside of work for Codethink, I'm generally interested in
things such as NixOS and other tools that assist maintaining
Linux systems - mostly born out of my pursuit of the perfect
Linux desktop (feel free to have a browse through my
[dotfiles](https://github.com/tlater/dotfiles)).
I also just enjoy Programming, my core languages currently are
Rust, Python, Lisp and JavaScript (including a number of
frameworks and tools for these), although I have hopes to
eventually reduce these to just Rust ;)
If you're interested in seeing these things for yourself,
visit my [Gitlab](https://gitlab.com/tlater) and
[GitHub](https://github.com/tlater) pages.
</markdown>
</div>
</div>
</div>
<div class="col-md-6">
<markdown>
#### My Work
I'm a software consultant working for
[Codethink](https://www.codethink.co.uk) in Manchester,
UK. Our specializaiton is open source software, so this has
allowed me to directly contribute to a number of open source
projects, notably
[BuildStream](https://www.gitlab.com/buildstream/buildstream),
an integration tool for large software stacks.
I've given a couple of talks on it, as well:
- Build meetup 2017
- Build meetup 2018
- Build meetup 2019
Outside of work for Codethink, I'm generally interested in
things such as NixOS and other tools that assist maintaining
Linux systems - mostly born out of my pursuit of the perfect
Linux desktop (feel free to have a browse through my
[dotfiles](https://github.com/tlater/dotfiles)).
I also just enjoy Programming, my core languages currently are
Rust, Python, Lisp and JavaScript (including a number of
frameworks and tools for these), although I have hopes to
eventually reduce these to just Rust ;)
If you're interested in seeing these things for yourself,
visit my [Gitlab](https://gitlab.com/tlater) and
[GitHub](https://github.com/tlater) pages.
</markdown>
</div>
</div>
</block>
<block name="footer">
<script type="module" src="./index.ts" defer></script>
</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>
<include src="lib/html/message-flash.html"></include>
<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="content"></block>
<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 @@
{{#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>
{{/if}}
<span>
{{#if flash}}
<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 */
@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 */
@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: "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,65 +1,72 @@
<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">
<form id="sendmail" role="form" action="mail.html" method="post">
<div class="form-group">
<label div="control-label" for="mail">Email address</label>
<input
id="mail"
class="form-control"
type="email"
placeholder="Your address"
name="mail"
required
/>
<div class="columns">
<div class="column">
<form id="sendmail" role="form" action="mail.html" method="post">
<div class="field">
<label class="label" for="mail">Email address</label>
<input
id="mail"
class="input"
type="email"
placeholder="Your address"
name="mail"
required
/>
</div>
<div class="field">
<label class="label" for="subject">Subject</label>
<input
id="subject"
class="input"
type="text"
placeholder="E.g. There's a typo on your home page!"
name="subject"
autocomplete="off"
required
/>
</div>
<div class="field">
<label class="label" for="message">Message</label>
<textarea
id="message"
class="textarea"
type="text"
rows="6"
name="message"
autocomplete="off"
required
></textarea>
</div>
<div class="field">
<div class="control">
<button class="button is-link">Send</button>
</div>
</div>
</form>
</div>
<div class="form-group">
<label div="control-label" for="subject">Subject</label>
<input
id="subject"
class="form-control"
type="text"
placeholder="E.g. There's a typo on your home page!"
name="subject"
autocomplete="off"
required
/>
</div>
<div class="column content">
<!-- prettier-ignore -->
<markdown>
Any messages you enter here are directly forwarded to me. I aim to
respond within a day.
<div class="form-group">
<label div="control-label" for="message">Message</label>
<textarea
id="message"
class="form-control"
type="text"
rows="6"
name="message"
autocomplete="off"
required
></textarea>
Don't be upset about the form, I want to avoid the spam
publishing your email address brings with it... And minimize
the amount of mail that doesn't reach me, this form is an
exception in all my spam filters, you see ;)
</markdown>
</div>
<button class="btn btn-primary" type="submit" form="sendmail">
Send
</button>
</form>
</div>
</div>
<div class="col-md-6">
<markdown>
Any messages you enter here are directly forwarded to me. I aim to
respond within a day.
Don't be upset about the form, I want to avoid the spam
publishing your email address brings with it... And minimize
the amount of mail that doesn't reach me, this form is an
exception in all my spam filters, you see ;)
</markdown>
</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"
}
]
}
}