Browse Source

Refactor codebase

Better implement some parts into their separate functions.
master
TheoryOfNekomata 7 months ago
parent
commit
5fbd6ec20b
11 changed files with 196 additions and 171 deletions
  1. +10
    -14
      src/common/config.ts
  2. +114
    -140
      src/main/index.ts
  3. +22
    -0
      src/main/utils/display.ts
  4. +5
    -2
      src/renderer/index.html
  5. +1
    -1
      src/renderer/src/components/Activity/index.tsx
  6. +6
    -2
      src/renderer/src/components/DropdownInput/index.tsx
  7. +0
    -0
      src/renderer/src/components/MidiDeviceProvider/index.tsx
  8. +9
    -5
      src/renderer/src/components/SettingsForm/index.tsx
  9. +5
    -1
      src/renderer/src/hooks/form.ts
  10. +16
    -2
      src/renderer/src/pages/index.tsx
  11. +8
    -4
      src/renderer/src/pages/settings.tsx

+ 10
- 14
src/common/config.ts View File

@@ -1,13 +1,13 @@
export const RANGES = { export const RANGES = {
'C3-C5': '48|72',
'C3-C6': '48|84',
'C2-C6': '36|84',
'C2-C7': '36|96',
'E1-E7': '28|100',
'E1-G7': '28|103',
'A0-C8': '21|108',
'C0-B9': '12|119',
'Full MIDI': '0|127'
'C3-C5 (2 octaves)': '48|72',
'C3-C6 (3 octaves)': '48|84',
'C2-C6 (4 octaves)': '36|84',
'C2-C7 (5 octaves)': '36|96',
'E1-E7 (73 keys)': '28|100',
'E1-G7 (76 keys)': '28|103',
'A0-C8 (88 keys)': '21|108',
'C0-B9 (108 keys)': '12|119',
'Full MIDI (128 keys)': '0|127'
} }


export const SCALE_FACTORS = { export const SCALE_FACTORS = {
@@ -15,11 +15,7 @@ export const SCALE_FACTORS = {
'2x': '2' '2x': '2'
} }


export const NATURAL_KEY_WIDTHS = [
16,
18,
20
]
export const NATURAL_KEY_WIDTHS = [16, 18, 20]


export interface Config { export interface Config {
range: string range: string


+ 114
- 140
src/main/index.ts View File

@@ -1,38 +1,71 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { app, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path' import { join } from 'path'
import { readFile, stat, writeFile } from 'fs/promises' import { readFile, stat, writeFile } from 'fs/promises'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
import { Config, defaultConfig } from '../common/config' import { Config, defaultConfig } from '../common/config'
import { getNaturalKeys } from './utils/display'
import { Stats } from 'fs'


const configPath = './config.json' const configPath = './config.json'
const height = 160 const height = 160


const getNaturalKeys = (range: string): number => {
const [startKeyRaw, endKeyRaw] = range.split('|')
const startKey = Number(startKeyRaw)
const endKey = Number(endKeyRaw)
let naturalKeys = 0
for (let i = startKey; i <= endKey; i += 1) {
switch (i % 12) {
case 0:
case 2:
case 4:
case 5:
case 7:
case 9:
case 11:
naturalKeys += 1
break
default:
break
const ALLOWED_PERMISSIONS = ['midi']
const defineWindowCommonSecurity = (browserWindow: BrowserWindow): void => {
browserWindow.webContents.setWindowOpenHandler(() => {
return { action: 'deny' }
})
}
const addWindowEvents = (browserWindow: BrowserWindow): void => {
browserWindow.on('ready-to-show', () => {
browserWindow.show()
})
}
const addMidiPermissions = (browserWindow: BrowserWindow): void => {
browserWindow.webContents.session.setPermissionRequestHandler(
(_webContents, permission, callback) => {
callback(ALLOWED_PERMISSIONS.includes(permission))
} }
)

browserWindow.webContents.session.setPermissionCheckHandler((_webContents, permission) => {
return ALLOWED_PERMISSIONS.includes(permission)
})
}

const assignWindowView = async (
browserWindow: BrowserWindow,
routeName: string,
config: Config
): Promise<BrowserWindow> => {
let search = new URLSearchParams()
let url: URL | undefined
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
url = new URL(process.env['ELECTRON_RENDERER_URL'])
search = new URLSearchParams(url.searchParams)
}

Object.entries(config).forEach(([key, value]) => {
search.set(key, value)
})
search.set('window', routeName)

if (typeof url !== 'undefined') {
url.search = search.toString()
await browserWindow.loadURL(url.toString())
return browserWindow
} }
return naturalKeys

await browserWindow.loadFile(join(__dirname, '../renderer/index.html'), {
search: search.toString()
})
return browserWindow
} }


const createMainWindow = async (config: Config): Promise<BrowserWindow> => { const createMainWindow = async (config: Config): Promise<BrowserWindow> => {
// Create the browser window.
const naturalKeys = getNaturalKeys(config.range) const naturalKeys = getNaturalKeys(config.range)
const width = naturalKeys * Number(config.naturalKeyWidth || 20) + Number(config.scaleFactor) const width = naturalKeys * Number(config.naturalKeyWidth || 20) + Number(config.scaleFactor)
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
@@ -46,6 +79,7 @@ const createMainWindow = async (config: Config): Promise<BrowserWindow> => {
sandbox: false, sandbox: false,
devTools: is.dev devTools: is.dev
}, },
title: 'Piano MIDI Monitor',
maximizable: is.dev, maximizable: is.dev,
minimizable: false, minimizable: false,
maxHeight: is.dev ? undefined : height, maxHeight: is.dev ? undefined : height,
@@ -56,58 +90,57 @@ const createMainWindow = async (config: Config): Promise<BrowserWindow> => {
resizable: is.dev, resizable: is.dev,
useContentSize: true useContentSize: true
}) })
defineWindowCommonSecurity(mainWindow)
addMidiPermissions(mainWindow)
addWindowEvents(mainWindow)
return assignWindowView(mainWindow, 'main', config)
}


mainWindow.on('ready-to-show', () => {
mainWindow.show()
})

mainWindow.webContents.setWindowOpenHandler((details) => {
void shell.openExternal(details.url)
return { action: 'deny' }
const createSettingsWindow = async (
parent: BrowserWindow,
config: Config
): Promise<BrowserWindow> => {
const settingsWindow = new BrowserWindow({
width: 360,
height: 640,
show: false,
autoHideMenuBar: true,
modal: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
devTools: is.dev
},
title: 'Settings',
maximizable: false,
minimizable: false,
fullscreenable: false,
resizable: false,
useContentSize: true,
parent
}) })
defineWindowCommonSecurity(settingsWindow)
addMidiPermissions(settingsWindow)
addWindowEvents(settingsWindow)
return assignWindowView(settingsWindow, 'settings', config)
}


mainWindow.webContents.session.setPermissionRequestHandler(
(_webContents, permission, callback) => {
if (permission === 'midi') {
callback(true)
return
}

callback(false)
}
)

let search: URLSearchParams
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
const url = new URL(process.env['ELECTRON_RENDERER_URL'])
search = new URLSearchParams(url.searchParams)
Object.entries(config).forEach(([key, value]) => {
search.set(key, value)
})
search.set('window', 'main')
url.search = search.toString()
await mainWindow.loadURL(url.toString())
return mainWindow
const ensureConfig = async (defaultConfig: Config): Promise<Config> => {
const effectiveConfig = { ...defaultConfig }
let theStat: Stats
try {
theStat = await stat(configPath)
} catch {
await writeFile(configPath, JSON.stringify(defaultConfig))
return effectiveConfig
} }


search = new URLSearchParams()
Object.entries(config).forEach(([key, value]) => {
search.set(key, value)
})
search.set('window', 'main')
await mainWindow.loadFile(join(__dirname, '../renderer/index.html'), {
search: search.toString()
})
return mainWindow
}
if (theStat.isDirectory()) {
throw new Error('Config path is a directory.')
}


app.whenReady().then(async () => {
const effectiveConfig = { ...defaultConfig }
try { try {
const theStat = await stat(configPath)
if (theStat.isDirectory()) {
return
}
const jsonRaw = await readFile(configPath, 'utf-8') const jsonRaw = await readFile(configPath, 'utf-8')
const json = JSON.parse(jsonRaw) const json = JSON.parse(jsonRaw)
Object.entries(json).forEach(([key, value]) => { Object.entries(json).forEach(([key, value]) => {
@@ -117,79 +150,24 @@ app.whenReady().then(async () => {
await writeFile(configPath, JSON.stringify(defaultConfig)) await writeFile(configPath, JSON.stringify(defaultConfig))
} }


electronApp.setAppUserModelId('sh.modal.pianomidimonitor')

app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})

const createSettingsWindow = async (
parent: BrowserWindow,
config: Config
): Promise<BrowserWindow> => {
const settingsWindow = new BrowserWindow({
width: 360,
height: 640,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
devTools: is.dev
},
maximizable: false,
minimizable: false,
fullscreenable: false,
resizable: false,
useContentSize: true,
parent
})

settingsWindow.on('ready-to-show', () => {
settingsWindow.show()
})

settingsWindow.webContents.setWindowOpenHandler((details) => {
void shell.openExternal(details.url)
return { action: 'deny' }
})
return effectiveConfig
}


settingsWindow.webContents.session.setPermissionRequestHandler(
(_webContents, permission, callback) => {
if (permission === 'midi') {
callback(true)
return
}
const main = async (): Promise<void> => {
const effectiveConfig = await ensureConfig(defaultConfig)


callback(false)
}
)

let search: URLSearchParams
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
const url = new URL(process.env['ELECTRON_RENDERER_URL'])
search = new URLSearchParams(url.searchParams)
Object.entries(config).forEach(([key, value]) => {
search.set(key, value)
})
search.set('window', 'settings')
url.search = search.toString()
await settingsWindow.loadURL(url.toString())
return settingsWindow
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
} }
})


search = new URLSearchParams()
Object.entries(config).forEach(([key, value]) => {
search.set(key, value)
})
search.set('window', 'settings')
await settingsWindow.loadFile(join(__dirname, '../renderer/index.html'), {
search: search.toString()
})
await app.whenReady()
electronApp.setAppUserModelId('sh.modal.pianomidimonitor')


return settingsWindow
}
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})


ipcMain.on('action', async (event, value, formData) => { ipcMain.on('action', async (event, value, formData) => {
const webContents = event.sender const webContents = event.sender
@@ -236,10 +214,6 @@ app.whenReady().then(async () => {
await createMainWindow(effectiveConfig) await createMainWindow(effectiveConfig)
} }
}) })
})
}


app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
void main()

+ 22
- 0
src/main/utils/display.ts View File

@@ -0,0 +1,22 @@
export const getNaturalKeys = (range: string): number => {
const [startKeyRaw, endKeyRaw] = range.split('|')
const startKey = Number(startKeyRaw)
const endKey = Number(endKeyRaw)
let naturalKeys = 0
for (let i = startKey; i <= endKey; i += 1) {
switch (i % 12) {
case 0:
case 2:
case 4:
case 5:
case 7:
case 9:
case 11:
naturalKeys += 1
break
default:
break
}
}
return naturalKeys
}

+ 5
- 2
src/renderer/index.html View File

@@ -2,12 +2,15 @@
<html lang="en-PH"> <html lang="en-PH">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Piano MIDI Monitor</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<title></title>
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/> />
<meta
http-equiv="Permissions-Policy"
content="midi=self"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>


+ 1
- 1
src/renderer/src/components/Activity/index.tsx View File

@@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import {DeviceChannelActive} from '../../hooks/midi';
import { DeviceChannelActive } from '../../hooks/midi'


export interface ActivityProps { export interface ActivityProps {
device?: MIDIInput device?: MIDIInput


+ 6
- 2
src/renderer/src/components/DropdownInput/index.tsx View File

@@ -8,10 +8,14 @@ export const DropdownInput = React.forwardRef<HTMLElementTagNameMap['select'], D
({ label, ...etcProps }, forwardedRef) => { ({ label, ...etcProps }, forwardedRef) => {
return ( return (
<label className="block border rounded overflow-hidden box-border"> <label className="block border rounded overflow-hidden box-border">
<span className="flex items-center h-3 text-xs -mb-3 relative pointer-events-none pt-1 px-1">
<span className="flex items-center h-3 text-xs -mb-3 relative pointer-events-none pt-1 px-1 font-bold">
{label} {label}
</span> </span>
<select {...etcProps} ref={forwardedRef} className="w-full bg-black h-10 appearance-none pt-2 px-1" />
<select
{...etcProps}
ref={forwardedRef}
className="w-full bg-black h-10 appearance-none pt-2 px-1"
/>
</label> </label>
) )
} }


+ 0
- 0
src/renderer/src/components/MidiDeviceProvider/index.tsx View File


+ 9
- 5
src/renderer/src/components/SettingsForm/index.tsx View File

@@ -22,6 +22,8 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
...etcProps ...etcProps
}) => { }) => {
const inputs = typeof midiAccess !== 'undefined' ? Array.from(midiAccess.inputs) : undefined const inputs = typeof midiAccess !== 'undefined' ? Array.from(midiAccess.inputs) : undefined
const naturalKeyColorId = React.useId()
const accidentalKeyColorId = React.useId()


return ( return (
<form {...etcProps}> <form {...etcProps}>
@@ -89,9 +91,10 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
</DropdownInput> </DropdownInput>
</div> </div>
<div className="flex justify-between items-center gap-8"> <div className="flex justify-between items-center gap-8">
<div>Natural key color</div>
<label htmlFor={naturalKeyColorId}>Natural key color</label>
<div> <div>
<input <input
id={naturalKeyColorId}
name="naturalKeyColor" name="naturalKeyColor"
type="color" type="color"
className="h-10" className="h-10"
@@ -100,9 +103,10 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
</div> </div>
</div> </div>
<div className="flex justify-between items-center gap-8"> <div className="flex justify-between items-center gap-8">
<div>Accidental key color</div>
<label htmlFor={accidentalKeyColorId}>Accidental key color</label>
<div> <div>
<input <input
id={accidentalKeyColorId}
name="accidentalKeyColor" name="accidentalKeyColor"
type="color" type="color"
className="h-10" className="h-10"
@@ -113,7 +117,7 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
<div className="mt-12 flex gap-4"> <div className="mt-12 flex gap-4">
<div className="w-0 flex-auto"> <div className="w-0 flex-auto">
<button <button
className="w-full h-10 rounded border overflow-hidden"
className="w-full h-10 rounded border overflow-hidden font-bold"
type="submit" type="submit"
name="action" name="action"
value="cancelSaveConfig" value="cancelSaveConfig"
@@ -123,7 +127,7 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
</div> </div>
<div className="w-0 flex-auto"> <div className="w-0 flex-auto">
<button <button
className="w-full h-10 rounded border overflow-hidden"
className="w-full h-10 rounded border overflow-hidden font-bold"
type="submit" type="submit"
name="action" name="action"
value="resetConfig" value="resetConfig"
@@ -134,7 +138,7 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
</div> </div>
<div> <div>
<button <button
className="w-full h-10 rounded border bg-white text-black overflow-hidden"
className="w-full h-10 rounded border bg-white text-black overflow-hidden font-bold"
type="submit" type="submit"
name="action" name="action"
value="saveConfig" value="saveConfig"


+ 5
- 1
src/renderer/src/hooks/form.ts View File

@@ -1,6 +1,10 @@
import * as React from 'react' import * as React from 'react'


export const useForm = () => {
export interface UseFormReturn {
handleAction: React.FormEventHandler<HTMLElementTagNameMap['form']>
}

export const useForm = (): UseFormReturn => {
const handleAction: React.FormEventHandler<HTMLElementTagNameMap['form']> = (e) => { const handleAction: React.FormEventHandler<HTMLElementTagNameMap['form']> = (e) => {
e.preventDefault() e.preventDefault()
const { submitter } = e.nativeEvent as unknown as { submitter: HTMLElementTagNameMap['button'] } const { submitter } = e.nativeEvent as unknown as { submitter: HTMLElementTagNameMap['button'] }


+ 16
- 2
src/renderer/src/pages/index.tsx View File

@@ -69,9 +69,23 @@ const IndexPage: React.FC<IndexPageProps> = ({ params: search }) => {
type="submit" type="submit"
name="action" name="action"
value="showSettings" value="showSettings"
className="h-10 border rounded overflow-hidden px-4"
className="h-10 border rounded overflow-hidden px-2 sm:px-4"
> >
Settings
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="inline-block align-middle"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>{' '}
<span className="sr-only sm:not-sr-only font-bold">Settings</span>
</button> </button>
</form> </form>
</div> </div>


+ 8
- 4
src/renderer/src/pages/settings.tsx View File

@@ -1,6 +1,12 @@
import * as React from 'react' import * as React from 'react'
import { SettingsForm } from '../components/SettingsForm' import { SettingsForm } from '../components/SettingsForm'
import {Config, defaultConfig, NATURAL_KEY_WIDTHS, RANGES, SCALE_FACTORS} from '../../../common/config'
import {
Config,
defaultConfig,
NATURAL_KEY_WIDTHS,
RANGES,
SCALE_FACTORS
} from '../../../common/config'
import { useMidi } from '../hooks/midi' import { useMidi } from '../hooks/midi'
import { useForm } from '../hooks/form' import { useForm } from '../hooks/form'


@@ -23,9 +29,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({ params }) => {
return ( return (
<main className="py-16 select-none"> <main className="py-16 select-none">
<div className="px-4 mx-auto max-w-screen-sm"> <div className="px-4 mx-auto max-w-screen-sm">
<h1 className="text-5xl mb-8">
Settings
</h1>
<h1 className="text-5xl mb-8">Settings</h1>
<SettingsForm <SettingsForm
lastStateChangeTimestamp={lastStateChangeTimestamp} lastStateChangeTimestamp={lastStateChangeTimestamp}
midiAccess={midiAccess} midiAccess={midiAccess}


Loading…
Cancel
Save