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

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" />