Rework website with bulma instead of bootstrap #6
|
@ -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))))
|
||||
|
||||
|
||||
|
|
|
@ -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
59
.eslintrc.yaml
Normal 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
1
.gitignore
vendored
|
@ -3,3 +3,4 @@
|
|||
/dist/
|
||||
/node_modules
|
||||
/result
|
||||
/package.json
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
},
|
||||
"posthtml-favicons": {
|
||||
"root": "src",
|
||||
"outDir": "./dist/browser/",
|
||||
"outDir": "./dist/",
|
||||
"configuration": {
|
||||
"appName": "tlater.net",
|
||||
"appShortName": "tlater.net",
|
||||
|
|
11
.prettierrc
Normal file
11
.prettierrc
Normal file
|
@ -0,0 +1,11 @@
|
|||
# -*- yaml -*-
|
||||
|
||||
tabWidth: 4
|
||||
|
||||
overrides:
|
||||
- files: "**/*.html"
|
||||
options:
|
||||
tabWidth: 2
|
||||
- files: "**/*.scss"
|
||||
options:
|
||||
tabWidth: 2
|
30
README.md
30
README.md
|
@ -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
|
||||
```
|
||||
|
|
23
flake.lock
23
flake.lock
|
@ -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"
|
||||
}
|
||||
|
|
48
flake.nix
48
flake.nix
|
@ -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
67
nix/checks.nix
Normal 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
74
nix/package.nix
Normal 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
20
nix/utilities/default.nix
Normal 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";
|
||||
};
|
||||
}
|
25
nix/utilities/update-lockfile
Normal file
25
nix/utilities/update-lockfile
Normal 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
8750
package-lock.json
generated
File diff suppressed because it is too large
Load diff
61
package.json
61
package.json
|
@ -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
82
package.yaml
Normal 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'
|
146
src/index.html
146
src/index.html
|
@ -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">
|
||||
$ <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">
|
||||
$ <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>
|
||||
|
|
119
src/index.ts
119
src/index.ts
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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">×</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>
|
||||
|
|
|
@ -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
67
src/lib/js/index.ts
Normal 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 {};
|
|
@ -1,3 +0,0 @@
|
|||
import jQuery from "jquery";
|
||||
|
||||
jQuery(($) => $("html").removeClass("no-js"));
|
|
@ -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";
|
45
src/lib/scss/_custom-bulma.scss
Normal file
45
src/lib/scss/_custom-bulma.scss
Normal 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";
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
.head-line {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tag-line {
|
||||
font-size: 1rem;
|
||||
}
|
21
src/lib/scss/_navbar.scss
Normal file
21
src/lib/scss/_navbar.scss
Normal 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
134
src/lib/scss/_typed.scss
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
117
src/mail.html
117
src/mail.html
|
@ -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>
|
||||
|
|
|
@ -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);
|
2
src/music/assets/Mseq_-_Journey.mp3.d.ts
vendored
Normal file
2
src/music/assets/Mseq_-_Journey.mp3.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
declare const mseq: string;
|
||||
export default mseq;
|
|
@ -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>
|
||||
by Mseq (c) copyright 2016 Licensed under a Creative
|
||||
Commons
|
||||
<a href="http://creativecommons.org/licenses/by-nc/3.0/">
|
||||
Attribution Noncommercial (3.0)
|
||||
</a>
|
||||
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);
|
|
@ -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);
|
|
@ -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;
|
44
src/music/features/controls/Controls.tsx
Normal file
44
src/music/features/controls/Controls.tsx
Normal 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>
|
||||
by Mseq (c) copyright 2016 Licensed under a Creative
|
||||
Commons
|
||||
<a href="http://creativecommons.org/licenses/by-nc/3.0/">
|
||||
Attribution Noncommercial (3.0)
|
||||
</a>
|
||||
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;
|
44
src/music/features/indicator/Indicator.tsx
Normal file
44
src/music/features/indicator/Indicator.tsx
Normal 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;
|
17
src/music/features/musicplayer/MusicPlayer.tsx
Normal file
17
src/music/features/musicplayer/MusicPlayer.tsx
Normal 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;
|
157
src/music/features/musicplayer/musicPlayerSlice.ts
Normal file
157
src/music/features/musicplayer/musicPlayerSlice.ts
Normal 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;
|
342
src/music/features/visualizer/Renderer.ts
Normal file
342
src/music/features/visualizer/Renderer.ts
Normal 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 };
|
183
src/music/features/visualizer/Shader.ts
Normal file
183
src/music/features/visualizer/Shader.ts
Normal 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 };
|
139
src/music/features/visualizer/Visualizer.tsx
Normal file
139
src/music/features/visualizer/Visualizer.tsx
Normal 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;
|
84
src/music/features/visualizer/cube.ts
Normal file
84
src/music/features/visualizer/cube.ts
Normal 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 };
|
12
src/music/features/visualizer/shaders/fragments.glsl
Normal file
12
src/music/features/visualizer/shaders/fragments.glsl
Normal 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; }
|
2
src/music/features/visualizer/shaders/fragments.glsl.d.ts
vendored
Normal file
2
src/music/features/visualizer/shaders/fragments.glsl.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
declare const fragments: string;
|
||||
export default fragments;
|
50
src/music/features/visualizer/shaders/vertices.glsl
Normal file
50
src/music/features/visualizer/shaders/vertices.glsl
Normal 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);
|
||||
}
|
||||
}
|
2
src/music/features/visualizer/shaders/vertices.glsl.d.ts
vendored
Normal file
2
src/music/features/visualizer/shaders/vertices.glsl.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
declare const vertices: string;
|
||||
export default vertices;
|
5
src/music/hooks.ts
Normal file
5
src/music/hooks.ts
Normal 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;
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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
15
src/music/player.ts
Normal 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
13
src/music/store.ts
Normal 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;
|
|
@ -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;
|
|
@ -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
|
||||
);
|
|
@ -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"
|
||||
);
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Reference in a new issue