refactor: rework frontend to use sveltekit and skeleton (#8)
ci/woodpecker/push/build Pipeline failed Details

Co-authored-by: Emerald <emerald@emeraldgreen.dev>
Reviewed-on: #8
Co-authored-by: Emerald <emerald_actual@proton.me>
Co-committed-by: Emerald <emerald_actual@proton.me>
This commit is contained in:
Emerald 2023-10-26 20:35:20 -04:00 committed by emerald
parent e662a0ff98
commit 8f34d02913
83 changed files with 5994 additions and 1267 deletions

13
.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

30
.eslintrc.cjs Normal file
View File

@ -0,0 +1,30 @@
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

17
.gitignore vendored
View File

@ -1,3 +1,14 @@
node_modules/
dist/
bundle/
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
target
# Local Netlify folder
.netlify

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

13
.prettierignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

9
.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

117
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,117 @@
{
"prettier.documentSelectors": [
"**/*.svelte"
],
"tailwindCSS.classAttributes": [
"class",
"accent",
"active",
"aspectRatio",
"background",
"badge",
"bgBackdrop",
"bgDark",
"bgDrawer",
"bgLight",
"blur",
"border",
"button",
"buttonAction",
"buttonBack",
"buttonClasses",
"buttonComplete",
"buttonDismiss",
"buttonNeutral",
"buttonNext",
"buttonPositive",
"buttonTextCancel",
"buttonTextConfirm",
"buttonTextFirst",
"buttonTextLast",
"buttonTextNext",
"buttonTextPrevious",
"buttonTextSubmit",
"caretClosed",
"caretOpen",
"chips",
"color",
"controlSeparator",
"controlVariant",
"cursor",
"display",
"element",
"fill",
"fillDark",
"fillLight",
"flex",
"gap",
"gridColumns",
"height",
"hover",
"inactive",
"indent",
"justify",
"meter",
"padding",
"position",
"regionAnchor",
"regionBackdrop",
"regionBody",
"regionCaption",
"regionCaret",
"regionCell",
"regionChildren",
"regionChipList",
"regionChipWrapper",
"regionCone",
"regionContent",
"regionControl",
"regionDefault",
"regionDrawer",
"regionFoot",
"regionFootCell",
"regionFooter",
"regionHead",
"regionHeadCell",
"regionHeader",
"regionIcon",
"regionInput",
"regionInterface",
"regionInterfaceText",
"regionLabel",
"regionLead",
"regionLegend",
"regionList",
"regionListItem",
"regionNavigation",
"regionPage",
"regionPanel",
"regionRowHeadline",
"regionRowMain",
"regionSummary",
"regionSymbol",
"regionTab",
"regionTrail",
"ring",
"rounded",
"select",
"shadow",
"slotDefault",
"slotFooter",
"slotHeader",
"slotLead",
"slotMessage",
"slotMeta",
"slotPageContent",
"slotPageFooter",
"slotPageHeader",
"slotSidebarLeft",
"slotSidebarRight",
"slotTrail",
"spacing",
"text",
"track",
"width",
"zIndex"
]
}

View File

@ -1,10 +1,9 @@
when:
- event: [ tag, manual ]
- event: [tag, manual]
branch: main
- event: deployment
environment: production
steps:
build:
image: forge.greenboi.me/emerald/cathode-build:latest
@ -29,8 +28,7 @@ steps:
settings:
server: https://ci.greenboi.me
repositories:
- emerald/cathode_dot_tube
- emerald/cathode_dot_tube@main
deploy: produciton
token:
from_secret: woodpecker_token

View File

@ -1,40 +1,38 @@
# Cathode [![status-badge](https://ci.greenboi.me/api/badges/emerald/cathode/status.svg)](https://ci.greenboi.me/emerald/cathode)
a small app for PNG tubing. Think Veadotube-mini but completely FOSS.
# create-svelte
Built with Tauri and Svelte.
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Installation
## Creating a project
### Packages
There are a few prebuilt packages available [here](https://gitea.greenboi.me/emerald/-/packages/generic/cathode-tube/) for the latest stable release. They are built on Bullseye Debian, so should be compatible with most up to date systems. Debian derived distros will want the `.deb` file, and Fedora users will want the `.rpm` file. Download the correct file and install it with your package manager.
If you're seeing this, you've probably already done this step. Congrats!
Alternatively, download the AppImage, which should work on any glibc linux distro at the cost of being a considerable larger file. Once you download the `.AppImage` file, give it execution permissions (eg: `chmod +x cathode-tube.AppImage`) and run it like a command or script.
```bash
# create a new project in the current directory
npm create svelte@latest
### Building from source
#### Prerequisites
In order to build from source you will need a few things to get started
- Nodejs/npm (I recommend using [nvm](https://github.com/nvm-sh/nvm) for this)
- [Rust](https://rustup.rs/)
- Tauri's [development dependencies](https://tauri.app/v1/guides/getting-started/prerequisites#installing)
#### Building
Once all of these are installed, clone the repo and run
```
npm install
```
This will install everything needed to build the frontend, as well as the tauri cli. Building the project itself is then as simple as
```
npm run tauri build
```
This will build the frontend and backend, and bundle the `.deb` and `.AppImage` packages, found in `src-tauri/target/release/bundle`.
The binary itself (at `src-tauri/target/release/cathode-tube`) is all that is needed to run the program, so if neither bundle works for you simply copy the executable to somewhere in your path, or run
```
cargo install --path src-tauri
# create a new project in my-app
npm create svelte@latest my-app
```
#### Just
If you have the [just](https://github.com/casey/just) command runner installed, as well as the other prerequisites, then you can run
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
just install
## Building
To create a production version of your app:
```bash
npm run build
```
Which will build the project and install it to `/usr/bin`, along with the `.desktop` file and icons
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

View File

@ -3,19 +3,17 @@ set export
alias d := debug
dev:
-cargo tauri dev
-yarn tauri dev
debug:
RUST_LOG=debug cargo tauri dev
RUST_LOG=debug yarn tauri dev
log RUST_LOG:
cargo tauri dev
yarn tauri dev
build:
cargo tauri build
yarn tauri build
cd src-tauri && cargo generate-rpm
cp -r src-tauri/target/release/bundle bundle
cp -r src-tauri/target/generate-rpm bundle
install:
@cargo tauri build -b none
@ -40,15 +38,20 @@ uninstall:
@sudo rm -f /usr/share/icons/hicolor/256x256/apps/cathode-tube.png
@sudo update-desktop-database
release tag:
release tag:
#!/usr/bin/env bash
set -euxo pipefail
just update-version {{tag}}
git cliff -o CHANGELOG.md --tag {{tag}}
git commit -am "chore(release): prepare for {{tag}}" -S
git tag -s -a "{{tag}}" -m "$(git cliff -u --strip all --tag {{tag}})"
echo "Ready to push release (git push && git push --tag {{tag}})"
update-version tag:
#!/usr/bin/env bash
set -euxo pipefail
v="{{trim_start_match(tag, "v")}}"
sed -i 's/version = "*.*.*" # managed by release.sh/version = "'"$v"'" # managed by release.sh/g' -i src-tauri/Cargo.toml
sed -i 's/"version": "*.*.*"/"version": "'"$v"'"/g' package.json
sed -i 's/"version": "*.*.*"/"version": "'"$v"'"/g' src-tauri/tauri.conf.json
git cliff -o CHANGELOG.md --tag v$v
git commit -am "chore(release): prepare for ${v}" -S
git tag -s -a "v$v" -m "$(git cliff -u --strip all --tag v$v)"
echo "Ready to push release (git push && git push --tag v$v)"
echo "Updated versions to {{tag}}"

35
old/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "cathode-tube",
"private": true,
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"tauri": "tauri"
},
"dependencies": {
"@neodrag/svelte": "^1.2.3",
"@tauri-apps/api": "^1.5.0",
"animejs": "^3.2.1",
"image-js": "^0.35.4",
"omggif": "^1.0.10"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^1.0.1",
"@tauri-apps/cli": "^1.5.2",
"@tsconfig/svelte": "^3.0.0",
"@types/animejs": "^3.1.8",
"@types/node": "^18.7.10",
"@types/omggif": "^1.0.3",
"sass": "^1.54.8",
"svelte": "^3.49.0",
"svelte-check": "^2.8.0",
"svelte-preprocess": "^4.10.7",
"tslib": "^2.4.0",
"typescript": "^4.6.4",
"vite": "^3.0.2"
}
}

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 654 B

After

Width:  |  Height:  |  Size: 654 B

10
old/svelte.config.js Normal file
View File

@ -0,0 +1,10 @@
import sveltePreprocess from "svelte-preprocess";
export default {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: sveltePreprocess(),
css: css => {
css.write('public/bundle.css');
}
};

21
old/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"baseUrl": ".",
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

28
old/vite.config.ts Normal file
View File

@ -0,0 +1,28 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
// Vite optons tailored for Tauri developemnt and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors
clearScreen: false,
// tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
cors: true
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ["VITE_", "TAURI_"],
build: {
// Tauri supports es2021
target: ["es2021", "chrome100", "safari13"],
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
},
});

View File

@ -1,35 +1,62 @@
{
"name": "cathode-tube",
"name": "cathode",
"version": "0.0.1",
"private": true,
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"tauri": "tauri"
},
"dependencies": {
"@neodrag/svelte": "^1.2.3",
"@tauri-apps/api": "^1.5.0",
"animejs": "^3.2.1",
"image-js": "^0.35.4",
"omggif": "^1.0.10"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^1.0.1",
"@tauri-apps/cli": "^1.5.2",
"@tsconfig/svelte": "^3.0.0",
"@types/animejs": "^3.1.8",
"@types/node": "^18.7.10",
"@skeletonlabs/skeleton": "2.3.0",
"@skeletonlabs/tw-plugin": "0.2.2",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.20.4",
"@tailwindcss/forms": "0.5.6",
"@tailwindcss/typography": "0.5.10",
"@tauri-apps/cli": "^1.5.4",
"@types/animejs": "^3.1.9",
"@types/node": "20.8.6",
"@types/omggif": "^1.0.3",
"sass": "^1.54.8",
"svelte": "^3.49.0",
"svelte-check": "^2.8.0",
"svelte-preprocess": "^4.10.7",
"tslib": "^2.4.0",
"typescript": "^4.6.4",
"vite": "^3.0.2"
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vite-pwa/sveltekit": "^0.2.7",
"autoprefixer": "10.4.16",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"kolorist": "^1.8.0",
"postcss": "8.4.31",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"sass": "^1.69.4",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"tailwindcss": "3.3.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2",
"vite-plugin-pwa": "^0.16.5",
"vite-plugin-tailwind-purgecss": "^0.1.3",
"vite-plugin-top-level-await": "^1.3.1",
"vite-plugin-wasm": "^3.2.2",
"workbox-build": "^7.0.0",
"workbox-window": "^7.0.0"
},
"type": "module",
"dependencies": {
"@cathode/cathode-ray": "^0.1.0",
"@floating-ui/dom": "1.5.3",
"@neodrag/svelte": "^2.0.3",
"@tauri-apps/api": "^1.5.1",
"animejs": "^3.2.1",
"image-js": "^0.35.5",
"omggif": "^1.0.10"
}
}

6
postcss.config.cjs Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

136
src-tauri/Cargo.lock generated
View File

@ -190,7 +190,7 @@ version = "0.68.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "726e4313eb6ec35d2730258ad4e15b547ee75d6afaa1361a922e78e59b7d8078"
dependencies = [
"bitflags 2.4.0",
"bitflags 2.4.1",
"cexpr",
"clang-sys",
"lazy_static",
@ -218,9 +218,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.4.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07"
[[package]]
name = "block"
@ -348,7 +348,7 @@ dependencies = [
]
[[package]]
name = "cathode-tube"
name = "cathode"
version = "0.2.0"
dependencies = [
"anyhow",
@ -781,10 +781,11 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.3.8"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946"
checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3"
dependencies = [
"powerfmt",
"serde",
]
@ -1030,9 +1031,9 @@ dependencies = [
[[package]]
name = "flate2"
version = "1.0.27"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6c98ee8095e9d1dcbf2fcc6d95acccb90d1c81db1e44725c6a984b1dbdfb010"
checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e"
dependencies = [
"crc32fast",
"miniz_oxide",
@ -1594,16 +1595,16 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "iana-time-zone"
version = "0.1.57"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613"
checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows 0.48.0",
"windows-core",
]
[[package]]
@ -1994,9 +1995,9 @@ checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f"
[[package]]
name = "lock_api"
version = "0.4.10"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16"
checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
dependencies = [
"autocfg",
"scopeguard",
@ -2407,9 +2408,9 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "os_str_bytes"
version = "6.5.1"
version = "6.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d5d9eb14b174ee9aa2ef96dc2b94637a2d4b6e7cb873c7e171f0c20c6cf3eac"
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
[[package]]
name = "overload"
@ -2454,13 +2455,13 @@ dependencies = [
[[package]]
name = "parking_lot_core"
version = "0.9.8"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447"
checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.3.5",
"redox_syscall 0.4.1",
"smallvec",
"windows-targets 0.48.5",
]
@ -2676,6 +2677,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
@ -2932,6 +2939,15 @@ dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "redox_users"
version = "0.4.3"
@ -2945,14 +2961,14 @@ dependencies = [
[[package]]
name = "regex"
version = "1.10.0"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d119d7c7ca818f8a53c300863d4f87566aac09943aef5b355bb83969dae75d87"
checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.4.1",
"regex-syntax 0.8.0",
"regex-automata 0.4.3",
"regex-syntax 0.8.2",
]
[[package]]
@ -2966,13 +2982,13 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.1"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465c6fc0621e4abc4187a2bda0937bfd4f722c2730b29562e19689ea796c9a4b"
checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax 0.8.0",
"regex-syntax 0.8.2",
]
[[package]]
@ -2983,9 +2999,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "regex-syntax"
version = "0.8.0"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3cbb081b9784b07cceb8824c8583f86db4814d172ab043f3c23f7dc600bf83d"
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "rfd"
@ -3043,11 +3059,11 @@ dependencies = [
[[package]]
name = "rustix"
version = "0.38.18"
version = "0.38.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a74ee2d7c2581cd139b42447d7d9389b889bdaad3a73f1ebb16f2a3237bb19c"
checksum = "745ecfa778e66b2b63c88a61cb36e0eea109e803b0b86bf9879fbc77c70e86ed"
dependencies = [
"bitflags 2.4.0",
"bitflags 2.4.1",
"errno",
"libc",
"linux-raw-sys",
@ -3139,18 +3155,18 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
[[package]]
name = "serde"
version = "1.0.188"
version = "1.0.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e"
checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.188"
version = "1.0.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2"
checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5"
dependencies = [
"proc-macro2 1.0.69",
"quote 1.0.33",
@ -3190,9 +3206,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.3.0"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ca3b16a3d82c4088f343b7480a93550b3eabe1a358569c2dfe38bbcead07237"
checksum = "64cd236ccc1b7a29e7e2739f27c0b2dd199804abc4290e32f59f3b68d6405c23"
dependencies = [
"base64 0.21.4",
"chrono",
@ -3207,9 +3223,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.3.0"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e6be15c453eb305019bfa438b1593c731f36a289a7853f7707ee29e870b3b3c"
checksum = "93634eb5f75a2323b16de4748022ac4297f9e76b6dced2be287a099f41b5e788"
dependencies = [
"darling",
"proc-macro2 1.0.69",
@ -3622,9 +3638,9 @@ checksum = "9d0e916b1148c8e263850e1ebcbd046f333e0683c724876bb0da63ea4373dc8a"
[[package]]
name = "tauri"
version = "1.5.1"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0238c5063bf9613054149a1b6bce4935922e532b7d8211f36989a490a79806be"
checksum = "9bfe673cf125ef364d6f56b15e8ce7537d9ca7e4dae1cf6fbbdeed2e024db3d9"
dependencies = [
"anyhow",
"clap",
@ -3894,12 +3910,13 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.29"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "426f806f4089c493dcac0d24c29c01e2c38baf8e30f1b716ee37e83d200b18fe"
checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5"
dependencies = [
"deranged",
"itoa 1.0.9",
"powerfmt",
"serde",
"time-core",
"time-macros",
@ -4035,11 +4052,10 @@ dependencies = [
[[package]]
name = "tracing"
version = "0.1.37"
version = "0.1.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8"
checksum = "ee2ef2af84856a50c1d430afce2fdded0a4ec7eda868db86409b4543df0797f9"
dependencies = [
"cfg-if",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@ -4047,9 +4063,9 @@ dependencies = [
[[package]]
name = "tracing-attributes"
version = "0.1.26"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [
"proc-macro2 1.0.69",
"quote 1.0.33",
@ -4058,9 +4074,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.31"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a"
checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
dependencies = [
"once_cell",
"valuable",
@ -4172,9 +4188,9 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "uuid"
version = "1.4.1"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
checksum = "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc"
dependencies = [
"getrandom 0.2.10",
]
@ -4489,6 +4505,15 @@ dependencies = [
"windows-tokens",
]
[[package]]
name = "windows-core"
version = "0.51.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-implement"
version = "0.39.0"
@ -4705,9 +4730,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "winnow"
version = "0.5.16"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711d82167854aff2018dfd193aa0fef5370f456732f0d5a0c59b0f1b4b907"
checksum = "a3b801d0e0a6726477cc207f60162da452f3a95adb368399bef20a946e06f65c"
dependencies = [
"memchr",
]
@ -4837,12 +4862,11 @@ dependencies = [
[[package]]
name = "zstd-sys"
version = "2.0.8+zstd.1.5.5"
version = "2.0.9+zstd.1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c"
checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656"
dependencies = [
"cc",
"libc",
"pkg-config",
]

View File

@ -1,5 +1,5 @@
[package]
name = "cathode-tube"
name = "cathode"
version = "0.2.0" # managed by release.sh
description = "A small PNGTubing app"
authors = ["AnActualEmerald"]
@ -10,10 +10,12 @@ edition = "2021"
[package.metadata.generate-rpm]
assets = [
{source= "target/release/cathode", dest= "/usr/bin/cathode", mode= "755"},
{source="cathode-tube.desktop", dest="/usr/share/applications/cathode-tube.desktop", mode="0644"},
{source="cathode-tube.desktop", dest="/usr/share/applications/cathode.desktop", mode="0644"},
{source="application-cathode.xml", dest="/usr/share/mime/packages/application-cathode.xml", mode="0644"},
{source="icons/128x128.png", dest="/usr/share/icons/hicolor/128x128/apps/cathode-tube.png", mode="0644"},
{source="icons/128x128@2x.png", dest="/usr/share/icons/hicolor/256x256@2/apps/cathode-tube.png", mode="0644"},
{source="icons/128x128.png", dest="/usr/share/icons/hicolor/128x128/apps/cathode.png", mode="0644"},
{source="icons/128x128@2x.png", dest="/usr/share/icons/hicolor/256x256/apps/cathode.png", mode="0644"},
{source="icons/application-cathode-128.png", dest="/usr/share/icons/hicolor/128x128/mimetypes/application-cathode.png", mode="0644"},
{source="icons/application-cathode-256.png", dest="/usr/share/icons/hicolor/256x256/mimetypes/application-cathode.png", mode="0644"},
]
auto-req = "no"
@ -23,8 +25,6 @@ gtk3 = ">= 3"
webkit2gtk3 = ">= 2"
[workspace]
[profile.release]
strip = true
opt-level = "s"
@ -37,7 +37,7 @@ tauri-build = { version = "1", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1", features = [ "protocol-asset", "window-center", "cli", "dialog-all", "fs-create-dir", "fs-read-dir", "fs-read-file", "fs-write-file", "macos-private-api", "window-minimize", "window-set-max-size", "window-set-min-size", "window-unminimize"] }
tauri = { version = "1", features = ["cli", "dialog-all", "fs-create-dir", "fs-read-dir", "fs-read-file", "fs-write-file", "macos-private-api", "protocol-asset", "window-center", "window-minimize", "window-set-max-size", "window-set-min-size", "window-unminimize"] }
cpal = { version = "0.14.1", features = ["jack"] }
ray_format = {path = "../ray_format", version = "~0.1.0"}
anyhow = "1.0.66"

View File

@ -1,6 +1,6 @@
[Desktop Entry]
Type=Application
Icon=cathode-tube
Icon=cathode
Name=Cathode
Exec=cathode %U
Terminal=false

View File

@ -25,20 +25,32 @@ pub fn monitor(
.build_input_stream(
&config.config(),
move |data: &[f32], _: &InputCallbackInfo| {
if data
let Some(new_level) = data
.iter()
.any(|e| (e.abs() * *sens.lock().unwrap()) >= *threshold.lock().unwrap())
.map(|v| {
let val = v.abs();
if val < 0.0001 {
0.0
} else {
val * *sens.lock().expect("Unable to lock mic sens mutex")
}
})
.reduce(|prev, curr| prev + curr)
.map(|v| v / data.len() as f32)
else {
return;
};
if new_level
> *threshold
.lock()
.expect("Unable to lock mic threshold mutex")
{
window.emit("mouth-open", "").unwrap();
} else {
window.emit("mouth-close", "").unwrap();
}
*level.lock().unwrap() = data
.iter()
.map(|e| e.abs() * *sens.lock().unwrap())
.max_by(|a, b| a.total_cmp(&b))
.unwrap();
*level.lock().unwrap() = new_level;
},
move |err| {
println!("Audio error: {:?}", err);
@ -55,7 +67,6 @@ pub fn monitor(
}
fn initialize() -> Result<Device> {
debug!("Hey there");
let host = cpal::default_host();
host.default_input_device()
.ok_or_else(|| anyhow!("No default output device"))

View File

@ -5,7 +5,7 @@ use figment::{
providers::{Env, Format, Toml},
Figment,
};
use log::{debug, error, trace, warn};
use log::{debug, error, trace};
use serde::{Deserialize, Serialize};
use tauri::api::path::config_dir;

View File

@ -8,8 +8,7 @@ use std::path::Path;
use anyhow::Result;
use base64::prelude::*;
use image::codecs::gif::{GifDecoder, GifEncoder, Repeat};
use image::imageops::resize;
use image::{guess_format, AnimationDecoder, Frame, ImageDecoder, ImageFormat};
use image::{guess_format, AnimationDecoder, ImageFormat};
use log::{debug, error};
use ray_format::Ray;
use serde::{Deserialize, Serialize};

View File

@ -2,8 +2,8 @@
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devPath": "http://localhost:1420",
"distDir": "../dist"
"devPath": "http://localhost:5173",
"distDir": "../build"
},
"package": {
"productName": "cathode",

9
src/app.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Locals {}
// interface PageData {}
// interface Error {}
// interface Platform {}
}

21
src/app.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content="A small PNGTubing app" />
<title>Cathode</title>
<meta name="og:site_name" contnent="Cathode" />
<meta name="og:title" contnent="Cathode" />
<meta name="og:type" content="website" />
<meta name="og:description" content="A small PNGTubing app" />
<meta name="og:url" content="https://app.cathode.tube" />
<meta name="og:image" content="%sveltekit.assets%/icon.png" />
<meta name="og:image:width" content="512" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" data-theme="hamlindigo" class="overflow-hidden">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

12
src/app.postcss Normal file
View File

@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
.dark body {
@apply bg-transparent;
}
.meta-button {
@apply btn btn-md variant-soft-surface border-token border-surface-400-500-token;
}

View File

@ -1,170 +0,0 @@
<script lang="ts" context="module">
const hints = [
"Eyes open | Mouth closed",
"Eyes open | Mouth open",
"Eyes closed | Mouth closed",
"Eyes closed | Mouth open",
];
let loading = [false, false, false, false];
</script>
<script lang="ts">
import { frames } from "../store";
import { fs, invoke } from "@tauri-apps/api";
import { fade } from "svelte/transition";
import Context from "./context.svelte";
import LoadingSVG from "/src/loading.svg";
import Image from "image-js";
import { Gif } from "../gifler";
export let index: number;
let menuTimeout: NodeJS.Timeout | null = null;
let showMenu = false;
let src: string | null;
$: {
const frame = $frames[index];
if (!frame) src = null;
else {
if (frame?.kind === "still") src = frame.value.toDataURL();
else {
const b = new Blob([new Uint8Array(frame.data)]);
src = URL.createObjectURL(b);
}
}
}
const openImage = async () => {
loading[index] = true;
const path = (await invoke("open_image")) as {
kind: "png" | "gif";
data: string;
};
if (path) {
const data = await fs.readBinaryFile(path.data);
if (path.kind == "png")
$frames[index] = {
kind: "still",
value: await Image.load(data),
};
else {
$frames[index] = {
kind: "GIF",
value: new Gif(new Promise((res) => res(data))),
data: data,
};
}
} else {
console.error("Error loading image");
}
loading[index] = false;
};
const clearImage = () => {
$frames[index] = null;
};
</script>
<div class="box">
<button
class="preview"
on:click={(e) => {
if (e.shiftKey) {
clearImage();
} else {
openImage();
}
}}
on:mouseenter={() =>
(menuTimeout = setTimeout(() => (showMenu = true), 200))}
on:mouseleave={() => {
if (menuTimeout) {
clearTimeout(menuTimeout);
}
showMenu = false;
}}
>
{#if loading[index]}
<img class="loading" src={LoadingSVG} alt="Loading" />
{:else if src}
<img {src} alt="Frame {{ index }}" />
{/if}
</button>
{#if showMenu}
<div transition:fade={{ duration: 50 }} class="context">
<Context>
<p>{hints[index]}</p>
</Context>
</div>
{/if}
</div>
<style lang="scss">
@-webkit-keyframes rotating {
from {
-webkit-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loading {
-webkit-animation: rotating 2s linear infinite;
-moz-animation: rotating 2s linear infinite;
-ms-animation: rotating 2s linear infinite;
-o-animation: rotating 2s linear infinite;
animation: rotating 2s linear infinite;
}
$bg: rgba(150, 150, 150, 0.5);
.preview {
&:hover {
background-color: darken($color: $bg, $amount: 5%);
}
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
border: 2px solid black;
width: 100%;
height: 100%;
background-color: $bg;
user-select: none;
cursor: pointer;
}
.box {
width: 15vh;
height: 15vh;
user-select: none;
gap: 10px;
display: flex;
align-content: center;
justify-content: center;
.context {
z-index: 900;
position: absolute;
left: 105%;
width: max-content;
}
}
img {
width: 75%;
user-select: none;
}
</style>

View File

@ -1,126 +0,0 @@
<script lang="ts">
import { draggable } from "@neodrag/svelte";
import { onMount } from "svelte";
import { tweened } from "svelte/motion";
import { sineIn, sineOut } from "svelte/easing";
// import { invoke } from "@tauri-apps/api";
export let progress = 0;
export let withSetpoint = false;
export let setpoint = 0;
export let onSetpointChange = (_: number) => {};
let bar: HTMLDivElement;
let point: HTMLDivElement;
let pos = { x: 0, y: 0 };
const tweenedProgress = tweened(0, { duration: 100, easing: sineOut });
onMount(async () => {
let rect = bar.getBoundingClientRect();
let prect = point.getBoundingClientRect();
pos.y = -(setpoint / 100) * rect.height + prect.height;
});
$: if (bar) {
let pxProgress = (progress / 100) * bar.getBoundingClientRect().height;
if (pxProgress > $tweenedProgress) {
tweenedProgress
.set(pxProgress, {
duration: 30,
easing: sineOut,
})
.then();
} else {
$tweenedProgress = pxProgress;
}
}
</script>
<svelte:window
on:resize={async () => {
// await invoke("log", {
// msg: `Resized`,
// });
pos = { x: 0, y: 0 };
let rect = bar.getBoundingClientRect();
let prect = point.getBoundingClientRect();
pos.y = -(setpoint / 100) * rect.height + prect.height;
}}
/>
<div class="box">
<div bind:this={bar} class="bar-container">
{#if withSetpoint}
<div
bind:this={point}
class="setpoint"
use:draggable={{
onDragStart: () => (pos = undefined),
handle: ".handle",
position: pos,
axis: "y",
bounds: "parent",
onDragEnd: (e) => {
let rect = bar.getBoundingClientRect();
let y = (e.domRect.y - rect.y + e.domRect.height / 2) / rect.height;
onSetpointChange(1.0 - y);
},
}}
>
<div class="handle" />
</div>
{/if}
<div class="bar" style="height: {$tweenedProgress}px;" />
</div>
<slot class="icon" />
</div>
<style lang="scss">
.box {
display: flex;
flex-direction: column;
align-items: center;
gap: 1vh;
:global(img) {
width: var(--bar-width, 5vh);
}
}
.setpoint {
position: absolute;
background-color: black;
width: var(--bar-width, 5vh);
height: 0.5vh;
.handle {
position: absolute;
border-top: 2vh solid transparent;
border-bottom: 2vh solid transparent;
border-left: 2vh solid var(--handle-color, forestgreen);
left: -2.2vh;
top: 50%;
transform: translateY(-50%);
}
}
.bar-container {
border-radius: var(--bar-radius, 0px);
display: flex;
flex-direction: column;
justify-content: flex-end;
background-color: white;
border: 2px solid black;
height: 80vh;
width: var(--bar-width, 5vh);
}
.bar {
border-radius: inherit;
user-select: none;
width: 100%;
border: 0;
margin: 0;
background-color: var(--bar-color, blue);
transition: background-color 0.1s;
}
</style>

View File

@ -1,16 +0,0 @@
<script lang="ts">
</script>
<div class="context">
<slot />
</div>
<style>
.context {
color: white;
background-color: rgba(75, 75, 75, 10);
padding: 1vh;
border-radius: 10px;
align-self: center;
}
</style>

View File

@ -1 +0,0 @@

View File

@ -1,126 +0,0 @@
<script lang="ts">
import { config } from "../store";
import { createEventDispatcher } from "svelte";
import { invoke } from "@tauri-apps/api";
const dispatch = createEventDispatcher();
let org = $config;
$: {
invoke("set_mic_sens", { sens: $config.mic_sens }).catch(
async (e) => await invoke("log", { msg: JSON.stringify(e) })
);
}
const onClose = () => {
if (org != $config) {
invoke("set_blink_interval", { value: $config.blink_interval }).catch();
org = $config;
}
dispatch("close");
};
</script>
<div class="settings">
<button class="close" on:click={onClose}>X</button>
<div class="setting">
<label for="bg-color">Background color:</label>
<select bind:value={$config.background_color} id="bg-color">
<option value="transparent">Transparent</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
<option value="red">Red</option>
<option value="pink">Pink</option>
</select>
</div>
<div class="setting">
<label for="blink">Blink interval: </label>
<input
value={$config.blink_interval}
on:input={(e) => {
$config.blink_interval = e.currentTarget.valueAsNumber;
}}
id="blink"
type="number"
min="0"
/>
</div>
<div class="setting">
<label for="sens">Mic sensitivity: </label>
<input
type="range"
id="sens"
step="0.01"
min="0.01"
max="5.0"
bind:value={$config.mic_sens}
/>
<span>{Math.trunc($config.mic_sens * 100)}%</span>
</div>
<button
id="save"
on:click={async () => {
await invoke("save_current_config");
onClose();
}}>Save</button
>
</div>
<style lang="scss">
.settings {
position: absolute;
transform: translateX(-50%) translateY(-50%);
top: 50%;
left: 50%;
z-index: 1000;
width: 90%;
height: 90%;
display: flex;
flex-direction: column;
gap: 5vh;
background-color: rgba(63, 176, 252, 0.9);
border-radius: 15px;
border: solid black 2px;
padding: 20px;
label {
font-weight: bold;
}
.close {
&:focus {
border: solid white 2px;
}
position: absolute;
right: 15px;
top: 15px;
font-size: 20px;
cursor: pointer;
width: 2rem;
height: 2rem;
background-color: black;
color: white;
border: solid black 1px;
text-align: center;
vertical-align: center;
border-radius: 5px;
}
}
.setting {
display: flex;
align-content: center;
justify-content: space-between;
width: 50%;
}
#save {
position: absolute;
right: 0.25rem;
bottom: 0.25rem;
width: 25%;
height: 5rem;
}
</style>

View File

@ -1,179 +0,0 @@
<script lang="ts">
import { frames, type FrameData } from "../store";
import { onMount } from "svelte";
import { appWindow } from "@tauri-apps/api/window";
import anime from "animejs";
import type { Animator } from "../gifler";
export let buf = 0;
export let open = false;
export let threshold = 50;
let closed = false;
let blink = false;
$: bitMaps = [null, null, null, null];
$: currFrame = 0;
$: src = null;
$: {
if (src?.mode === "gif" && src.data.running) src.data.stop();
src = bitMaps[currFrame] as
| { mode: "png"; data: ImageBitmap }
| { mode: "gif"; data: Animator };
if (src?.mode === "gif") src.data.reset().start();
}
let pos = { x: 0, y: 0 };
$: {
if (buf < threshold) {
open = false;
closed = true;
}
}
$: {
if (closed) {
currFrame = 0;
} else if (open) {
currFrame = 1;
}
if (blink && closed) {
currFrame = $frames[2] ? 2 : 0;
} else if (blink && open) {
currFrame = $frames[3] ? 3 : 1;
}
}
$: {
if (open) {
anime({
targets: pos,
y: [
{ value: 50, duration: 200 },
{ value: 0, duration: 200 },
],
autoplay: true,
easing: "easeOutCubic",
});
}
}
// $: {
// if (closed)
// anime({
// targets: pos,
// y: [
// { value: 100, duration: 100 },
// { value: 0, duration: 100 },
// ],
// autoplay: true,
// easing: "easeInOutCubic",
// });
// }
const createBitmaps = async (f: Array<FrameData | null>) => {
return await Promise.all(
f.map(async (v) => {
if (!v) return null;
return v.kind === "still"
? {
mode: "png",
data: await createImageBitmap(await v.value.toBlob()),
}
: {
mode: "gif",
data: (
await v.value.frames(
"#png",
(
ctx: CanvasRenderingContext2D,
frame: {
buffer: CanvasImageSource;
width: number;
height: number;
}
) => {
ctx.drawImage(
frame.buffer,
canvas.width / 2 - pos.x - frame.width / 2,
canvas.height / 2 - pos.y - frame.height / 2
);
},
false
)
).stop(),
};
})
);
};
onMount(async () => {
bitMaps = await createBitmaps($frames);
console.log("bitMaps: ", bitMaps);
frames.subscribe(async (f) => {
bitMaps.forEach((v) => (v && v.mode === "gif" ? v.data.stop() : null));
bitMaps = await createBitmaps(f);
});
await appWindow.listen("mouth-open", () => {
buf = 100;
open = true;
closed = false;
});
await appWindow.listen("mouth-close", () => {
buf = buf - 5;
buf = buf < 0 ? 0 : buf;
});
await appWindow.listen("blink", () => {
blink = true;
setTimeout(() => (blink = false), 100);
});
await appWindow.onResized(updateCanvasSize);
updateCanvasSize();
const ctx = canvas.getContext("2d");
const update = async () => {
try {
if (src.mode === "png") {
const img = src.data;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(
img,
canvas.width / 2 - pos.x - img.width / 2,
canvas.height / 2 - pos.y - img.height / 2
);
} else if (src.mode === "gif" && !src.data.running()) {
src.data.start();
}
} catch {}
requestAnimationFrame(update);
};
requestAnimationFrame(update);
});
const updateCanvasSize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
let canvas: HTMLCanvasElement;
</script>
<canvas bind:this={canvas} id="png" />
<style lang="scss">
canvas {
position: absolute;
translate: -50% -50%;
top: 50%;
left: 50%;
}
</style>

81
src/lib/audio.ts Normal file
View File

@ -0,0 +1,81 @@
import { writable } from 'svelte/store';
import { mode } from './store';
export const micLevel = writable(0);
export const micThreshold = writable(0);
export const initAudio = async (): Promise<NodeJS.Timeout | undefined> => {
if (mode == 'tauri') {
const { invoke } = await import('@tauri-apps/api');
micThreshold.subscribe(async (threshold) => {
try {
await invoke('set_mic_threshold', { threshold });
} catch (e) {
await invoke('log', { msg: `Error setting mic threshold: ${e}` });
}
});
return setInterval(async () => {
const newLevel = (await invoke('get_audio_level')) as number;
micLevel.set(newLevel * 100);
}, 40);
} else {
await webAudio();
}
};
let audioCtx: AudioContext | undefined = undefined;
const webAudio = async () => {
if (audioCtx) audioCtx.close();
audioCtx = new AudioContext();
if (navigator.mediaDevices) {
try {
const device = await navigator.mediaDevices.getUserMedia({ audio: true });
const mic = audioCtx.createMediaStreamSource(device);
await audioCtx.audioWorklet.addModule("audio.worker.js")
const worklet = new AudioWorkletNode(audioCtx, "audio-trigger");
mic.connect(worklet);
micThreshold.subscribe((val) => {
worklet.port.postMessage(val);
})
worklet.port.onmessage = (e) => {
switch (typeof e.data) {
case "number":
micLevel.set(e.data);
break;
case "string":
window?.dispatchEvent(new Event(e.data));
break;
}
}
await audioCtx.resume();
// const buffer = new Uint8Array(analyzer.frequencyBinCount);
// const monitor = () => {
// analyzer.getByteFrequencyData(buffer);
// }
// return setInterval(monitor);
} catch (e) {
// console.error("Failed to get microphone: ", e);
throw e;
//TODO: toast here
}
} else {
console.error("This browser doesn't support audio access?");
//TODO: toast here
}
}

View File

@ -0,0 +1,33 @@
<script lang="ts">
import { version } from '$app/environment';
import { mode } from '$lib/store';
</script>
<section class="grid grid-rows-[auto_1fr_auto] gap-2 h-full">
<header>
<h1 class="h1"><strong>Cathode {mode === 'web' ? 'Web' : 'Desktop'}</strong></h1>
<p>Version v{version}</p>
</header>
<main>
<p>
Powered by <a href="https://kit.svelte.dev" class="anchor" target="_blank">SvelteKit</a>{mode === 'tauri'
? ', '
: ' and '}
<a href="https://skeleton.dev" class="anchor" target="_blank">Skeleton</a>
{#if mode === 'tauri'}
<span>, and <a href="https://tauri.app" class="anchor" target="_blank">Tauri</a></span>
{/if}
</p>
<p>
Source code is available <a href="https://codeberg.org/anactualemerald/cathode" class="anchor" target="_blank">here</a>
</p>
</main>
<footer>
<p><strong>Cathode</strong> ©2023 Emerald</p>
<p>This program comes with ABSOLUTELY NO WARRANTY.
This is free software, and you are welcome to redistribute it
under certain conditions; For details, see the <a href="https://codeberg.org/AnActualEmerald/cathode/src/branch/main/LICENSE" class="anchor">license</a>.</p>
</footer>
</section>

View File

@ -0,0 +1,112 @@
<script lang="ts">
import { draggable } from '@neodrag/svelte';
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { sineOut } from 'svelte/easing';
// import { invoke } from "@tauri-apps/api";
export let value = 0;
export let withSetpoint = false;
export let setpoint = 0;
export let onSetpointChange = (_: number) => {};
export let barColor: string;
const handleXPos = 0;
let bar: HTMLDivElement;
let point: HTMLDivElement;
let pos = { x: handleXPos, y: 0 };
const tweenedProgress = tweened(0, { duration: 100, easing: sineOut });
onMount(async () => {
let rect = bar.getBoundingClientRect();
let prect = point.getBoundingClientRect();
pos.y = -(setpoint / 100) * rect.height + prect.height;
});
$: if (bar) {
let pxProgress = (value / 100) * bar.getBoundingClientRect().height;
if (pxProgress > $tweenedProgress) {
tweenedProgress
.set(pxProgress, {
duration: 30,
easing: sineOut
})
.then();
} else {
$tweenedProgress = pxProgress;
}
}
</script>
<svelte:window
on:resize={async () => {
// await invoke("log", {
// msg: `Resized`,
// });
pos = { x: handleXPos, y: 0 };
let rect = bar.getBoundingClientRect();
let prect = point.getBoundingClientRect();
pos.y = -(setpoint / 100) * rect.height + prect.height;
}}
/>
<div class="h-full flex flex-col gap-4">
<div
id="bar-container"
bind:this={bar}
class="w-10 flex flex-col justify-end rounded-container-token bg-surface-500 border-token border-surface-500-400-token h-5/6"
>
{#if withSetpoint}
<div
bind:this={point}
class="setpoint bg-slate-900 w-9 rounded-token"
use:draggable={{
handle: '.handle',
position: pos,
axis: 'y',
bounds: 'parent'
}}
on:neodrag:end={(e) => {
const rect = bar.getBoundingClientRect();
const pointRect = e.detail.currentNode.getBoundingClientRect();
const y = (pointRect.y - rect.y + pointRect.height / 2) / rect.height;
onSetpointChange(1.0 - y);
}}
>
<div class="handle rounded" />
</div>
{/if}
<div
id="bar"
class="bar rounded-token {barColor ? barColor : 'bg-primary-500'}"
style="height: {$tweenedProgress}px;"
/>
</div>
<div class="aspect-square w-full">
<slot />
</div>
</div>
<style lang="scss">
.setpoint {
position: absolute;
height: 0.5vh;
.handle {
position: absolute;
border-top: 1rem solid transparent;
border-bottom: 1rem solid transparent;
border-left: 1.5rem solid var(--handle-color, forestgreen);
top: 50%;
transform: translateY(-50%) translateX(-1.7rem);
}
}
.bar {
user-select: none;
border: 0;
margin: 0;
transition: background-color 0.1s;
}
</style>

View File

View File

@ -0,0 +1,147 @@
<script lang="ts" context="module">
const hints = [
'Eyes open | Mouth closed',
'Eyes open | Mouth open',
'Eyes closed | Mouth closed',
'Eyes closed | Mouth open'
];
</script>
<script lang="ts">
import { frames, mode } from '../store';
import { ProgressRadial } from '@skeletonlabs/skeleton';
import { debug, info } from '$lib/logging';
import { loadGif, loadImage } from '$lib/io';
import { keepFocused } from '$lib/state';
export let index: number;
let src: string | null;
let files: FileList;
$: loading = false;
// prevent the component from being unmounted when the file picker opens
$: $keepFocused = loading;
$: {
const frame = $frames[index];
if (!frame) src = null;
else {
debug(frame);
if (frame?.kind === 'still') src = frame.value.toDataURL();
else {
const b = new Blob([new Uint8Array(frame.data)]);
src = URL.createObjectURL(b);
}
}
}
const openImage = async () => {
if (mode === 'web') {
fileInput?.click();
loading = true;
}
// loading[index] = true;
// const path = (await invoke('open_image')) as {
// kind: 'png' | 'gif';
// data: string;
// };
// if (path) {
// const data = await fs.readBinaryFile(path.data);
// if (path.kind == 'png')
// $frames[index] = {
// kind: 'still',
// value: await Image.load(data)
// };
// else {
// $frames[index] = {
// kind: 'GIF',
// value: new Gif(new Promise((res) => res(data))),
// data: data
// };
// }
// } else {
// console.error('Error loading image');
// }
// loading[index] = false;
};
const finishLoad = async () => {
info('Loading image in web mode');
loading = true;
const file = files.item(0);
info('Got file:', file);
if (file) {
switch (file.type) {
case 'image/gif':
const gif = await loadGif(file);
$frames[index] = gif
? {
kind: 'GIF',
value: gif,
data: new Uint8Array(await file.arrayBuffer())
}
: null;
break;
default:
const img = await loadImage(file);
$frames[index] = img
? {
kind: 'still',
value: img
}
: null;
}
}
fileInput.value = "";
loading = false;
};
const clearImage = () => {
$frames[index] = null;
loading = false;
};
const handleCancel = () => {
if(loading && fileInput?.value.length === 0) loading = false;
};
let fileInput: HTMLInputElement;
</script>
<svelte:window on:focus={handleCancel} />
{#if mode === 'web'}
<input
bind:this={fileInput}
type="file"
bind:files={files}
on:change={finishLoad}
on:input={() => debug("This is the input event")}
class="hidden"
accept="image/png, image/jpeg, image/tiff, image/gif"
/>
{/if}
<button
class="btn btn-xl p-4 w-24 md:w-32 variant-soft-primary border-token border-primary-400-500-token aspect-square cursor-pointer z-50"
aria-label="Preview of frame {index + 1}"
on:click={(e) => {
if (e.shiftKey) {
clearImage();
} else {
openImage();
}
}}
>
{#if loading}
<ProgressRadial />
{:else if src}
<img {src} alt="Frame {{ index }}" class="w-full" />
{/if}
</button>

View File

@ -0,0 +1,39 @@
<script lang="ts">
import { loadRay } from '$lib/io';
import { debug } from '$lib/logging';
import { keepFocused } from '$lib/state';
import { mode } from '$lib/store';
import { setLoaded } from '$lib/utils';
let fileLoader: HTMLInputElement;
let files: FileList;
$: loading = false;
$: $keepFocused = loading;
const handleLoad = () => {
if (mode === 'web') {
fileLoader.click();
loading = true;
}
};
const handleWebLoad = () => {
const file = files.item(0);
if (file) finishLoad(file);
fileLoader.value = '';
};
const finishLoad = async (data: File | Blob) => {
loadRay(data);
setLoaded(await data.arrayBuffer());
loading = false
};
</script>
{#if mode === 'web'}
<input type="file" class="hidden" bind:this={fileLoader} bind:files on:change={handleWebLoad} accept="application/ray,.ray" />
{/if}
<button class="meta-button" on:click={handleLoad}>Load</button>

View File

@ -0,0 +1,29 @@
<script lang="ts">
import { mode } from '$lib/store';
import { setLoaded } from '$lib/utils';
import { onMount } from 'svelte';
import { packRay } from '$lib/io';
let downloader: HTMLAnchorElement | undefined;
onMount(() => {
if (!downloader) downloader = document.createElement('a') as HTMLAnchorElement;
});
const saveCurrent = async () => {
const data = await packRay();
if(mode === "web"){
const url = URL.createObjectURL(new Blob([data]));
if(!downloader) return;
downloader.href = url;
downloader.download = 'cathode.ray';
downloader.click();
}
setLoaded(data);
};
</script>
<button class="meta-button" on:click={saveCurrent}> Save </button>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { getModalStore, type ModalSettings } from "@skeletonlabs/skeleton";
import SettingsModal from "./SettingsModal.svelte";
const modal = getModalStore();
const onClick = () => {
const settings: ModalSettings = {
type: "component",
component: {
ref: SettingsModal,
}
};
modal.trigger(settings);
}
</script>
<button class="meta-button" on:click={onClick}>Settings</button>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { Tab, TabGroup } from '@skeletonlabs/skeleton';
import SettingsPane from './SettingsPane.svelte';
import AboutPane from './AboutPane.svelte';
let currTab = 0;
</script>
<TabGroup class="h-[50vh] w-modal card p-4 flex flex-col" regionPanel="flex-1 flex-col">
<Tab bind:group={currTab} value={0} name="settings">
<strong>Settings</strong>
</Tab>
<Tab bind:group={currTab} value={1} name="about">
<strong>About</strong>
</Tab>
<svelte:fragment slot="panel">
{#if currTab === 0}
<SettingsPane />
{:else if currTab === 1}
<AboutPane />
{/if}
</svelte:fragment>
</TabGroup>

View File

@ -0,0 +1,64 @@
<script lang="ts">
import { config, mode, type BGColor } from '$lib/store';
import { slide } from 'svelte/transition';
</script>
<section id="settings" class="w-1/2 flex flex-col gap-4">
<label>
Mic Sens
<div class="grid grid-cols-[0.75fr_0.25fr] gap-2">
<input
type="range"
name="MicSens"
id="mic-sens"
bind:value={$config.mic_sens}
class="range"
max={5.0}
step={0.1}
/>
<input
type="number"
name="MicSens"
id="mic-sens"
bind:value={$config.mic_sens}
class="input"
max={5.0}
step={0.1}
/>
</div>
</label>
<label>
Minimum Blink Interval (milliseconds)
<input
type="number"
name="MicSens"
id="mic-sens"
bind:value={$config.blink_interval}
class="input"
/>
</label>
<label>
Background Color
<select class="select" bind:value={$config.background_color.color}>
<option value="bg-transparent" disabled={mode === 'web'}>Transparent</option>
<option value="bg-green-500">Green</option>
<option value="bg-blue-500">Blue</option>
<option value="bg-pink-500">Pink</option>
<option value="custom">Custom</option>
</select>
{#if $config.background_color.color === 'custom'}
<div class="grid grid-cols-[auto_1fr] gap-2 my-4" transition:slide>
<input class="input" type="color" bind:value={$config.background_color.custom} />
<input
class="input"
type="text"
bind:value={$config.background_color.custom}
readonly
tabindex="-1"
/>
</div>
{/if}
</label>
</section>

View File

@ -0,0 +1,165 @@
<script lang="ts">
import { frames, type FrameData } from '../store';
import { onMount } from 'svelte';
import anime from 'animejs';
import type { Animator } from '$lib/gifler';
import { active, blink } from '$lib/state';
import { debug, info } from '$lib/logging';
type MaybeSrc = { mode: 'png'; data: ImageBitmap } | { mode: 'gif'; data: Animator } | null;
$: bitMaps = [null, null, null, null] as MaybeSrc[];
$: currFrame = 0;
$: src = null as MaybeSrc;
$: {
if (src?.mode === 'gif' && src.data.running()) src.data.stop();
src = bitMaps[currFrame] as MaybeSrc;
debug('canvas source', src);
if (src?.mode === 'gif') src.data.reset().start();
}
let pos = { x: 0, y: 0 };
$: {
if (!$active) {
currFrame = 0;
} else {
currFrame = 1;
}
if ($blink && !$active) {
currFrame = $frames[2] ? 2 : 0;
} else if ($blink && $active) {
currFrame = $frames[3] ? 3 : 1;
}
}
$: {
if ($active) {
anime({
targets: pos,
y: [
{ value: 50, duration: 200 },
{ value: 0, duration: 200 }
],
autoplay: true,
easing: 'easeOutCubic'
});
}
}
// $: {
// if (closed)
// anime({
// targets: pos,
// y: [
// { value: 100, duration: 100 },
// { value: 0, duration: 100 },
// ],
// autoplay: true,
// easing: "easeInOutCubic",
// });
// }
const createBitmaps = async (f: Array<FrameData | null>): Promise<MaybeSrc[]> => {
return await Promise.all(
f.map(async (v) => {
debug('creating bitmap for', v);
if (!v) return null;
if (v?.kind === 'still') {
info('Creating bitmap for still image');
return {
mode: 'png',
data: await createImageBitmap(await v?.value.toBlob())
};
} else {
return {
mode: 'gif',
data: (
await v?.value.frames(
'#png',
(
ctx: CanvasRenderingContext2D,
frame: {
buffer: CanvasImageSource;
width: number;
height: number;
}
) => {
ctx.drawImage(
frame.buffer,
canvas.width / 2 - pos.x - frame.width / 2,
canvas.height / 2 - pos.y - frame.height / 2
);
},
false
)
).stop()
};
}
})
);
};
onMount(async () => {
bitMaps = await createBitmaps($frames);
console.log('bitMaps: ', bitMaps);
frames.subscribe(async (f) => {
debug('Updating image bitmaps');
// make sure any loaded gifs are stopped or they'll keep drawing themselves
bitMaps.forEach((v) => (v && v.mode === 'gif' ? v.data.stop() : null));
bitMaps = await createBitmaps(f);
});
updateCanvasSize();
window?.addEventListener('resize', updateCanvasSize);
const ctx = canvas.getContext('2d');
const update = async () => {
try {
if (src?.mode === 'png') {
const img = src?.data;
ctx?.clearRect(0, 0, canvas.width, canvas.height);
ctx?.drawImage(
img,
canvas.width / 2 - pos.x - img.width / 2,
canvas.height / 2 - pos.y - img.height / 2
);
} else if (src?.mode === 'gif' && !src.data.running()) {
src.data.start();
} else {
ctx?.clearRect(0, 0, canvas.width, canvas.height);
}
} catch (e) {
debug('Rendering error', e);
}
requestAnimationFrame(update);
};
requestAnimationFrame(update);
});
const updateCanvasSize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
info(`Set canvas size to ${canvas.width}, ${canvas.height}`);
};
let canvas: HTMLCanvasElement;
</script>
<canvas bind:this={canvas} id="png" />
<style lang="scss">
canvas {
position: absolute;
translate: -50% -50%;
top: 50%;
left: 50%;
}
</style>

145
src/lib/io.ts Normal file
View File

@ -0,0 +1,145 @@
import { Image } from 'image-js';
import { loadedRay, mode, frames } from '$lib/store';
import gifler, { Gif } from './gifler';
import { Expression, load_ray, save_ray, UnpackedRay } from '@cathode/cathode-ray';
import { base64ToArrayBuffer, getBase64 } from '$lib/utils';
import { info, debug } from './logging';
import { get } from 'svelte/store';
export const loadLast = async () => {
if (mode === 'web' && loadedRay) {
const data = get(loadedRay);
if (data) await loadRay(new Blob([base64ToArrayBuffer(data)]));
} else if (mode === 'tauri') {
//TODO
}
return null;
};
export const loadFrame = async (raw: string): Promise<Gif | Image | null> => {
const blob = new Blob([base64ToArrayBuffer(raw)]);
try {
return await loadImage(blob);
} catch {
info('Error loading still image, attempting gif');
try {
return await loadGif(blob);
} catch {}
}
return null;
};
export const loadImage = async (data: File | Blob): Promise<Image | null> => {
if (mode === 'web') {
return new Promise(async (resolve) => {
const raw = await data.arrayBuffer();
if (!raw) return null;
let img = await Image.load(raw);
if (img.width > 600 || img.height > 400) {
img = img.resize({
width: 600
});
}
resolve(img);
});
}
return null;
};
export const loadGif = async (data: File | Blob): Promise<Gif | null> => {
if (mode === 'web') {
return new Promise(async (resolve) => {
const reader = new FileReader();
reader.onload = async () => {
const raw = reader.result as string | null;
if (!raw) return resolve(null);
let img = gifler(raw);
resolve(img);
};
reader.readAsDataURL(data);
});
}
return null;
};
export const packRay = async () => {
// this feels inefficient but we're basically just mapping the current expression to
// wasm memory
const ray = new UnpackedRay();
const expr = new Expression();
for (const [i, frame] of get(frames).entries()) {
if (!frame) {
continue;
}
const base64 = await getBase64(frame);
expr.set_frame(i, base64);
}
ray.add_expr(expr);
const data = save_ray(ray);
ray.free();
return data;
}
export const getRay = async (data: File | Blob): Promise<UnpackedRay | null> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async () => {
const raw = reader.result as ArrayBuffer | null;
if (!raw) return resolve(null);
const ray = load_ray(new Uint8Array(raw));
resolve(ray ?? null);
};
reader.readAsArrayBuffer(data);
});
};
export const loadRay = async (data: File | Blob) => {
const ray = await getRay(data);
const expr = ray?.get_expr(0);
for (let i = 0; i < 4; i++) {
const raw = expr?.get_frame(i);
if (!raw) {
frames.update((v) => {
v[i] = null;
return v;
});
continue;
}
const image = await loadFrame(raw);
if (image instanceof Image) {
frames.update((v) => {
v[i] = {
kind: 'still',
value: image
};
return v;
});
} else if (image instanceof Gif) {
frames.update((v) => {
v[i] = {
kind: 'GIF',
value: image,
data: new Uint8Array(base64ToArrayBuffer(raw))
};
return v;
});
} else {
frames.update((v) => {
v[i] = null;
return v;
});
}
}
ray?.free();
};

11
src/lib/logging.ts Normal file
View File

@ -0,0 +1,11 @@
import { mode } from "$lib/store"
export const info = (...args: any[]) => {
if(mode === "web")
console.log("%c[INFO]", "color: lightgreen", ":", ...args);
}
export const debug = (...args: any[]) => {
if(mode === "web")
console.log("%c[DEBUG]", "color: yellow", ":", ...args);
}

66
src/lib/state.ts Normal file
View File

@ -0,0 +1,66 @@
import { derived, writable } from 'svelte/store';
import { mode } from '$lib/store';
export const activationLevel = writable(0);
export const closeThreshold = writable(0);
export const active = derived(
[activationLevel, closeThreshold],
([$level, $threshold]) => $level > $threshold
);
export const blink = writable(false);
export const keepFocused = writable(false);
export const wantsTransparent = writable(false);
export const transparent = derived(
[keepFocused, wantsTransparent],
([$keepFocused, $wantsTransparent]) => !$keepFocused && $wantsTransparent
);
export const initState = async () => {
if (mode == 'tauri') {
const { appWindow, PhysicalSize } = await import('@tauri-apps/api/window');
await appWindow.center();
await appWindow.setMinSize(new PhysicalSize(720, 600));
await appWindow.onFocusChanged(({ payload: focused }) => {
wantsTransparent.set(!focused);
});
await appWindow.listen('mouth-open', () => {
activationLevel.set(100);
});
await appWindow.listen('mouth-close', () => {
activationLevel.update((buf) => {
buf = buf - 5;
return buf < 0 ? 0 : buf;
});
});
await appWindow.listen('blink', () => {
blink.set(true);
setTimeout(() => blink.set(false), 100);
});
} else {
window?.addEventListener('mouth-open', () => {
activationLevel.set(100);
});
window?.addEventListener('mouth-close', () => {
activationLevel.update((buf) => {
buf = buf - 5;
return buf < 0 ? 0 : buf;
});
});
window?.addEventListener('blink', () => {
blink.set(true);
setTimeout(() => blink.set(false), 100);
});
window?.addEventListener('blur', () => wantsTransparent.set(true));
window?.addEventListener('focus', () => wantsTransparent.set(false));
}
};

65
src/lib/store.ts Normal file
View File

@ -0,0 +1,65 @@
import { writable, type Writable } from 'svelte/store';
import type { Image } from 'image-js';
import type { Gif } from '$lib/gifler';
import { info } from '$lib/logging';
import { TAURI_MODE } from '$env/static/public';
import { localStorageStore } from '@skeletonlabs/skeleton';
import { loadLast } from './io';
export const mode = TAURI_MODE === 'tauri' ? 'tauri' : 'web';
export type BGColor = {
color: 'bg-transparent' | 'bg-blue-500' | 'bg-green-500' | 'bg-pink-500' | 'custom';
custom?: string;
};
export type Meta = {
threshold: string | null;
closeThreshold: string | null;
};
export class WebRay {
meta: Meta;
frames: Array<Frame | null>;
public constructor(
frames: Array<Frame | null> = [null, null, null, null],
meta: Meta = { threshold: null, closeThreshold: null }
) {
this.frames = frames;
this.meta = meta;
}
}
export type Frame = {
kind: 'png' | 'gif';
data: string;
};
export type FrameData =
| {
kind: 'still';
value: Image;
}
| {
kind: 'GIF';
value: Gif;
data: ArrayLike<number>;
};
export class Config {
background_color: BGColor;
blink_interval: number;
mic_sens: number;
constructor() {
this.background_color = { color: mode === 'tauri' ? 'bg-transparent' : 'bg-green-500' };
this.blink_interval = 1500;
this.mic_sens = 1.0;
}
}
export let frames: Writable<Array<FrameData | null>> = writable([null, null, null, null]);
export let config: Writable<Config> =
mode === 'web' ? localStorageStore('cathode-config', new Config()) : writable(new Config());
export let loadedRay: Writable<string | null> | null = mode === 'web' ? localStorageStore('cathode-loaded-ray', null) : null;
info(`Running app in ${mode} mode`);

27
src/lib/utils.ts Normal file
View File

@ -0,0 +1,27 @@
import { loadedRay, type FrameData } from './store';
export function bytesToBase64(bytes: Uint8Array): string {
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}
export function base64ToArrayBuffer(base64: string) {
var binaryString = atob(base64);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
export const getBase64 = async (obj: FrameData) => {
if (obj.kind === 'still') {
return await obj.value.toBase64();
} else {
return bytesToBase64(new Uint8Array(obj.data));
}
};
export const setLoaded = (data: ArrayBufferLike) => {
loadedRay?.set(bytesToBase64(new Uint8Array(data)));
};

View File

@ -1,56 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 204.481 204.481" style="enable-background:new 0 0 204.481 204.481;" xml:space="preserve">
<g>
<path d="M162.116,38.31c0.163-0.215,0.315-0.438,0.454-0.67c0.033-0.055,0.068-0.109,0.1-0.164
c0.156-0.276,0.297-0.561,0.419-0.857c0.014-0.034,0.024-0.069,0.038-0.104c0.102-0.26,0.188-0.528,0.261-0.801
c0.019-0.069,0.037-0.137,0.053-0.207c0.068-0.288,0.124-0.581,0.157-0.881c0.002-0.017,0.006-0.034,0.008-0.052
c0.028-0.262,0.043-0.527,0.043-0.796V7.5c0-4.142-3.358-7.5-7.5-7.5H48.332c-4.142,0-7.5,3.358-7.5,7.5v26.279
c0,0.269,0.016,0.534,0.043,0.796c0.002,0.017,0.006,0.034,0.008,0.052c0.034,0.3,0.089,0.593,0.157,0.881
c0.016,0.069,0.035,0.138,0.053,0.207c0.073,0.273,0.159,0.541,0.261,0.801c0.013,0.034,0.024,0.069,0.038,0.104
c0.121,0.296,0.262,0.581,0.419,0.857c0.032,0.056,0.067,0.109,0.1,0.164c0.14,0.232,0.291,0.455,0.454,0.67
c0.027,0.035,0.047,0.074,0.074,0.109l50.255,63.821l-50.255,63.821c-0.028,0.035-0.047,0.074-0.074,0.109
c-0.163,0.215-0.315,0.438-0.454,0.67c-0.033,0.055-0.068,0.109-0.1,0.164c-0.156,0.276-0.297,0.561-0.419,0.857
c-0.014,0.034-0.024,0.069-0.038,0.104c-0.102,0.26-0.188,0.528-0.261,0.801c-0.019,0.069-0.037,0.137-0.053,0.207
c-0.068,0.288-0.124,0.581-0.157,0.881c-0.002,0.017-0.006,0.034-0.008,0.052c-0.028,0.262-0.043,0.527-0.043,0.796v26.279
c0,4.142,3.358,7.5,7.5,7.5h107.817c4.142,0,7.5-3.358,7.5-7.5v-26.279c0-0.269-0.016-0.534-0.043-0.796
c-0.002-0.017-0.006-0.034-0.008-0.052c-0.034-0.3-0.089-0.593-0.157-0.881c-0.016-0.069-0.035-0.138-0.053-0.207
c-0.073-0.273-0.159-0.541-0.261-0.801c-0.013-0.034-0.024-0.069-0.038-0.104c-0.121-0.296-0.262-0.581-0.419-0.857
c-0.032-0.056-0.067-0.109-0.1-0.164c-0.14-0.232-0.291-0.455-0.454-0.67c-0.027-0.035-0.047-0.074-0.074-0.109l-50.255-63.821
l50.255-63.821C162.07,38.385,162.089,38.346,162.116,38.31z M148.649,15v11.279H55.832V15H148.649z M55.832,189.481v-11.279
h92.817v11.279H55.832z M140.698,163.202H63.784l38.457-48.838L140.698,163.202z M102.241,90.118L63.784,41.279h76.914
L102.241,90.118z"/>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

222
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,222 @@
<script lang="ts">
import FramePreview from '$lib/components/FramePreview.svelte';
import { quintInOut } from 'svelte/easing';
import { initAudio, micLevel, micThreshold } from '$lib/audio';
import '../app.postcss';
// Floating UI for Popups
import { computePosition, autoUpdate, flip, shift, offset, arrow } from '@floating-ui/dom';
import {
Modal,
Toast,
initializeStores,
storePopup,
type ModalSettings,
getToastStore,
getModalStore
} from '@skeletonlabs/skeleton';
import { fly } from 'svelte/transition';
import Bar from '$lib/components/Bar.svelte';
import { onDestroy, onMount } from 'svelte';
import { active, initState, closeThreshold, activationLevel, transparent } from '$lib/state';
import SaveButton from '$lib/components/SaveButton.svelte';
import LoadButton from '$lib/components/LoadButton.svelte';
import { config, frames } from '$lib/store';
import SettingsButton from '$lib/components/SettingsButton.svelte';
import { debug } from '$lib/logging';
import { loadLast } from '$lib/io';
storePopup.set({ computePosition, autoUpdate, flip, shift, offset, arrow });
initializeStores();
const modal = getModalStore();
const toast = getToastStore();
// import { pwaInfo } from 'virtual:pwa-info';
// $: webManifestLink = pwaInfo ? pwaInfo.webManifest.linkTag : '' ;
let deinitAudio: NodeJS.Timeout | undefined = undefined;
onMount(async () => {
try {
deinitAudio = await initAudio();
} catch (e) {
toast.trigger({ message: 'Error starting audio worker', background: 'variant-ghost-error' });
}
await initState();
await loadLast();
});
onDestroy(() => {
if (deinitAudio) clearInterval(deinitAudio);
});
const clearFrames = () => {
const settings: ModalSettings = {
type: 'confirm',
title: 'Create new Ray?',
body: 'This will clear the currently loaded Ray',
response(ok) {
if (ok) frames.update(() => [null, null, null, null]);
}
};
if (!$frames.every((v) => !v)) modal.trigger(settings);
};
$: solidColor = 'bg-surface-900';
// $config.background_color.color === 'bg-transparent'
// ? 'bg-surface-900'
// : $config.background_color.color === "custom" ? undefined : $config.background_color.color;
$: transparentColor =
$config.background_color.color === 'custom' ? undefined : $config.background_color.color;
$: bgColor = $transparent ? transparentColor : solidColor;
</script>
<!-- <svelte:head>
{@html webManifestLink}
</svelte:head> -->
<Toast />
<Modal />
<div
class="{bgColor} w-screen h-screen"
style:background-color={$config.background_color.color === 'custom' && $transparent
? $config.background_color.custom
: undefined}
id="container"
>
<slot />
{#if !$transparent}
<div
transition:fly={{
duration: 200,
y: -100,
opacity: 100,
easing: quintInOut
}}
class="flex w-full justify-center absolute top-4 gap-4"
>
<button class="meta-button" on:click={clearFrames}>New</button>
<SaveButton />
<LoadButton />
<SettingsButton />
</div>
<div
transition:fly={{
duration: 200,
x: -200,
opacity: 100,
easing: quintInOut
}}
class="flex flex-col justify-around h-full mx-10"
>
{#each [0, 1, 2, 3] as i}
<FramePreview index={i} />
{/each}
</div>
<div
transition:fly={{
duration: 200,
x: 200,
opacity: 100,
easing: quintInOut
}}
class="audio flex absolute right-6 gap-10 top-1/2 translate-y-[-50%] h-5/6"
>
{#key $micThreshold}
<Bar
value={$micLevel}
setpoint={$micThreshold * 100}
withSetpoint
onSetpointChange={async (e) => {
$micThreshold = e;
console.log(e);
}}
barColor={$active ? 'bg-success-500' : 'bg-blue-500'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class:stroke-success-500={$active}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
/>
</svg>
</Bar>
{/key}
{#key $closeThreshold}
<Bar
withSetpoint
value={$activationLevel}
setpoint={$closeThreshold}
onSetpointChange={(y) => ($closeThreshold = y * 100)}
barColor={'bg-blue-500'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
/>
</svg>
</Bar>
{/key}
</div>
{/if}
</div>
<!--
<style lang="scss">
// @keyframes fade-out {
// 0% {
// background-color: var(--active-color);
// }
// 100% {
// background-color: var(--inactive-color);
// }
// }
// @keyframes fade-in {
// 0% {
// background-color: var(--inactive-color);
// }
// 100% {
// background-color: var(--active-color);
// }
// }
// .container {
// animation-name: fade-in;
// animation-duration: 0.2s;
// }
// .container.transparent {
// animation-name: fade-out;
// animation-duration: 0.2s;
// }
.frames {
align-items: left;
position: absolute;
top: 5vh;
left: 20px;
bottom: 5vh;
display: flex;
justify-content: space-between;
flex-direction: column;
}
</style> -->

2
src/routes/+layout.ts Normal file
View File

@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;

6
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,6 @@
<script lang="ts">
import Tuber from "$lib/components/tube.svelte";
</script>
<Tuber />

64
src/service-worker.js Normal file
View File

@ -0,0 +1,64 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import {build, files, version} from "$service-worker";
const CACHE = `cache-${version}`;
const ASSETS = [
...build,
...files,
];
self.addEventListener('install', (event) => {
async function cacheFiles() {
const c = await caches.open(CACHE);
await c.addAll(ASSETS);
}
event.waitUntil(cacheFiles());
});
self.addEventListener('activate', (event) => {
// Remove previous cached data from disk
async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) await caches.delete(key);
}
}
event.waitUntil(deleteOldCaches());
});
self.addEventListener('fetch', (event) => {
// ignore POST requests etc
if (event.request.method !== 'GET') return;
async function respond() {
const url = new URL(event.request.url);
const cache = await caches.open(CACHE);
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
return cache.match(url.pathname);
}
// for everything else, try the network first, but
// fall back to the cache if we're offline
try {
const response = await fetch(event.request);
if (response.status === 200) {
cache.put(event.request, response.clone());
}
return response;
} catch {
return cache.match(event.request);
}
}
event.respondWith(respond());
});

View File

@ -1,54 +0,0 @@
import {writable} from "svelte/store";
import type { Image } from "image-js";
import type { Gif } from "./gifler";
export type BGColor ="transparent" | "blue" | "green" | "pink" | {custom: string} ;
export type Meta = {
threshold: string | null;
closeThreshold: string | null;
};
export class WebRay {
meta: Meta;
frames: Array<Frame | null>;
public constructor(
frames: Array<Frame | null> = [null, null, null, null],
meta: Meta = { threshold: null, closeThreshold: null }
) {
this.frames = frames;
this.meta = meta;
}
}
export type Frame = {
kind: "png" | "gif",
data: string
}
export type FrameData = {
kind: "still",
value: Image,
} | {
kind: "GIF",
value: Gif
data: ArrayLike<number>
}
export class Config {
background_color: BGColor;
blink_interval: number;
mic_sens: number;
constructor(){
this.background_color = "transparent";
this.blink_interval = 1500;
this.mic_sens = 1.0;
}
}
export let frames = writable<Array<FrameData | null>>([null, null, null, null]);
export let config = writable(new Config());

View File

@ -1,4 +0,0 @@
export function bytesToBase64(bytes: Uint8Array): string {
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

37
static/audio.worker.js Normal file
View File

@ -0,0 +1,37 @@
// @ts-nocheck
export class AudioTrigger extends AudioWorkletProcessor {
threshold
constructor() {
super();
this.port.onmessage = (e) => {
this.threshold = e.data;
}
}
process(inputs) {
const channels = inputs[0];
let count = 0;
let avg = 0;
for (const chan of channels) {
avg += (chan.map(v => Math.abs(v)).reduce((prev, cur) => prev + cur) / chan.length);
count += chan.length
}
avg /= count;
avg *= 100 * 100;
if (avg > (this.threshold * 100) - 1) {
this.port.postMessage('mouth-open')
} else {
this.port.postMessage('mouth-close');
}
this.port.postMessage(avg);
}
}
registerProcessor("audio-trigger", AudioTrigger);

BIN
static/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

BIN
static/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

3
static/mic.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

3
static/mouth.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
</svg>

After

Width:  |  Height:  |  Size: 462 B

2
static/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-Agent: *
Allow: /

View File

@ -1,10 +1,33 @@
import sveltePreprocess from "svelte-preprocess";
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/kit/vite';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
export default {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: sveltePreprocess(),
css: css => {
css.write('public/bundle.css');
}
const file = fileURLToPath(new URL('package.json', import.meta.url));
const json = readFileSync(file, 'utf8');
const pkg = JSON.parse(json);
/** @type {import('@sveltejs/kit').Config} */
const config = {
extensions: ['.svelte'],
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: [ vitePreprocess()],
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({
fallback: 'index.html'
}),
env: {
publicPrefix: 'TAURI_'
},
version: {
name: pkg.version
}
}
};
export default config;

27
tailwind.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { join } from 'path'
import type { Config } from 'tailwindcss'
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
import { skeleton } from '@skeletonlabs/tw-plugin'
export default {
darkMode: 'class',
content: ['./src/**/*.{html,js,svelte,ts}', join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')],
theme: {
extend: {},
},
plugins: [
forms,
typography,
skeleton({
themes: {
preset: [
{
name: 'hamlindigo',
enhancements: false,
},
],
},
}),
],
} satisfies Config;

View File

@ -1,21 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"baseUrl": ".",
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"types": [
"vite-plugin-pwa/client",
"vite-plugin-pwa/info"
]
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View File

@ -1,28 +1,12 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { purgeCss } from 'vite-plugin-tailwind-purgecss';
import { sveltekit } from '@sveltejs/kit/vite';
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
plugins: [wasm(), topLevelAwait(), sveltekit(), purgeCss()],
// Vite optons tailored for Tauri developemnt and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors
clearScreen: false,
// tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
cors: true
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ["VITE_", "TAURI_"],
build: {
// Tauri supports es2021
target: ["es2021", "chrome100", "safari13"],
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
},
});

4329
yarn.lock

File diff suppressed because it is too large Load Diff