project initiate.

This commit is contained in:
Vixalie 2025-02-26 05:39:36 +08:00
commit 4f5420b658
81 changed files with 4816 additions and 0 deletions

74
.editorconfig Normal file
View File

@ -0,0 +1,74 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
max_line_length = 100
[*.java]
indent_size = 4
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
[*.{yml, yaml}]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
[*.json]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
[*.sql]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
[*.go]
indent_style = space
indent_size = 4
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
[*.py]
indent_style = space
indent_size = 4
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
[*.sh]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
[*.rs]
indent_size = 4
indent_style = space
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8

1033
.gitignore vendored Normal file

File diff suppressed because it is too large Load Diff

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
**/*.md
.env
.env.*

12
.prettierrc Normal file
View File

@ -0,0 +1,12 @@
{
"bracketSpacing": true,
"bracketSameLine": true,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": false,
"trailingComma": "all",
"arrowParens": "always",
"printWidth": 100
}

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}

7
README.md Normal file
View File

@ -0,0 +1,7 @@
# Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

1416
deno.lock Normal file

File diff suppressed because it is too large Load Diff

25
eslint.config.js Normal file
View File

@ -0,0 +1,25 @@
import js from '@eslint/js';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': 'off',
},
},
);

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "estim",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev"
},
"dependencies": {
"@iconify/react": "^5.2.0",
"@tauri-apps/plugin-dialog": "~2.2.0",
"@tauri-apps/plugin-notification": "~2.2.1",
"@tauri-apps/plugin-os": "~2.2.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"jotai": "^2.12.1",
"lodash-es": "^4.17.21",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-router-dom": "^7.2.0",
"sanitize.css": "^13.0.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"uuid": "^11.0.5"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/lodash-es": "^4.17.12",
"@types/uuid": "^10.0.0",
"eslint": "^9.19.0",
"lightningcss": "^1.29.1",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.3",
"typescript-eslint": "^8.22.0",
"vite": "^6.0.3",
"@tauri-apps/cli": "^2"
}
}

6
public/tauri.svg Normal file
View File

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

40
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,40 @@
[package]
name = "estim"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "estim_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
btleplug = { version = "0.11.6", features = ["serde"] }
tokio = { version = "1.40.0", features = ["full"] }
sled = "0.34.7"
anyhow = "1.0.89"
thiserror = "2.0.11"
serde_repr = "0.1.19"
bincode = "1.3.3"
uuid = { version = "1.10.0", features = ["serde"] }
tauri-plugin-dialog = "2"
futures = "0.3.31"
tauri-plugin-devtools = "2.0.0"
tauri-plugin-os = "2"
tauri-plugin-notification = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default",
"core:window:allow-start-dragging",
"os:default",
"notification:default",
"dialog:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

134
src-tauri/src/bluetooth.rs Normal file
View File

@ -0,0 +1,134 @@
use std::sync::Arc;
use btleplug::{
api::{Central, CentralState, Manager as _, Peripheral as _, ScanFilter},
platform::{Adapter, Manager, Peripheral},
};
use futures::StreamExt;
use tauri::{
async_runtime::{self, JoinHandle, RwLock},
AppHandle, Emitter, State,
};
use crate::{errors, state::AppState};
pub async fn handle_bluetooth_events(
app_handle: Arc<AppHandle>,
app_state: Arc<RwLock<AppState>>,
) -> Result<JoinHandle<()>, errors::AppError> {
let state = app_state.read().await;
let adapter = state.central_adapter.read().await;
let app = Arc::clone(&app_handle);
if let Some(adapter) = &*adapter {
let adapter = adapter.clone();
let app_state = Arc::clone(&app_state);
Ok(async_runtime::spawn(async move {
let mut event_stream = adapter.events().await.unwrap();
while let Some(event) = event_stream.next().await {
match event {
btleplug::api::CentralEvent::DeviceDiscovered(_id) => {
app.emit("app_state_updated", ()).unwrap();
}
btleplug::api::CentralEvent::DeviceConnected(_id) => {
let state = app_state.write().await;
let peripherals = adapter.peripherals().await;
if let Ok(peripherals) = peripherals {
for peripheral in peripherals {
if peripheral.id() == _id {
state.set_connected_peripheral(peripheral).await;
app.emit("app_state_updated", ()).unwrap();
break;
}
}
}
}
btleplug::api::CentralEvent::DeviceDisconnected(_id) => {
let state = app_state.write().await;
state.clear_connected_peripheral().await;
app.emit("app_state_updated", ()).unwrap();
}
_ => {}
}
}
}))
} else {
app.emit("app_state_updated", ()).unwrap();
Err(errors::AppError::BluetoothNotReady)
}
}
pub async fn get_central_adapter() -> Result<Adapter, errors::AppError> {
let manager = Manager::new()
.await
.map_err(|_| errors::AppError::BluetoothNotReady)?;
let adapters = manager
.adapters()
.await
.map_err(|_| errors::AppError::BluetoothAdapterNotFound)?;
let mut found_adapter = None;
for adpater in adapters {
let state = adpater.adapter_state().await;
if let Ok(state) = state {
if state == CentralState::PoweredOn {
found_adapter = Some(adpater);
break;
}
}
}
found_adapter.ok_or(errors::AppError::NoAvailableBluetoothAdapter)
}
pub async fn start_scan(
app_handle: Arc<AppHandle>,
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<(), errors::AppError> {
let state = app_state.read().await;
let adapter = state.get_central_adapter().await;
if let Some(adapter) = adapter {
adapter
.start_scan(ScanFilter::default())
.await
.map_err(|_| errors::AppError::UnableToStartScan)?;
app_handle.emit("app_state_updated", ()).unwrap();
state.set_scanning(true).await;
Ok(())
} else {
Err(errors::AppError::NoAvailableBluetoothAdapter)
}
}
pub async fn stop_scan(
app_handle: Arc<AppHandle>,
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<(), errors::AppError> {
let state = app_state.read().await;
let adapter = state.get_central_adapter().await;
if let Some(adapter) = adapter {
adapter
.stop_scan()
.await
.map_err(|_| errors::AppError::UnableToStopScan)?;
app_handle.emit("app_state_updated", ()).unwrap();
state.set_scanning(false).await;
Ok(())
} else {
Err(errors::AppError::NoAvailableBluetoothAdapter)
}
}
pub async fn get_peripherals(
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<Vec<Peripheral>, errors::AppError> {
let state = app_state.read().await; // Change from lock() to read()
let adapter = state.get_central_adapter().await;
if let Some(adapter) = adapter {
Ok(adapter
.peripherals()
.await
.map_err(|_| errors::AppError::UnableToRetrievePeripherals)?)
} else {
Err(errors::AppError::NoAvailableBluetoothAdapter)
}
}

122
src-tauri/src/cmd/mod.rs Normal file
View File

@ -0,0 +1,122 @@
use std::sync::Arc;
use btleplug::api::{Central as _, Peripheral as _};
use state::{ApplicationState, CentralState, ChannelState, PeripheralItem};
use tauri::{async_runtime::RwLock, AppHandle, Emitter, State};
use crate::{bluetooth, errors, state::AppState};
mod state;
#[tauri::command]
pub async fn activate_central_adapter(
app_handle: AppHandle,
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<(), errors::AppError> {
let app = Arc::new(app_handle);
let adapter = bluetooth::get_central_adapter().await?;
{
let state = app_state.read().await;
state.set_central_adapter(adapter).await;
state.clear_central_event_handler().await;
}
let handle =
bluetooth::handle_bluetooth_events(Arc::clone(&app), Arc::clone(&app_state)).await?;
{
let state = app_state.write().await; // Changed from lock() to write()
state.set_central_event_handler(handle).await;
}
app.emit("app_state_updated", ()).unwrap();
Ok(())
}
#[tauri::command]
pub async fn refresh_application_state(
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<ApplicationState, errors::AppError> {
let state = app_state.read().await;
let mut peripherals: Vec<PeripheralItem> = vec![];
let mut connected_peripheral = None;
let central = state.get_central_adapter().await;
let central_state = if let Some(central) = central {
let central_device_state = central
.adapter_state()
.await
.map_err(|_| errors::AppError::BluetoothNotReady)?;
let found_peripherals = central
.peripherals()
.await
.map_err(|_| errors::AppError::UnableToRetrievePeripherals)?;
for peripheral in found_peripherals {
let properties = peripheral
.properties()
.await
.map_err(|_| errors::AppError::UnableToRetrievePeripheralProperties)?;
if let Some(properties) = properties {
let represent = properties
.local_name
.unwrap_or_else(|| properties.address.to_string());
let item = PeripheralItem {
id: peripheral.id(),
address: properties.address.to_string(),
represent,
is_connected: peripheral
.is_connected()
.await
.map_err(|_| errors::AppError::UnableToRetrievePeripheralProperties)?,
rssi: properties.rssi,
battery: properties.tx_power_level,
};
if peripheral
.is_connected()
.await
.map_err(|_| errors::AppError::UnableToRetrievePeripheralState)?
{
connected_peripheral = Some(item.clone());
}
peripherals.push(item);
}
}
CentralState {
is_ready: central_device_state == btleplug::api::CentralState::PoweredOn,
is_scanning: *state.scanning.lock().await,
connected: state
.connected_peripheral
.lock()
.await
.clone()
.map(|p| p.id()),
}
} else {
CentralState::default()
};
Ok(ApplicationState {
central: central_state,
peripherals,
connected_peripheral,
channel_a: ChannelState::default(),
channel_b: ChannelState::default(),
})
}
#[tauri::command]
pub async fn start_scan_devices(
app_handle: AppHandle,
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<(), errors::AppError> {
bluetooth::start_scan(Arc::new(app_handle), app_state).await?;
Ok(())
}
#[tauri::command]
pub async fn stop_scan_devices(
app_handle: AppHandle,
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<(), errors::AppError> {
bluetooth::stop_scan(Arc::new(app_handle), app_state).await?;
Ok(())
}

View File

@ -0,0 +1,41 @@
use btleplug::platform::PeripheralId;
use serde::Serialize;
use crate::playlist::PlayMode;
#[derive(Debug, Clone, Default, Serialize)]
pub struct CentralState {
pub is_ready: bool,
pub is_scanning: bool,
pub connected: Option<PeripheralId>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PeripheralItem {
pub id: PeripheralId,
pub address: String,
pub represent: String,
pub is_connected: bool,
pub rssi: Option<i16>,
pub battery: Option<i16>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ChannelState {
pub is_playing: bool,
pub strength: u32,
pub strength_limit: u32,
pub is_boosting: bool,
pub boost_level: u32,
pub boost_limit: u32,
pub play_mode: PlayMode,
}
#[derive(Debug, Clone, Serialize)]
pub struct ApplicationState {
pub central: CentralState,
pub peripherals: Vec<PeripheralItem>,
pub connected_peripheral: Option<PeripheralItem>,
pub channel_a: ChannelState,
pub channel_b: ChannelState,
}

View File

@ -0,0 +1,23 @@
use std::{fs::DirBuilder, path::PathBuf};
use tauri::{path::PathResolver, AppHandle, Manager, Runtime};
fn ensure_dir<R: Runtime>(resolver: &PathResolver<R>) -> anyhow::Result<PathBuf> {
let config_dir = resolver
.local_data_dir()
.map_err(|e| anyhow::anyhow!("Unable to get local data directory: {}", e))?;
if !config_dir.exists() {
let mut dir_creator = DirBuilder::new();
dir_creator
.recursive(true)
.create(&config_dir)
.map_err(|e| anyhow::anyhow!("Unable to create config directory: {}", e))?;
}
Ok(config_dir)
}
pub fn open_config_db<R: Runtime>(app_handle: &AppHandle<R>) -> anyhow::Result<sled::Db> {
let config_dir = ensure_dir(app_handle.path())?;
let db_path = config_dir.join("conf.db");
sled::open(&db_path).map_err(|e| anyhow::anyhow!("Unable to open config database: {}", e))
}

22
src-tauri/src/errors.rs Normal file
View File

@ -0,0 +1,22 @@
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error, Serialize)]
pub enum AppError {
#[error("Bluetooth not ready")]
BluetoothNotReady,
#[error("No Bluetooth adapter found")]
BluetoothAdapterNotFound,
#[error("No available Bluetooth adapter, maybe not powered on")]
NoAvailableBluetoothAdapter,
#[error("Unable to start scan devices")]
UnableToStartScan,
#[error("Unable to stop scan devices")]
UnableToStopScan,
#[error("Unable to retrieve peripherals")]
UnableToRetrievePeripherals,
#[error("Unable to retrieve peripheral properties")]
UnableToRetrievePeripheralProperties,
#[error("Unable to retrieve peripheral state")]
UnableToRetrievePeripheralState,
}

63
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,63 @@
#![allow(dead_code)]
#![feature(stmt_expr_attributes)]
use std::sync::Arc;
use tauri::{
async_runtime::{self, RwLock},
generate_handler, Manager,
};
use tauri_plugin_dialog::DialogExt;
mod bluetooth;
mod cmd;
mod config_db;
mod errors;
mod playlist;
mod state;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(debug_assertions)]
let devtools = tauri_plugin_devtools::init();
let mut builder = tauri::Builder::default();
#[cfg(debug_assertions)]
builder = builder.plugin(devtools);
builder
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_single_instance::init(|_app, _args, _cwd| {}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| {
if let Err(e) = async_runtime::block_on(async {
let state = state::AppState::new(app.handle()).await?;
app.manage(Arc::new(RwLock::new(state)));
Ok::<(), anyhow::Error>(())
}) {
app.dialog()
.message(e.to_string())
.kind(tauri_plugin_dialog::MessageDialogKind::Error)
.title("Initialization error")
.blocking_show();
return Err(e.into());
}
#[cfg(debug_assertions)]
{
let window = app.get_webview_window("main").unwrap();
window.open_devtools();
}
Ok(())
})
.invoke_handler(generate_handler![
cmd::refresh_application_state,
cmd::activate_central_adapter,
cmd::start_scan_devices,
cmd::stop_scan_devices
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
estim_lib::run()
}

17
src-tauri/src/playlist.rs Normal file
View File

@ -0,0 +1,17 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlayMode {
#[serde(rename = "repeat")]
Repeat,
#[serde(rename = "repeat-one")]
RepeatOne,
#[serde(rename = "shuffle")]
Shuffle,
}
impl Default for PlayMode {
fn default() -> Self {
PlayMode::Repeat
}
}

86
src-tauri/src/state.rs Normal file
View File

@ -0,0 +1,86 @@
use std::sync::Arc;
use btleplug::platform::{Adapter, Peripheral};
use tauri::{
async_runtime::{JoinHandle, Mutex, RwLock},
AppHandle, Runtime,
};
use crate::config_db;
pub struct AppState {
pub db: Arc<Mutex<sled::Db>>,
pub central_adapter: Arc<RwLock<Option<Adapter>>>,
pub central_event_handler: Arc<Mutex<Option<JoinHandle<()>>>>,
pub scanning: Arc<Mutex<bool>>,
pub connected_peripheral: Arc<Mutex<Option<Arc<Peripheral>>>>,
}
unsafe impl Send for AppState {}
unsafe impl Sync for AppState {}
impl AppState {
pub async fn new<R: Runtime>(app: &AppHandle<R>) -> anyhow::Result<Self> {
let db = config_db::open_config_db(app)?;
Ok(Self {
db: Arc::new(Mutex::new(db)),
central_adapter: Arc::new(RwLock::new(None)),
central_event_handler: Arc::new(Mutex::new(None)),
scanning: Arc::new(Mutex::new(false)),
connected_peripheral: Arc::new(Mutex::new(None)),
})
}
pub async fn set_central_adapter(&self, adapter: Adapter) {
let mut central_adapter = self.central_adapter.write().await;
*central_adapter = Some(adapter);
}
pub async fn clear_central_adapter(&self) {
let mut central_adapter = self.central_adapter.write().await;
*central_adapter = None;
}
pub async fn get_central_adapter(&self) -> Option<Adapter> {
let central_adapter = self.central_adapter.read().await;
central_adapter.clone()
}
pub async fn set_central_event_handler(&self, handler: JoinHandle<()>) {
let mut central_event_handler = self.central_event_handler.lock().await;
*central_event_handler = Some(handler);
}
pub async fn clear_central_event_handler(&self) {
let mut central_event_handler = self.central_event_handler.lock().await;
if let Some(handler) = central_event_handler.take() {
handler.abort();
}
}
pub async fn set_scanning(&self, scanning_state: bool) {
let mut scanning = self.scanning.lock().await;
*scanning = scanning_state;
}
pub async fn set_connected_peripheral(&self, peripheral: Peripheral) {
let mut connected_peripheral = self.connected_peripheral.lock().await;
*connected_peripheral = Some(Arc::new(peripheral));
}
pub async fn clear_connected_peripheral(&self) {
let mut connected_peripheral = self.connected_peripheral.lock().await;
*connected_peripheral = None;
}
pub async fn get_connected_peripheral(&self) -> Option<Arc<Peripheral>> {
let connected_peripheral = self.connected_peripheral.lock().await;
if let Some(peripheral) = connected_peripheral.clone() {
Some(Arc::clone(&peripheral))
} else {
None
}
}
}

39
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "estim",
"version": "0.1.0",
"identifier": "net.archgrid.app.estim",
"build": {
"beforeDevCommand": "deno task dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "deno task build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "estim",
"width": 1200,
"height": 800,
"resizable": false,
"hiddenTitle": true,
"titleBarStyle": "Overlay",
"theme": "Dark"
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

81
src/Layout.module.css Normal file
View File

@ -0,0 +1,81 @@
@layer pages {
.layout {
width: 100%;
height: 100%;
display: flex;
padding-block-start: calc(var(--spacing));
padding-block-end: calc(var(--spacing) * 4);
padding-inline: calc(var(--spacing) * 4);
margin: 0;
flex-direction: column;
align-items: stretch;
gap: calc(var(--spacing) * 3);
header {
display: flex;
flex-direction: row;
align-items: flex-start;
height: calc(var(--spacing) * 6);
z-index: 10;
&.mac_titlebar {
padding-left: calc(var(--spacing) * 14);
}
h1 {
line-height: 1em;
font-style: italic;
}
}
section {
flex: 1;
display: flex;
flex-direction: row;
align-items: stretch;
gap: calc(var(--spacing) * 3);
menu {
flex: 1;
padding: calc(var(--spacing) * 4) 0;
max-width: calc(var(--spacing) * 12);
display: flex;
flex-direction: column;
align-items: stretch;
gap: calc(var(--spacing) * 4);
}
.main_content {
flex: 1;
border-radius: calc(var(--border-radius) * 2);
}
}
}
.window_move_handler {
position: absolute;
top: 0;
left: 0;
right: 0;
height: calc(var(--spacing) * 8);
z-index: 300;
}
.route_link {
display: flex;
flex-direction: column;
align-items: stretch;
gap: calc(var(--spacing));
div {
text-align: center;
&.filled {
color: var(--color-dark-on-surface);
background-color: transparent;
border-radius: calc(var(--border-radius) * 2);
padding-block: calc(var(--spacing));
}
}
&.inactive {
color: var(--color-dark-on-surface-variant);
}
&.active {
color: var(--color-dark-on-surface);
div.filled {
color: var(--color-dark-on-secondary-container);
background-color: var(--color-dark-secondary-container);
}
}
}
}

70
src/Layout.tsx Normal file
View File

@ -0,0 +1,70 @@
import { Icon } from '@iconify/react/dist/iconify.js';
import { platform } from '@tauri-apps/plugin-os';
import cx from 'clsx';
import { FC, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { NavLink, Outlet } from 'react-router-dom';
import styles from './Layout.module.css';
import { defaultIconProps } from './icons/shared-props';
import StateBar from './page-components/state-bar/StateBar';
type FunctionLinkProps = {
name: string;
url: string;
end?: boolean;
children?: ReactNode;
};
const FunctionLink: FC<FunctionLinkProps> = ({ name, url, end, children }) => {
return (
<NavLink
to={url}
className={({ isActive }) =>
cx(styles.route_link, isActive ? styles.active : styles.inactive)
}
end={end}>
<div className={styles.filled}>{children}</div>
<div>{name}</div>
</NavLink>
);
};
const Layout: FC = () => {
const os = platform();
return (
<main className={styles.layout}>
<header className={cx({ [styles.mac_titlebar]: os === 'macos' })}>
<h1>ESTIM Remote</h1>
<StateBar />
</header>
<section>
<menu>
<FunctionLink name="Device" url="/" end>
<Icon icon="material-symbols-light:device-unknown" {...defaultIconProps} />
</FunctionLink>
<FunctionLink name="Play" url="/play">
<Icon icon="material-symbols-light:motion-play" {...defaultIconProps} />
</FunctionLink>
<FunctionLink name="Pattern Library" url="/library">
<Icon icon="material-symbols-light:menu-book" {...defaultIconProps} />
</FunctionLink>
<FunctionLink name="Pattern Editor" url="/pattern-editor">
<Icon icon="material-symbols-light:movie-edit" {...defaultIconProps} />
</FunctionLink>
<FunctionLink name="Settings" url="/settings">
<Icon icon="material-symbols-light:settings" {...defaultIconProps} />
</FunctionLink>
</menu>
<div className={styles.main_content}>
<Outlet />
</div>
</section>
{createPortal(
<div data-tauri-drag-region className={styles.window_move_handler} />,
document.body,
)}
</main>
);
};
export default Layout;

203
src/components.css Normal file
View File

@ -0,0 +1,203 @@
@layer base {
:root {
--button-text: var(--color-dark-on-primary);
--button-surface: var(--color-dark-primary);
--button-outline: var(--color-dark-outline);
}
:where(ul, menu) {
margin: 0;
padding: 0;
}
:where(a) {
text-decoration: none;
color: var(--color-dark-on-surface);
&:hover {
color: var(--color-dark-primary);
}
&:active {
color: var(--color-dark-tertiary);
}
}
:is(h1, h2, h3, h4, h5, h6) {
font-weight: bold;
line-height: 1.2em;
}
h1 {
font-size: 2.6em;
}
h2 {
font-size: 2em;
}
h3 {
font-size: 1.8em;
}
h4 {
font-size: 1.5em;
}
h5 {
font-size: 1.2em;
}
h6 {
font-size: 1em;
}
.center {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.spacer {
flex: 1 1;
}
.workspace {
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
align-items: stretch;
gap: calc(var(--spacing) * 6);
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
&.veritcal {
flex-direction: column;
}
&.horizontal {
flex-direction: row;
}
}
:where(button, .button) {
border: none;
border-radius: calc(var(--border-radius) * 2);
padding: calc(var(--spacing) * 1) calc(var(--spacing) * 3);
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: calc(var(--spacing) * 2);
font-size: calc(var(--font-size) * 1.2);
line-height: 1.3em;
color: var(--button-text);
background-color: var(--button-surface);
box-shadow: var(--elevation-dark-0);
&.smaller {
font-size: calc(var(--font-size) * 0.8);
}
&.small {
font-size: calc(var(--font-size) * 1);
}
&.large {
font-size: calc(var(--font-size) * 1.4);
}
&.larger {
font-size: calc(var(--font-size) * 1.6);
}
&:hover:not(:disabled) {
--button-text: var(--color-dark-on-primary);
box-shadow: var(--elevation-dark-1-ambient), var(--elevation-dark-1-umbra);
}
&:active:not(:disabled) {
color: color-mix(in oklch, var(--button-text) 12%, transparent);
}
&.tonal:not(:disabled) {
--button-text: var(--color-dark-on-primary-container);
--button-surface: var(--color-dark-primary-container);
}
&.danger:not(:disabled) {
--button-text: var(--color-dark-on-error);
--button-surface: var(--color-dark-error);
}
&.warn:not(:disabled) {
--button-text: var(--color-dark-on-warning);
--button-surface: var(--color-dark-warning);
}
&.success:not(:disabled) {
--button-text: var(--color-dark-on-success);
--button-surface: var(--color-dark-success);
}
&.info:not(:disabled) {
--button-text: var(--color-dark-on-info);
--button-surface: var(--color-dark-info);
}
&:disabled {
--button-text: color-mix(in oklch, var(--color-dark-on-surface) 38%, transparent);
--button-surface: color-mix(in oklch, var(--color-dark-on-surface) 12%, transparent);
--button-outline: color-mix(in oklch, var(--color-dark-on-surface) 12%, transparent);
cursor: not-allowed;
}
&.outline {
--button-text: var(--color-dark-primary);
--button-surface: transparent;
border: 1px solid var(--button-outline);
&:hover:not(:disabled) {
--button-text: var(--color-dark-primary);
--button-surface: color-mix(in oklch, var(--button-text) 8%, transparent);
box-shadow: var(--elevation-dark-0);
}
&:active:not(:disabled) {
--button-text: var(--color-dark-primary);
--button-surface: color-mix(in oklch, var(--button-text) 10%, transparent);
box-shadow: var(--elevation-dark-0);
}
&.danger:not(:disabled) {
--button-text: var(--color-dark-error);
}
&.warn:not(:disabled) {
--button-text: var(--color-dark-warning);
}
&.success:not(:disabled) {
--button-text: var(--color-dark-success);
}
&.info:not(:disabled) {
--button-text: var(--color-dark-info);
}
&:disabled {
--button-text: color-mix(in oklch, var(--color-dark-on-surface) 38%, transparent);
}
}
&.text {
--button-text: --color-dark-primary;
--button-surface: transparent;
border: none;
&:hover:not(:disabled) {
--button-surface: color-mix(in oklch, var(--color-dark-primary) 8%, transparent);
}
&:active:not(:disabled) {
--button-surface: color-mix(in oklch, var(--color-dark-primary) 10%, transparent);
}
&.danger:not(:disabled) {
--button-text: var(--color-dark-error);
--button-surface: transparent;
}
&.warn:not(:disabled) {
--button-text: var(--color-dark-warning);
--button-surface: transparent;
}
&.success:not(:disabled) {
--button-text: var(--color-dark-success);
--button-surface: transparent;
}
&.info:not(:disabled) {
--button-text: var(--color-dark-info);
--button-surface: transparent;
}
&:disabled {
--button-text: color-mix(in oklch, var(--color-dark-on-surface) 38%, transparent);
}
}
}
}

View File

@ -0,0 +1,43 @@
@layer components {
.scroll_area {
display: grid;
width: 100%;
height: 100%;
overflow: hidden;
grid-template-columns: auto calc(var(--spacing) * 3);
grid-template-rows: auto calc(var(--spacing) * 3);
}
.content {
grid-column: 1;
grid-row: 1;
overflow: hidden;
}
.v_scrollbar {
grid-column: 2;
grid-row: 1;
overflow: hidden;
position: relative;
.v_thumb {
width: 100%;
aspect-ratio: 1 / 3;
position: absolute;
border-radius: calc(var(--border-radius) * 2);
background-color: oklch(from var(--color-dark-primary) l c h / 70%);
cursor: pointer;
}
}
.h_scrollbar {
grid-column: 1;
grid-row: 2;
overflow: hidden;
position: relative;
.h_thumb {
height: 100%;
aspect-ratio: 3 / 1;
position: absolute;
border-radius: calc(var(--border-radius) * 2);
background-color: oklch(from var(--color-dark-primary) l c h / 70%);
cursor: pointer;
}
}
}

View File

@ -0,0 +1,195 @@
import { clamp } from 'lodash-es';
import { MouseEvent, RefObject, useEffect, useRef, useState, WheelEvent } from 'react';
import styles from './ScrollArea.module.css';
type ScrollBarProps = {
containerRef: RefObject<HTMLDivElement> | null;
};
function VerticalScrollBar({ containerRef }: ScrollBarProps) {
const [thumbPos, setThumbPos] = useState(0);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const handleMouseDown = (evt: MouseEvent) => {
evt.preventDefault();
//@ts-expect-error TS2769
document.addEventListener('mousemove', handleMouseMove);
//@ts-expect-error TS2769
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (evt: MouseEvent<HTMLDivElement>) => {
evt.preventDefault();
const container = containerRef?.current;
const scrollbar = trackRef.current;
const thumb = thumbRef.current;
if (container && scrollbar && thumb) {
const trackRect = scrollbar.getBoundingClientRect();
const thumbRect = thumb.getBoundingClientRect();
const offsetY = evt.clientY - trackRect.top - thumbRect.height / 2;
const thumbPosition = clamp(offsetY, 0, trackRect.height - thumbRect.height);
setThumbPos(thumbPosition);
const scrollPercentage = thumbPosition / (trackRect.height - thumbRect.height);
container.scrollTop = scrollPercentage * (container.scrollHeight - container.clientHeight);
}
};
const handleMouseUp = (evt: MouseEvent) => {
evt.preventDefault();
//@ts-expect-error TS2769
document.removeEventListener('mousemove', handleMouseMove);
//@ts-expect-error TS2769
document.removeEventListener('mouseup', handleMouseUp);
};
const updateThumbPosition = () => {
const container = containerRef?.current;
const scrollbar = trackRef.current;
const scrollPercentage =
container.scrollTop / (container.scrollHeight - container.clientHeight);
const thumbPosition =
scrollPercentage * (scrollbar.clientHeight - thumbRef.current!.clientHeight);
setThumbPos(thumbPosition);
};
useEffect(() => {
const container = containerRef?.current;
const scrollbar = trackRef.current;
if (container && scrollbar) {
container.addEventListener('scroll', updateThumbPosition);
return () => {
container.removeEventListener('scroll', updateThumbPosition);
};
}
}, []);
return (
<div className={styles.v_scrollbar} ref={trackRef}>
<div
className={styles.v_thumb}
ref={thumbRef}
style={{ top: thumbPos }}
onMouseDown={handleMouseDown}
/>
</div>
);
}
function HorizontalScrollBar({ containerRef }: ScrollBarProps) {
const [thumbPos, setThumbPos] = useState(0);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const handleMouseDown = (evt: MouseEvent) => {
evt.preventDefault();
//@ts-expect-error TS2769
document.addEventListener('mousemove', handleMouseMove);
//@ts-expect-error TS2769
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (evt: MouseEvent) => {
evt.preventDefault();
const container = containerRef?.current;
const scrollbar = trackRef.current;
const thumb = thumbRef.current;
if (container && scrollbar && thumb) {
const trackRect = scrollbar.getBoundingClientRect();
const thumbRect = thumb.getBoundingClientRect();
const offsetX = evt.clientX - trackRect.left - thumbRect.width / 2;
const thumbPosition = clamp(offsetX, 0, trackRect.width - thumbRect.width);
setThumbPos(thumbPosition);
const scrollPercentage = thumbPosition / (trackRect.width - thumbRect.width);
container.scrollLeft = scrollPercentage * (container.scrollWidth - container.clientWidth);
}
};
const handleMouseUp = (evt: MouseEvent) => {
evt.preventDefault();
//@ts-expect-error TS2769
document.removeEventListener('mousemove', handleMouseMove);
//@ts-expect-error TS2769
document.removeEventListener('mouseup', handleMouseUp);
};
const updateThumbPosition = () => {
const container = containerRef?.current;
const scrollbar = trackRef.current;
const scrollPercentage = container.scrollLeft / (container.scrollWidth - container.clientWidth);
const thumbPosition =
scrollPercentage * (scrollbar.clientWidth - thumbRef.current!.clientWidth);
setThumbPos(thumbPosition);
};
useEffect(() => {
const container = containerRef?.current;
const scrollbar = trackRef.current;
if (container && scrollbar) {
container.addEventListener('scroll', updateThumbPosition);
return () => {
container.removeEventListener('scroll', updateThumbPosition);
};
}
}, []);
return (
<div className={styles.h_scrollbar} ref={trackRef}>
<div
className={styles.h_thumb}
ref={thumbRef}
style={{ left: thumbPos }}
onMouseDown={(e) => handleMouseDown(e)}
/>
</div>
);
}
type ScrollAreaProps = {
children?: React.ReactNode;
enableX?: boolean;
enableY?: boolean;
normalizedScroll?: boolean;
};
export function ScrollArea({
children,
enableX = false,
enableY = false,
normalizedScroll = false,
}: ScrollAreaProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const [xScrollNeeded, setXScrollNeeded] = useState(false);
const [yScrollNeeded, setYScrollNeeded] = useState(false);
const handleWheel = (evt: WheelEvent<HTMLDivElement>) => {
const container = scrollContainerRef?.current;
if (enableY && container) {
const delta = evt.deltaY;
const normalizedDelta = normalizedScroll ? clamp(delta, -1, 1) * 30 : delta;
const newScrollTop = container.scrollTop + normalizedDelta;
container.scrollTop = clamp(newScrollTop, 0, container.scrollHeight - container.clientHeight);
}
if (enableX && container) {
const delta = evt.deltaX;
const normalizedDelta = normalizedScroll ? clamp(delta, -1, 1) * 30 : delta;
const newScrollLeft = container.scrollLeft + normalizedDelta;
container.scrollLeft = clamp(newScrollLeft, 0, container.scrollWidth - container.clientWidth);
}
};
useEffect(() => {
const container = scrollContainerRef.current;
if (container) {
setXScrollNeeded(container.scrollWidth > container.clientWidth);
setYScrollNeeded(container.scrollHeight > container.clientHeight);
}
}, [children]);
return (
<div className={styles.scroll_area}>
<div className={styles.content} ref={scrollContainerRef} onWheel={(e) => handleWheel(e)}>
{children}
</div>
{enableY && yScrollNeeded && <VerticalScrollBar containerRef={scrollContainerRef} />}
{enableX && xScrollNeeded && <HorizontalScrollBar containerRef={scrollContainerRef} />}
</div>
);
}

View File

@ -0,0 +1,96 @@
import { invoke } from '@tauri-apps/api/core';
import { Event, listen, UnlistenFn } from '@tauri-apps/api/event';
import { message } from '@tauri-apps/plugin-dialog';
import { atom, PrimitiveAtom, useSetAtom } from 'jotai';
import { atomFamily } from 'jotai/utils';
import { FC, ReactNode, useCallback, useEffect, useRef } from 'react';
export type Channels = 'a' | 'b';
type ChannelState = {
playing: boolean;
playMode: 'shuffle' | 'repeat' | 'repeat-one';
strength: number;
maxStrength: number;
boosting: boolean;
boostLevel: number;
maxBoostLevel: number;
};
type DeviceState = {
rssi: number | null;
battery: number | null;
};
type BluetoothState = {
ready: boolean | null;
searching: boolean | null;
connected: string | null;
};
export const BleState = atom<BluetoothState>({
ready: null,
searching: null,
connected: null,
});
export const DeviceState = atom<DeviceState>({
rssi: null,
battery: null,
});
const Channels: Record<Channels, PrimitiveAtom<ChannelState>> = {
a: atom<ChannelState>({
playing: false,
playMode: 'repeat-one',
strength: 0,
maxStrength: 100,
boosting: false,
boostLevel: 0,
maxBoostLevel: 100,
}),
b: atom<ChannelState>({
playing: false,
playMode: 'repeat-one',
strength: 0,
maxStrength: 100,
boosting: false,
boostLevel: 0,
maxBoostLevel: 100,
}),
};
export const ChannelState = atomFamily((channel: Channels) => Channels[channel]);
const EstimWatchProvider: FC<{ children?: ReactNode }> = ({ children }) => {
const unlisten = useRef<UnlistenFn | null>(null);
const setBleState = useSetAtom(BleState);
const handleAppStateRefresh = useCallback(async (event: Event<unknown>) => {
try {
const newState = await invoke('refresh_application_state');
setBleState({
ready: newState.central.is_ready,
searching: newState.central.is_scanning,
connected: newState.central.connected,
});
} catch (e) {
console.error('[Answer refresh state]', e);
}
}, []);
useEffect(() => {
(async function () {
try {
unlisten.current = await listen('app_state_updated', handleAppStateRefresh);
await invoke('activate_central_adapter');
} catch (e) {
console.error('[Activate Adapter]', e);
await message('Fail to activate Bluetooth adapter.', {
title: 'Bluetooth Error',
kind: 'error',
});
}
})();
return () => {
unlisten.current?.();
};
}, []);
return <>{children}</>;
};
export default EstimWatchProvider;

42
src/icons/IconBattery.tsx Normal file
View File

@ -0,0 +1,42 @@
import { Icon } from '@iconify/react/dist/iconify.js';
import { FC, useMemo } from 'react';
import { SharedIconProps } from './shared-props';
type IconBatteryProps = SharedIconProps & {
level?: number | null;
};
const IconBattery: FC<IconBatteryProps> = ({
height = 24,
aspect = 1,
stroke = 0.5,
level = null,
}) => {
const width = useMemo(() => height * aspect, [height, aspect]);
const batteryIcon = useMemo(() => {
if (level !== null && level >= 90) {
return 'material-symbols-light:battery-full';
} else if (level !== null && level >= 80) {
return 'material-symbols-light:battery-6-bar';
} else if (level !== null && level >= 70) {
return 'material-symbols-light:battery-5-bar';
} else if (level !== null && level >= 50) {
return 'material-symbols-light:battery-4-bar';
} else if (level !== null && level >= 30) {
return 'material-symbols-light:battery-3-bar';
} else if (level !== null && level >= 20) {
return 'material-symbols-light:battery-2-bar';
} else if (level !== null && level >= 10) {
return 'material-symbols-light:battery-1-bar';
} else if (level !== null && level >= 0) {
return 'material-symbols-light:battery-0-bar';
} else {
return 'material-symbols-light:battery-error';
}
}, [level]);
console.debug('[icon battery]', level, batteryIcon);
return <Icon icon={batteryIcon} width={width} height={height} stroke={stroke} />;
};
export default IconBattery;

View File

@ -0,0 +1,35 @@
import { Icon } from '@iconify/react/dist/iconify.js';
import { FC, useMemo } from 'react';
import { SharedIconProps } from './shared-props';
type IconBluetoothProps = SharedIconProps & {
ready?: boolean | null;
searching?: boolean | null;
connected?: boolean | null;
};
const IconBluetooth: FC<IconBluetoothProps> = ({
height = 24,
aspect = 1,
stroke = 0.5,
ready = false,
searching = false,
connected = false,
}) => {
const width = useMemo(() => height * aspect, [height, aspect]);
const bleIcon = useMemo(() => {
if (ready && !searching && !connected) {
return 'material-symbols-light:bluetooth';
} else if (ready && searching) {
return 'material-symbols-light:bluetooth-searching';
} else if (ready && !searching && connected) {
return 'material-symbols-light:bluetooth-connected';
} else {
return 'material-symbols-light:bluetooth-disabled';
}
}, [ready, searching, connected]);
return <Icon icon={bleIcon} width={width} height={height} stroke={stroke} />;
};
export default IconBluetooth;

30
src/icons/IconRssi.tsx Normal file
View File

@ -0,0 +1,30 @@
import { Icon } from '@iconify/react/dist/iconify.js';
import { FC, useMemo } from 'react';
import { SharedIconProps } from './shared-props';
type IconRssiProps = SharedIconProps & {
level?: number | null;
};
const IconRssi: FC<IconRssiProps> = ({ height = 24, aspect = 1, stroke = 0.5, level = null }) => {
const width = useMemo(() => height * aspect, [height, aspect]);
const rssiIcon = useMemo(() => {
if (level === null) {
return 'material-symbols-light:signal-cellular-nodata';
} else if (level <= -80) {
return 'material-symbols-light:signal-cellular-4-bar';
} else if (level <= -70) {
return 'material-symbols-light:signal-cellular-3-bar';
} else if (level <= -60) {
return 'material-symbols-light:signal-cellular-2-bar';
} else if (level <= -50) {
return 'material-symbols-light:signal-cellular-1-bar';
} else {
return 'material-symbols-light:signal-cellular-null';
}
}, [level]);
return <Icon icon={rssiIcon} width={width} height={height} stroke={stroke} />;
};
export default IconRssi;

17
src/icons/shared-props.ts Normal file
View File

@ -0,0 +1,17 @@
import { IconProps } from '@iconify/react/dist/iconify.js';
export type SharedIconProps = {
height?: number | null;
aspect?: number | null;
stroke?: number | null;
};
export const defaultIconProps: Partial<IconProps> = {
height: 24,
stroke: 0.5,
};
export const smallIconProps: Partial<IconProps> = {
height: 16,
stroke: 0.5,
};

5
src/index.css Normal file
View File

@ -0,0 +1,5 @@
@layer theme, base, components, utilities, pages;
@import 'sanitize.css' layer(base);
@import 'sanitize.css/forms.css' layer(base);
@import './theme.css';
@import './components.css';

31
src/main.tsx Normal file
View File

@ -0,0 +1,31 @@
// Load global styles
import './index.css';
// Load foundations
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Layout from './Layout';
import EstimWatchProvider from './context/EstimContext';
import Device from './pages/Device';
import PatternEditor from './pages/PatternEditor';
import PatternLibrary from './pages/PatternLibrary';
import PlayControl from './pages/Play';
import Settings from './pages/Settings';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<EstimWatchProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Device />} />
<Route path="/play" element={<PlayControl />} />
<Route path="/library" element={<PatternLibrary />} />
<Route path="/pattern-editor" element={<PatternEditor />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
</EstimWatchProvider>
</React.StrictMode>,
);

View File

@ -0,0 +1,11 @@
@layer pages {
.ble_control {
height: calc(var(--spacing) * 12);
padding-inline: calc(var(--spacing) * 2);
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: calc(var(--spacing) * 2);
}
}

View File

@ -0,0 +1,17 @@
import { useAtomValue } from 'jotai';
import { FC } from 'react';
import { BleState } from '../../context/EstimContext';
import styles from './BleControl.module.css';
const BleControl: FC = () => {
const bleState = useAtomValue(BleState);
return (
<div className={styles.ble_control}>
<button disabled={!bleState.ready || bleState.searching}>Scan</button>
<button disabled={!bleState.ready || !bleState.connected}>Disconnect</button>
</div>
);
};
export default BleControl;

View File

@ -0,0 +1,8 @@
@layer pages {
.device_detail {
flex: 2;
border-radius: calc(var(--border-radius) * 2);
padding: calc(var(--spacing) * 2);
background-color: var(--color-dark-surface-container);
}
}

View File

@ -0,0 +1,8 @@
import { FC } from 'react';
import styles from './DeviceDetail.module.css';
const DeviceDetail: FC = () => {
return <div className={styles.device_detail}></div>;
};
export default DeviceDetail;

View File

@ -0,0 +1,8 @@
@layer pages {
.devices {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
}
}

View File

@ -0,0 +1,13 @@
import { FC } from 'react';
import { ScrollArea } from '../../components/ScrollArea';
import styles from './DeviceList.module.css';
const DeviceList: FC = () => {
return (
<div className={styles.devices}>
<ScrollArea enableY></ScrollArea>
</div>
);
};
export default DeviceList;

View File

@ -0,0 +1,11 @@
@layer pages {
.channel_host {
flex: 1;
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
border-radius: calc(var(--border-radius) * 2);
background-color: var(--color-dark-surface-container);
display: flex;
flex-direction: column;
gap: calc(var(--spacing));
}
}

View File

@ -0,0 +1,17 @@
import { FC } from 'react';
import { Channels } from '../../context/EstimContext';
import styles from './ChannelHost.module.css';
type ChannelHostProps = {
channel: Channels;
};
const ChannelHost: FC<ChannelHostProps> = ({ channel }) => {
return (
<div className={styles.channel_host}>
<h3>Channel {channel.toUpperCase()}</h3>
</div>
);
};
export default ChannelHost;

View File

@ -0,0 +1,30 @@
import { useAtomValue } from 'jotai';
import { FC, useMemo } from 'react';
import { BleState } from '../../context/EstimContext';
import IconBluetooth from '../../icons/IconBluetooth';
const BleStates: FC = () => {
const ble = useAtomValue(BleState);
const bleIcon = useMemo(() => {
if (ble.ready && !ble.searching && !ble.connected) {
return 'material-symbols-light:bluetooth';
} else if (ble.ready && ble.searching) {
return 'material-symbols-light:bluetooth-searching';
} else if (ble.ready && !ble.searching && ble.connected) {
return 'material-symbols-light:bluetooth-connected';
} else {
return 'material-symbols-light:bluetooth-disabled';
}
}, [ble]);
return (
<IconBluetooth
height={16}
ready={ble.ready}
searching={ble.searching}
connected={ble.connected?.length > 0}
/>
);
};
export default BleStates;

View File

@ -0,0 +1,10 @@
@layer pages {
.channel_state {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: calc(var(--spacing));
font-size: calc(var(--font-size) * 1.4);
}
}

View File

@ -0,0 +1,35 @@
import { Icon } from '@iconify/react/dist/iconify.js';
import { useAtomValue } from 'jotai';
import { FC } from 'react';
import { Channels, ChannelState } from '../../context/EstimContext';
import { smallIconProps } from '../../icons/shared-props';
import styles from './ChannelStates.module.css';
const ChannelStates: FC<{ channel: Channels }> = ({ channel }) => {
const chState = useAtomValue(ChannelState(channel));
return (
<div className={styles.channel_state}>
<span>Ch {channel.toUpperCase()}</span>
{chState.playing ? (
<Icon icon="material-symbols-light:electric-bolt" {...smallIconProps} />
) : (
<Icon icon="material-symbols-light:stop" {...smallIconProps} />
)}
<span>{chState.strength}</span>
<Icon icon="material-symbols-light:arrow-upload-progress" {...smallIconProps} />
<span>{chState.boostLevel}</span>
{chState.playMode === 'shuffle' && (
<Icon icon="material-symbols-light:shuffle" {...smallIconProps} />
)}
{chState.playMode === 'repeat' && (
<Icon icon="material-symbols-light:repeat" {...smallIconProps} />
)}
{chState.playMode === 'repeat-one' && (
<Icon icon="material-symbols-light:repeat-one" {...smallIconProps} />
)}
</div>
);
};
export default ChannelStates;

View File

@ -0,0 +1,18 @@
import { useAtomValue } from 'jotai';
import { FC } from 'react';
import { DeviceState } from '../../context/EstimContext';
import IconBattery from '../../icons/IconBattery';
import IconRssi from '../../icons/IconRssi';
const DeviceStates: FC = () => {
const deviceState = useAtomValue(DeviceState);
return (
<>
<IconRssi height={16} level={deviceState.rssi} />
<IconBattery height={16} level={deviceState.battery} />
</>
);
};
export default DeviceStates;

View File

@ -0,0 +1,13 @@
@layer pages {
.state_bar {
flex: 1;
padding-block-start: calc(var(--spacing));
padding-inline: calc(var(--spacing) * 2);
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: calc(var(--spacing) * 4);
font-size: calc(var(--font-size) * 1.6);
}
}

View File

@ -0,0 +1,18 @@
import { FC } from 'react';
import BleStates from './BleState';
import ChannelStates from './ChannelStates';
import DeviceStates from './DeviceStates';
import styles from './StateBar.module.css';
const StateBar: FC = () => {
return (
<div className={styles.state_bar}>
<BleStates />
<ChannelStates channel="a" />
<ChannelStates channel="b" />
<DeviceStates />
</div>
);
};
export default StateBar;

View File

@ -0,0 +1,11 @@
@layer pages {
.device_list {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
gap: calc(var(--spacing) * 2);
border-radius: calc(var(--border-radius) * 2);
background-color: var(--color-dark-surface-container);
}
}

19
src/pages/Device.tsx Normal file
View File

@ -0,0 +1,19 @@
import { FC } from 'react';
import BleControl from '../page-components/device/BleControl';
import DeviceDetail from '../page-components/device/DeviceDetail';
import DeviceList from '../page-components/device/DeviceList';
import styles from './Device.module.css';
const Device: FC = () => {
return (
<div className="workspace horizontal">
<div className={styles.device_list}>
<BleControl />
<DeviceList />
</div>
<DeviceDetail />
</div>
);
};
export default Device;

View File

@ -0,0 +1,7 @@
import { FC } from 'react';
const PatternEditor: FC = () => {
return <div className="workspace"></div>;
};
export default PatternEditor;

View File

@ -0,0 +1,7 @@
import { FC } from 'react';
const PatternLibrary: FC = () => {
return <div className="workspace"></div>;
};
export default PatternLibrary;

10
src/pages/Play.module.css Normal file
View File

@ -0,0 +1,10 @@
@layer pages {
.play_control {
width: 100%;
overflow: hidden;
display: flex;
flex-direction: row;
align-items: stretch;
gap: calc(var(--spacing) * 2);
}
}

13
src/pages/Play.tsx Normal file
View File

@ -0,0 +1,13 @@
import { FC } from 'react';
import ChannelHost from '../page-components/play-control/ChannelHost';
const PlayControl: FC = () => {
return (
<div className="workspace horizontal">
<ChannelHost channel="a" />
<ChannelHost channel="b" />
</div>
);
};
export default PlayControl;

7
src/pages/Settings.tsx Normal file
View File

@ -0,0 +1,7 @@
import { FC } from 'react';
const Settings: FC = () => {
return <div className="workspace vertical"></div>;
};
export default Settings;

75
src/theme.css Normal file
View File

@ -0,0 +1,75 @@
@import './variables.css' layer(theme);
@layer base {
:root {
font-family: var(--font-family);
font-size: var(--font-size);
line-height: var(--line-height);
font-weight: 400;
width: 100vw;
height: 100vh;
overflow: hidden;
user-select: none;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
:where(html, body) {
color-scheme: dark;
width: 100vw;
height: 100vh;
overflow: hidden;
z-index: 10;
}
body {
position: relative;
color: var(--color-dark-on-surface);
background-color: var(--color-dark-surface);
}
:where(h1, h2, h3, h4, h5, h6, p, div, span) {
margin: 0;
padding: 0;
}
:where(menu) {
margin: 0;
}
#root {
width: 100%;
height: 100%;
overflow: hidden;
z-index: 15;
}
.evelation-0 {
box-shadow: var(--elevation-dark-0);
}
.evelation-1 {
box-shadow: var(--elevation-dark-1-ambient), --var(--elevation-dark-1-umbra);
}
.evelation-2 {
box-shadow: var(--elevation-dark-2-ambient), var(--elevation-dark-2-umbra);
}
.evelation-3 {
box-shadow: var(--elevation-dark-3-ambient), var(--elevation-dark-3-umbra);
}
.evelation-4 {
box-shadow: var(--elevation-dark-4-ambient), var(--elevation-dark-4-umbra);
}
.evelation-5 {
box-shadow: var(--elevation-dark-5-ambient), var(--elevation-dark-5-umbra);
}
}

271
src/variables.css Normal file
View File

@ -0,0 +1,271 @@
@layer theme {
:root {
--font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
--color-white: #ffffff;
--color-black: #000000;
--color-light-primary: #744c1f;
--color-light-on-primary: #fff5c0;
--color-light-primary-container: #95693b;
--color-light-on-primary-container: #fff5c0;
--color-light-primary-fixed: #95693b;
--color-light-primary-fixed-dim: #7a5124;
--color-light-on-primary-fixed: #fff5c0;
--color-light-on-primary-fixed-variant: #fff5c0;
--color-light-inverse-primary: #6f481b;
--color-light-secondary: #67513b;
--color-light-on-secondary: #fffae0;
--color-light-secondary-container: #866e57;
--color-light-on-secondary-container: #fffae0;
--color-light-secondary-fixed: #866e57;
--color-light-secondary-fixed-dim: #6c5540;
--color-light-on-secondary-fixed: #fffae0;
--color-light-on-secondary-fixed-variant: #fffae0;
--color-light-inverse-secondary: #dec1a8;
--color-light-tertiary: #5c561a;
--color-light-on-tertiary: #ffffbb;
--color-light-tertiary-container: #7b7436;
--color-light-on-tertiary-container: #ffffbb;
--color-light-tertiary-fixed: #7b7436;
--color-light-tertiary-fixed-dim: #615b1f;
--color-light-on-tertiary-fixed: #ffffbb;
--color-light-on-tertiary-fixed-variant: #ffffbb;
--color-light-inverse-tertiary: #d3c886;
--color-light-error: #b20000;
--color-light-on-error: #ffc89b;
--color-light-error-container: #d92f18;
--color-light-on-error-container: #ffc89b;
--color-light-error-fixed: #ffab80;
--color-light-error-fixed-dim: #ff8e66;
--color-light-on-error-fixed: #640000;
--color-light-on-error-fixed-variant: #9f0000;
--color-light-inverse-error: #ff8e66;
--color-light-surface: #fff8f1;
--color-light-surface-dim: #bfb7b1;
--color-light-surface-bright: #fff8f1;
--color-light-surface-variant: #efe0d4;
--color-light-surface-container: #e9e1db;
--color-light-surface-container-lowest: #fffef7;
--color-light-surface-container-low: #f7efe9;
--color-light-surface-container-high: #dbd3cd;
--color-light-surface-container-highest: #cdc5bf;
--color-light-on-surface: #090000;
--color-light-on-surface-variant: #41362d;
--color-light-inverse-surface: #f7efe9;
--color-light-inverse-on-surface: #090000;
--color-light-outline: #5f5349;
--color-light-outline-variant: #7d7065;
--color-light-scrim: #090000;
--color-light-shadow: #090000;
--color-light-success: #1c6300;
--color-light-on-success: #d6ff6a;
--color-light-success-container: #3f8200;
--color-light-on-success-container: #d6ff6a;
--color-light-success-fixed: #3f8200;
--color-light-success-fixed-dim: #226800;
--color-light-on-success-fixed: #d6ff6a;
--color-light-on-success-fixed-variant: #d6ff6a;
--color-light-inverse-success: #053f00;
--color-light-defensive: #7131b4;
--color-light-on-defensive: #ffdeff;
--color-light-defensive-container: #9350d6;
--color-light-on-defensive-container: #ffdeff;
--color-light-defensive-fixed: #9350d6;
--color-light-defensive-fixed-dim: #7737ba;
--color-light-on-defensive-fixed: #ffdeff;
--color-light-on-defensive-fixed-variant: #ffdeff;
--color-light-inverse-defensive: #47038c;
--color-light-gentle: #7c4b00;
--color-light-on-gentle: #fff25f;
--color-light-gentle-container: #9e6700;
--color-light-on-gentle-container: #fff25f;
--color-light-gentle-fixed: #9e6700;
--color-light-gentle-fixed-dim: #814f00;
--color-light-on-gentle-fixed: #fff25f;
--color-light-on-gentle-fixed-variant: #fff25f;
--color-light-inverse-gentle: #562900;
--color-light-aggressive: #b6002d;
--color-light-on-aggressive: #ffc3cc;
--color-light-aggressive-container: #dc2247;
--color-light-on-aggressive-container: #ffc3cc;
--color-light-aggressive-fixed: #dc2247;
--color-light-aggressive-fixed-dim: #bd0031;
--color-light-on-aggressive-fixed: #ffc3cc;
--color-light-on-aggressive-fixed-variant: #ffc3cc;
--color-light-inverse-aggressive: #88000e;
--color-light-info: #225694;
--color-light-on-info: #ddffff;
--color-light-info-container: #4973b4;
--color-light-on-info-container: #ddffff;
--color-light-info-fixed: #4973b4;
--color-light-info-fixed-dim: #2a5a99;
--color-light-on-info-fixed: #ddffff;
--color-light-on-info-fixed-variant: #ddffff;
--color-light-inverse-info: #00346e;
--color-light-warning: #834600;
--color-light-on-warning: #ffed93;
--color-light-warning-container: #a66204;
--color-light-on-warning-container: #ffed93;
--color-light-warning-fixed: #a66204;
--color-light-warning-fixed-dim: #884a00;
--color-light-on-warning-fixed: #ffed93;
--color-light-on-warning-fixed-variant: #ffed93;
--color-light-inverse-warning: #5b2300;
--color-dark-primary: #ffe6b1;
--color-dark-on-primary: #280000;
--color-dark-primary-container: #ecb986;
--color-dark-on-primary-container: #2b0000;
--color-dark-primary-fixed: #ffd8a5;
--color-dark-primary-fixed-dim: #f1bd8a;
--color-dark-on-primary-fixed: #280000;
--color-dark-on-primary-fixed-variant: #2d0600;
--color-dark-inverse-primary: #e8b482;
--color-dark-secondary: #ffebd1;
--color-dark-on-secondary: #1a0000;
--color-dark-secondary-container: #dabea5;
--color-dark-on-secondary-container: #1f0500;
--color-dark-secondary-fixed: #fbddc4;
--color-dark-secondary-fixed-dim: #dec1a8;
--color-dark-on-secondary-fixed: #1a0000;
--color-dark-on-secondary-fixed-variant: #220c00;
--color-dark-inverse-secondary: #715a44;
--color-dark-tertiary: #fef1ad;
--color-dark-on-tertiary: #150100;
--color-dark-tertiary-container: #cfc482;
--color-dark-on-tertiary-container: #1b0c00;
--color-dark-tertiary-fixed: #f0e4a0;
--color-dark-tertiary-fixed-dim: #d3c886;
--color-dark-on-tertiary-fixed: #150100;
--color-dark-on-tertiary-fixed-variant: #1e1200;
--color-dark-inverse-tertiary: #666023;
--color-dark-error: #ffb88d;
--color-dark-on-error: #4e0000;
--color-dark-error-container: #ff8a63;
--color-dark-on-error-container: #540000;
--color-dark-error-fixed: #ffab80;
--color-dark-error-fixed-dim: #ff8e66;
--color-dark-on-error-fixed: #640000;
--color-dark-on-error-fixed-variant: #9f0000;
--color-dark-inverse-error: #bf0902;
--color-dark-surface: #18120c;
--color-dark-surface-dim: #18120c;
--color-dark-surface-bright: #554f4a;
--color-dark-surface-variant: #50453b;
--color-dark-surface-container: #352f2a;
--color-dark-surface-container-lowest: #090000;
--color-dark-surface-container-low: #241f1a;
--color-dark-surface-container-high: #403a35;
--color-dark-surface-container-highest: #4c4640;
--color-dark-on-surface: #fffef7;
--color-dark-on-surface-variant: #fffdf0;
--color-dark-inverse-surface: #352f2a;
--color-dark-inverse-on-surface: #fffef7;
--color-dark-outline: #fcede1;
--color-dark-outline-variant: #cec0b4;
--color-dark-scrim: #090000;
--color-dark-shadow: #090000;
--color-dark-defensive: #ffcfff;
--color-dark-on-defensive: #050055;
--color-dark-defensive-container: #eca2ff;
--color-dark-on-defensive-container: #0d005d;
--color-dark-defensive-fixed: #ffc1ff;
--color-dark-defensive-fixed-dim: #f0a5ff;
--color-dark-on-defensive-fixed: #050055;
--color-dark-on-defensive-fixed-variant: #140062;
--color-dark-inverse-defensive: #ffcbff;
--color-dark-info: #cef0ff;
--color-dark-on-info: #00013a;
--color-dark-info-container: #9fc3ff;
--color-dark-on-info-container: #000d41;
--color-dark-info-fixed: #c0e2ff;
--color-dark-info-fixed-dim: #a3c6ff;
--color-dark-on-info-fixed: #00013a;
--color-dark-on-info-fixed-variant: #001346;
--color-dark-inverse-info: #caecff;
--color-dark-gentle: #ffe350;
--color-dark-on-gentle: #370000;
--color-dark-gentle-container: #fcb61a;
--color-dark-on-gentle-container: #390000;
--color-dark-gentle-fixed: #ffd642;
--color-dark-gentle-fixed-dim: #ffba20;
--color-dark-on-gentle-fixed: #370000;
--color-dark-on-gentle-fixed-variant: #3b0000;
--color-dark-inverse-gentle: #ffdf4c;
--color-dark-success: #c7ff5b;
--color-dark-on-success: #001600;
--color-dark-success-container: #97d529;
--color-dark-on-success-container: #001c00;
--color-dark-success-fixed: #b9f54d;
--color-dark-success-fixed-dim: #9cd92e;
--color-dark-on-success-fixed: #001600;
--color-dark-on-success-fixed-variant: #001f00;
--color-dark-inverse-success: #c2ff57;
--color-dark-warning: #ffde85;
--color-dark-on-warning: #350000;
--color-dark-warning-container: #ffb15b;
--color-dark-on-warning-container: #380000;
--color-dark-warning-fixed: #ffd179;
--color-dark-warning-fixed-dim: #ffb55e;
--color-dark-on-warning-fixed: #350000;
--color-dark-on-warning-fixed-variant: #3a0000;
--color-dark-inverse-warning: #ffda82;
--color-dark-aggressive: #ffb3bd;
--color-dark-on-aggressive: #4d0000;
--color-dark-aggressive-container: #ff8492;
--color-dark-on-aggressive-container: #540000;
--color-dark-aggressive-fixed: #ffa6b0;
--color-dark-aggressive-fixed-dim: #ff8896;
--color-dark-on-aggressive-fixed: #4d0000;
--color-dark-on-aggressive-fixed-variant: #590000;
--color-dark-inverse-aggressive: #ffafba;
--spacing: 4px;
--border-radius: 2px;
--font-size: 10px;
--line-height: 1.2em;
--elevation-light-0: none;
--elevation-light-1-ambient: 0 1px 3px 1px
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
--elevation-light-1-umbra: 0 1px 2px 0px
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
--elevation-light-2-ambient: 0 2px 6px 2px
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
--elevation-light-2-umbra: 0 1px 2px 0px
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
--elevation-light-3-ambient: 0 4px 8px 3px
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
--elevation-light-3-umbra: 0 1px 3px 0px
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
--elevation-light-4-ambient: 0 6px 10px 4px
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
--elevation-light-4-umbra: 0 2px 3px 0px
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
--elevation-light-5-ambient: 0 8px 12px 6px
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
--elevation-light-5-umbra: 0 4px 4px 0px
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
--elevation-dark-0: none;
--elevation-dark-1-ambient: 0 1px 3px 1px
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
--elevation-dark-1-umbra: 0 1px 2px 0px
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
--elevation-dark-2-ambient: 0 2px 6px 2px
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
--elevation-dark-2-umbra: 0 1px 2px 0px
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
--elevation-dark-3-ambient: 0 4px 8px 3px
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
--elevation-dark-3-umbra: 0 1px 3px 0px
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
--elevation-dark-4-ambient: 0 6px 10px 4px
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
--elevation-dark-4-umbra: 0 2px 3px 0px
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
--elevation-dark-5-ambient: 0 8px 12px 6px
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
--elevation-dark-5-umbra: 0 4px 4px 0px
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

38
vite.config.ts Normal file
View File

@ -0,0 +1,38 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
css: {
transformer: 'lightningcss',
lightningcss: {
cssModules: true,
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: 'ws',
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ['**/src-tauri/**'],
},
},
}));