Port from CRT to Vite

This commit is contained in:
Robin E. R. Davies
2025-02-19 08:58:15 -05:00
parent 830352ff69
commit d78d78026c
313 changed files with 10653 additions and 5396 deletions
+240
View File
@@ -0,0 +1,240 @@
import React, { SyntheticEvent, Component } from 'react';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import { PiPedalModel, PiPedalModelFactory, State } from './PiPedalModel';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Divider from '@mui/material/Divider';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import JackHostStatus from './JackHostStatus';
import { PiPedalError } from './PiPedalError';
import DialogEx from './DialogEx';
import Slide, { SlideProps } from '@mui/material/Slide';
interface AboutDialogProps {
open: boolean;
onClose: () => void;
};
interface AboutDialogState {
jackStatus?: JackHostStatus;
openSourceNotices: string;
};
const Transition = React.forwardRef(function Transition(
props: SlideProps, ref: React.Ref<unknown>
) {
return (<Slide direction="up" ref={ref} {...props} />);
});
const AboutDialog = class extends Component<AboutDialogProps, AboutDialogState> {
model: PiPedalModel;
refNotices: React.RefObject<HTMLDivElement | null >;
constructor(props: AboutDialogProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.refNotices = React.createRef();
this.handleDialogClose = this.handleDialogClose.bind(this);
this.state = {
jackStatus: undefined,
openSourceNotices: ""
};
}
mounted: boolean = false;
subscribed: boolean = false;
tick() {
if (this.model.state.get() === State.Ready) {
this.model.getJackStatus()
.then(jackStatus => {
this.setState({ jackStatus: jackStatus });
})
.catch(_error => { /* ignore*/ });
}
}
timerHandle?: number;
updateNotifications() {
let subscribed = this.mounted && this.props.open;
if (subscribed !== this.subscribed) {
if (subscribed) {
this.timerHandle = setInterval(() => this.tick(), 1000);
} else {
if (this.timerHandle) {
clearInterval(this.timerHandle);
this.timerHandle = undefined;
}
}
this.subscribed = subscribed;
}
}
startFossRequest() {
if (this.state.openSourceNotices === "") {
fetch("var/notices.txt")
.then((request) => {
if (!request.ok) {
throw new PiPedalError("notices request failed.");
}
return request.text();
})
.then((text) => {
if (this.mounted) {
this.setState({ openSourceNotices: text });
}
})
.catch((err) => {
// ok in debug builds. File doesn't get placed until install time.
console.log("Failed to fetch open-source notices. " + err.toString());
});
}
}
componentDidMount() {
super.componentDidMount?.();
this.mounted = true;
this.updateNotifications();
this.startFossRequest();
}
componentWillUnmount() {
super.componentWillUnmount?.();
this.mounted = false;
this.updateNotifications();
}
componentDidUpdate(prevProps: Readonly<AboutDialogProps>, prevState: Readonly<AboutDialogState>, snapshot: any): void {
super.componentDidUpdate?.(prevProps, prevState, snapshot);
this.updateNotifications();
}
handleDialogClose(_e: SyntheticEvent) {
this.props.onClose();
}
render() {
let addressKey = 0;
let serverVersion = this.model.serverVersion?.serverVersion ?? "";
let nPos = serverVersion.indexOf(' ');
if (nPos !== -1) {
serverVersion = serverVersion.substring(nPos + 1);
}
return (
<DialogEx tag="about" fullScreen open={this.props.open}
onClose={() => { this.props.onClose() }} TransitionComponent={Transition}
onEnterKey={() => { this.props.onClose() }}
style={{ userSelect: "none" }}
>
<div style={{ display: "flex", flexDirection: "column", flexWrap: "nowrap", width: "100%", height: "100%", overflow: "hidden" }}>
<div style={{ flex: "0 0 auto" }}>
<AppBar style={{
position: 'relative',
top: 0, left: 0
}} >
<Toolbar>
<IconButton edge="start" color="inherit" onClick={this.handleDialogClose} aria-label="back"
>
<ArrowBackIcon />
</IconButton>
<Typography noWrap variant="h6"
sx={{ maringLeft: 2, flex: 1 }}
>
About
</Typography>
</Toolbar>
</AppBar>
</div>
<div style={{
flex: "1 1 auto", position: "relative", overflow: "hidden",
overflowX: "hidden", overflowY: "auto", userSelect: "text"
}}
>
<div id="debug_info" style={{
userSelect: "text",
cursor: "text",margin: 24
}}>
<div style={{ display: "flex", flexFlow: "row nowrap" }}>
<Typography noWrap display="block" variant="h6" color="textPrimary" style={{ flexGrow: 1, flexShrink: 1 }}>
PiPedal <span style={{ fontSize: "0.7em" }}>
{serverVersion
+ (this.model.serverVersion?.debug ? " (Debug)" : "")}
</span>
</Typography>
</div>
<Typography noWrap display="block" variant="body2" style={{ marginBottom: 12 }} >
Copyright &#169; 2022-2024 Robin Davies.
</Typography>
{this.model.isAndroidHosted() && (
<Typography noWrap display="block" variant="body2" style={{ marginBottom: 0 }} >
{this.model.getAndroidHostVersion()}
</Typography>
)}
{this.model.isAndroidHosted() && (
<Typography noWrap display="block" variant="body2" style={{ marginBottom: 12 }} >
Copyright &#169; 2022-2024 Robin Davies.
</Typography>
)}
<Divider />
<Typography noWrap display="block" variant="caption" >
ADDRESSES
</Typography>
<div style={{ marginBottom: 16 }}>
{
this.model.serverVersion?.webAddresses.map((address) =>
(
<Typography key={addressKey++} display="block" variant="body2" style={{ marginBottom: 0, marginLeft: 24 }} >
{address}
</Typography>
))
}
</div>
<Divider />
<Typography noWrap display="block" variant="caption" >
SERVER OS
</Typography>
<div style={{ marginBottom: 16 }}>
<Typography display="block" variant="body2" style={{ marginBottom: 0, marginLeft: 24 }} >
{this.model.serverVersion?.osVersion ?? ""}
</Typography>
</div>
</div><div>
<Divider />
<Typography display="block" variant="caption" >
LEGAL NOTICES
</Typography>
<div dangerouslySetInnerHTML={{ __html: this.state.openSourceNotices }} style={{ fontSize: "0.8em", maxWidth: 400 }}>
</div>
</div>
</div>
</div>
</DialogEx >
);
}
};
export default AboutDialog;
+66
View File
@@ -0,0 +1,66 @@
export default class AlsaDeviceInfo {
deserialize(input: any): AlsaDeviceInfo {
this.cardId = input.cardId;
this.id = input.id;
this.name = input.name;
this.longName = input.longName;
this.sampleRates = input.sampleRates as number[];
this.minBufferSize = input.minBufferSize;
this.maxBufferSize = input.maxBufferSize;
return this;
}
static deserialize_array(input: any): AlsaDeviceInfo[]
{
let result: AlsaDeviceInfo[] = [];
for (let i = 0; i < input.length; ++i)
{
result.push(new AlsaDeviceInfo().deserialize(input[i]));
}
return result;
}
closestSampleRate(sr: number): number
{
let result = 48000;
let bestError = 1E36;
for (let rate of this.sampleRates)
{
let error = (sr-rate)*(sr-rate);
if (error < bestError)
{
bestError = error;
result = rate;
}
}
return result;
}
closestBufferSize(buffSize: number): number
{
let result = 64;
let bestError = 1E36;
for (let sz = 2; sz <= this.maxBufferSize; sz *= 2)
{
if (sz >= this.minBufferSize)
{
let error = (sz-buffSize)*(sz-buffSize);
if (error < bestError)
{
bestError = error;
result = sz;
}
}
}
return result;
}
cardId: number = -1;
id: string = "";
name: string = "";
longName: string = "";
sampleRates: number[] = [];
minBufferSize: number = 0;
maxBufferSize: number = 0;
};
+45
View File
@@ -0,0 +1,45 @@
/*
* MIT License
*
* Copyright (c) 2022 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export class AlsaMidiDeviceInfo {
deserialize(input: any) : AlsaMidiDeviceInfo{
this.name = input.name;
this.description = input.description;
return this;
}
static deserializeArray(input: any[]): AlsaMidiDeviceInfo[] {
let result: AlsaMidiDeviceInfo[] = [];
for (let i = 0; i < input.length; ++i) {
result[i] = new AlsaMidiDeviceInfo().deserialize(input[i]);
}
return result;
}
name: string = "";
description: string = "";
equals(other: AlsaMidiDeviceInfo) : boolean {
return this.name === other.name && this.description === other.description;
}
}
+74
View File
@@ -0,0 +1,74 @@
/*
* MIT License
*
* Copyright (c) 2022 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export interface AndroidHostInterface {
showSponsorship() : void;
isAndroidHosted(): boolean;
getHostVersion() : string;
chooseNewDevice() : void;
setDisconnected(isDisconnected: boolean): void;
launchExternalUrl(url:string): boolean;
setThemePreference(theme: number): void;
getThemePreference(): number;
isDarkTheme?: ()=> boolean;
};
export class FakeAndroidHost implements AndroidHostInterface
{
isAndroidHosted(): boolean {
return true;
}
getHostVersion(): string {
return "Fake Android 1.0";
}
chooseNewDevice(): void {
}
isDarkTheme(): boolean {
return true;
}
setDisconnected(_isDisconnected: boolean): void {
}
showSponsorship() : void { }
launchExternalUrl(_url:string): boolean
{
return false;
}
private theme = 1;
setThemePreference(theme: number): void{
this.theme = theme;
}
getThemePreference(): number {
return this.theme;
}
}
export function isAndroidHosted(): boolean {
return ((window as any).AndroidHost as AndroidHostInterface) !== undefined;
}
export function getAndroidHost(): AndroidHostInterface | undefined {
return ((window as any).AndroidHost as AndroidHostInterface);
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { ThemeProvider, createTheme, StyledEngineProvider } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import VirtualKeyboardHandler from './VirtualKeyboardHandler';
import AppThemed from "./AppThemed";
import { isDarkMode } from './DarkMode';
declare module '@mui/material/styles' {
interface Theme {
mainBackground: React.CSSProperties['color'];
toolbarColor: React.CSSProperties['color'];
}
interface ThemeOptions {
mainBackground?: React.CSSProperties['color'];
toolbarColor?: React.CSSProperties['color'];
}
}
declare module '@mui/material/Button' {
interface ButtonPropsVariantOverrides {
dialogPrimary: true;
dialogSecondary: true;
}
}
// declare module '@mui/styles/defaultTheme' {
// // eslint-disable-next-line @typescript-eslint/no-empty-interface
// interface DefaultTheme extends Theme { }
// }
const theme = createTheme(
isDarkMode() ?
{
cssVariables: true,
components: {
MuiButton: {
styleOverrides: {
containedPrimary: {
borderRadius: '9999px',
paddingLeft: "16px", paddingRight: "16px",
textTransform: "none"
},
containedSecondary: {
borderRadius: '9999px',
paddingLeft: "16px", paddingRight: "16px",
textTransform: "none"
}
},
variants: [
{
props: { variant: 'dialogPrimary' },
style: {
color: "#FFFFFF"
}
},
{
props: { variant: 'dialogSecondary', },
style: {
color: "rgb(255,255,255,0.7)"
},
},
],
},
},
palette: {
mode: 'dark',
primary: {
main: '#A770E4'// #6750A4" // #5B5690 #60529A #5C5694
},
secondary: {
main: "#FF6060"
}
},
mainBackground: "#222",
toolbarColor: '#222'
}
:
{
cssVariables: true,
components: {
/* make the selection state for MuiListItemButtons a smidgen darker (light theme only) */
MuiListItemButton: {
styleOverrides: {
root: ({ theme }) => ({
'&.Mui-selected': {
backgroundColor: 'rgba(0, 0, 0, 0.2)', // Adjust for desired darkness
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.25)', // Slightly darker on hover
},
},
}),
},
},
MuiButton: {
styleOverrides: {
containedPrimary: {
borderRadius: '9999px',
paddingLeft: "16px", paddingRight: "16px",
textTransform: "none"
},
containedSecondary: {
borderRadius: '9999px',
paddingLeft: "16px", paddingRight: "16px",
textTransform: "none"
}
},
variants: [
{
props: { variant: 'dialogPrimary' },
style: {
color: "rgb(0,0,0,0.87)"
}
},
{
props: { variant: 'dialogSecondary', },
style: {
color: "rgb(0,0,0,0.6)"
},
},
],
},
},
palette: {
primary: {
main: "#6750A4" // #5B5690 #60529A #5C5694
},
secondary: {
main: "#FF6060"
}
},
mainBackground: "#FFFFFF",
toolbarColor: '#FFFFFF'
}
);
type AppThemeProps = {
};
const App = (class extends React.Component {
// Before the component mounts, we initialise our state
constructor(props: AppThemeProps) {
super(props);
this.state = {
};
if (!App.virtualKeyboardHandler)
{
App.virtualKeyboardHandler = new VirtualKeyboardHandler();
}
}
static virtualKeyboardHandler?: VirtualKeyboardHandler;
render() {
return (
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<CssBaseline />
<AppThemed />
</ThemeProvider>
</StyledEngineProvider>
);
}
}
);
export default App;
+19
View File
@@ -0,0 +1,19 @@
.fossCopyrights P {
padding-top: 0px;
padding-bottom: 0px;
margin-top: 0px;
margin-bottom: 0px;
}
.fossCopyrights {
padding-top: 16px;
padding-bottom: 16px;
}
.fossLicense {
padding-left: 36px;
border-bottom: 1px #CCC solid
}
div {
min-width: 0;
}
File diff suppressed because it is too large Load Diff
+581
View File
@@ -0,0 +1,581 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { Component } from 'react';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { BankIndexEntry, BankIndex } from './Banks';
import Button from "@mui/material/Button";
import ButtonBase from "@mui/material/ButtonBase";
import Slide, { SlideProps } from '@mui/material/Slide';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import {createStyles} from './WithStyles';
import { Theme } from '@mui/material/styles';
import { withStyles } from "tss-react/mui";
import WithStyles from './WithStyles';
import DraggableGrid, { ScrollDirection } from './DraggableGrid';
import Fade from '@mui/material/Fade';
import SelectHoverBackground from './SelectHoverBackground';
import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import EditIcon from '@mui/icons-material/Edit';
import RenameDialog from './RenameDialog';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import DialogEx from './DialogEx';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import DownloadIcon from './svg/file_download_black_24dp.svg?react';
import UploadIcon from './svg/file_upload_black_24dp.svg?react';
import BankIcon from './svg/ic_bank.svg?react';
import { css } from '@emotion/react';
//import PublishIcon from '@mui/icons-material/Publish';
// import FileDownloadIcon from '@mui/icons-material/FileDownload';
// import AppsIcon from '@mui/icons-material/Apps';
interface BankDialogProps extends WithStyles<typeof styles> {
show: boolean;
isEditDialog: boolean;
onDialogClose: () => void;
};
interface BankDialogState {
banks: BankIndex;
showActionBar: boolean;
selectedItem: number;
filenameDialogOpen: boolean;
filenameSaveAs: boolean;
moreMenuAnchorEl: HTMLElement | null;
showDeletePrompt: boolean;
};
const styles = (theme: Theme) => createStyles({
listIcon: css({
width: 24, height: 24,
opacity: 0.6, fill: theme.palette.text.primary
}),
dialogAppBar: css({
position: 'relative',
top: 0, left: 0
}),
dialogActionBar: css({
position: 'relative',
top: 0, left: 0,
background: "black"
}),
dialogTitle: css({
marginLeft: theme.spacing(2),
flex: 1,
}),
itemFrame: css({
display: "flex",
flexDirection: "row",
flexWrap: "nowrap",
width: "100%",
height: "56px",
alignItems: "center",
textAlign: "left",
justifyContent: "center",
paddingLeft: 8
}),
iconFrame: css({
flex: "0 0 auto",
}),
itemIcon: css({
width: 24, height: 24, margin: 12, opacity: 0.6
}),
itemLabel: css({
flex: "1 1 1px",
marginLeft: 8
})
});
const Transition = React.forwardRef(function Transition(
props: SlideProps, ref: React.Ref<unknown>
) {
return (<Slide direction="up" ref={ref} {...props} />);
});
const BankDialog = withStyles(
class extends Component<BankDialogProps, BankDialogState> {
model: PiPedalModel;
refUpload: React.RefObject<HTMLInputElement|null>;
constructor(props: BankDialogProps) {
super(props);
this.refUpload = React.createRef();
this.model = PiPedalModelFactory.getInstance();
this.handleDialogClose = this.handleDialogClose.bind(this);
let banks = this.model.banks.get();
this.state = {
banks: banks,
showActionBar: false,
selectedItem: banks.selectedBank,
filenameDialogOpen: false,
filenameSaveAs: false,
moreMenuAnchorEl: null,
showDeletePrompt: false
};
this.handleBanksChanged = this.handleBanksChanged.bind(this);
}
onMoreClick(e: React.SyntheticEvent): void {
this.setState({ moreMenuAnchorEl: e.currentTarget as HTMLElement })
}
handleDownloadBank() {
this.handleMoreClose();
this.model.download("downloadBank", this.state.selectedItem);
}
async uploadFiles(fileList: FileList): Promise<number> {
let uploadAfter = this.state.selectedItem;
try {
for (let i = 0; i < fileList.length; ++i) {
uploadAfter = await this.model.uploadBank(fileList[i], uploadAfter);
}
} catch (error) {
this.model.showAlert(error + "");
};
return uploadAfter;
}
handleUpload(e: any) {
if (!e.target.files) return;
if (e.target.files.length === 0) return;
var fileList = e.target.files;
this.uploadFiles(fileList)
.then((newSelection) => {
e.target.value = ""; // clear the file list so we can get another change notice.
this.setState({ selectedItem: newSelection });
})
.catch(err => {
e.target.value = ""; // clear the file list so we can get another change notice.
this.model.showAlert(err.toString());
});
}
handleUploadBank() {
if (this.refUpload.current) {
this.refUpload.current.click();
}
this.handleMoreClose();
}
handleMoreClose(): void {
this.setState({ moreMenuAnchorEl: null });
}
selectItemAtIndex(index: number) {
let instanceId = this.state.banks.entries[index].instanceId;
this.setState({ selectedItem: instanceId });
}
isEditMode() {
return this.state.showActionBar || this.props.isEditDialog;
}
handleBanksChanged() {
let banks = this.model.banks.get();
if (!banks.areEqual(this.state.banks, false)) // avoid a bunch of peculiar effects if we update while a drag is in progress
{
// if we don't have a valid selection, then use the current bank.
if (this.state.banks.getEntry(this.state.selectedItem) == null) {
this.setState({ banks: banks, selectedItem: banks.selectedBank });
} else {
this.setState({ banks: banks });
}
}
}
mounted: boolean = false;
hasHooks: boolean = false;
componentDidUpdate() {
}
componentDidMount() {
this.model.banks.addOnChangedHandler(this.handleBanksChanged);
this.handleBanksChanged();
// scroll selected item into view.
this.mounted = true;
}
componentWillUnmount() {
this.model.banks.removeOnChangedHandler(this.handleBanksChanged);
this.mounted = false;
}
getSelectedIndex() {
let instanceId = this.isEditMode() ? this.state.selectedItem : this.state.banks.selectedBank;
let banks = this.state.banks;
for (let i = 0; i < banks.entries.length; ++i) {
if (banks.entries[i].instanceId === instanceId) return i;
}
return -1;
}
handleDeleteClick() {
if (!this.state.selectedItem) return;
this.setState({ showDeletePrompt: true });
}
handleDeletePromptClose() {
this.setState({ showDeletePrompt: false });
}
handleDeletePromptOk() {
this.handleDeletePromptClose();
let selectedItem = this.state.selectedItem;
if (selectedItem !== -1) {
this.model.deleteBankItem(selectedItem)
.then((newSelection: number) => {
this.setState({
selectedItem: newSelection
});
})
.catch((error) => {
this.model.showAlert(error.toString());
});
}
}
handleDialogClose() {
this.props.onDialogClose();
}
handleItemClick(instanceId: number): void {
if (this.isEditMode()) {
this.setState({ selectedItem: instanceId });
} else {
this.model.openBank(instanceId);
this.props.onDialogClose();
}
}
showActionBar(show: boolean): void {
this.setState({ showActionBar: show });
}
mapElement(el: any): React.ReactNode {
let bankEntry = el as BankIndexEntry;
const classes = withStyles.getClasses(this.props);
let selectedItem = this.isEditMode() ? this.state.selectedItem : this.state.banks.selectedBank;
return (
<div key={bankEntry.instanceId} >
<ButtonBase style={{ width: "100%", height: 48 }}
onClick={() => this.handleItemClick(bankEntry.instanceId)}
>
<SelectHoverBackground selected={bankEntry.instanceId === selectedItem} showHover={true} />
<div className={classes.itemFrame}>
<div className={classes.listIcon}>
<BankIcon />
</div>
<div className={classes.itemLabel}>
<Typography noWrap variant="body2" color="textPrimary">
{bankEntry.name}
</Typography>
</div>
</div>
</ButtonBase>
</div>
);
}
moveElement(from: number, to: number): void {
let newBanks = this.state.banks.clone();
newBanks.moveBank(from, to);
this.setState({
banks: newBanks,
selectedItem: newBanks.entries[to].instanceId
});
this.model.moveBank(from, to)
.catch((error) => {
this.model.showAlert(error.toString());
});
}
getSelectedName(): string {
let item = this.state.banks.getEntry(this.state.selectedItem);
if (item) return item.name;
return "";
}
handleRenameClick() {
let item = this.state.banks.getEntry(this.state.selectedItem);
if (item) {
this.setState({ filenameDialogOpen: true, filenameSaveAs: false });
}
}
handleRenameOk(text: string) {
let item = this.state.banks.getEntry(this.state.selectedItem);
if (!item) return;
if (item.name !== text) {
if (this.state.banks.hasName(text)) {
this.model.showAlert("A bank with that name already exists.");
}
this.model.renameBank(this.state.selectedItem, text)
.catch((error) => {
this.onError(error);
});
}
this.setState({ filenameDialogOpen: false });
}
handleSaveAsOk(text: string) {
let item = this.state.banks.getEntry(this.state.selectedItem);
if (item) {
if (item.name !== text) {
if (this.state.banks.hasName(text)) {
this.model.showAlert("A bank with that name already exists.");
return;
}
this.model.saveBankAs(this.state.selectedItem, text)
.then((newSelection) => {
this.setState({ selectedItem: newSelection });
})
.catch((error) => {
this.onError(error);
});
}
}
this.setState({ filenameDialogOpen: false });
}
handleCopy() {
let item = this.state.banks.getEntry(this.state.selectedItem);
if (item) {
this.setState({ filenameDialogOpen: true, filenameSaveAs: true });
}
}
onError(error: string): void {
this.model?.showAlert(error);
}
getSelectedBankName() {
try {
return this.model.banks.get()?.getEntry(this.state.selectedItem)?.name??"";
} catch (error) {
return "";
}
}
render() {
const classes = withStyles.getClasses(this.props);
let actionBarClass = this.props.isEditDialog ? classes.dialogAppBar : classes.dialogActionBar;
let defaultSelectedIndex = this.getSelectedIndex();
return (
<DialogEx tag="bank" fullScreen open={this.props.show}
onClose={() => { this.handleDialogClose() }} TransitionComponent={Transition}
onEnterKey={()=>{}}
style={{ userSelect: "none" }}
>
<div style={{ display: "flex", flexDirection: "column", flexWrap: "nowrap", width: "100%", height: "100%", overflow: "hidden" }}>
<div style={{ flex: "0 0 auto" }}>
<AppBar className={classes.dialogAppBar} style={{ display: this.isEditMode() ? "none" : "block" }} >
<Toolbar>
<IconButton edge="start" color="inherit" onClick={this.handleDialogClose} aria-label="back"
disabled={this.isEditMode()}
>
<ArrowBackIcon />
</IconButton>
<Typography noWrap variant="h6" className={classes.dialogTitle}>
Banks
</Typography>
<IconButton color="inherit" onClick={(e) => this.showActionBar(true)} >
<EditIcon />
</IconButton>
</Toolbar>
</AppBar>
<AppBar className={actionBarClass} style={{ display: this.isEditMode() ? "block" : "none" }}
onClick={(e) => { e.stopPropagation(); e.preventDefault(); }}
>
<Toolbar>
{(!this.props.isEditDialog) ? (
<IconButton edge="start" color="inherit" onClick={(e) => this.showActionBar(false)} aria-label="close">
<CloseIcon />
</IconButton>
) : (
<IconButton edge="start" color="inherit" onClick={this.handleDialogClose} aria-label="back"
>
<ArrowBackIcon />
</IconButton>
)}
<Typography noWrap variant="h6" className={classes.dialogTitle}>
Banks
</Typography>
{(this.state.banks.getEntry(this.state.selectedItem) != null)
&& (
<div style={{ flex: "0 0 auto" }}>
<Button color="inherit" onClick={(e) => this.handleCopy()}>
Copy
</Button>
<Button color="inherit" onClick={() => this.handleRenameClick()}>
Rename
</Button>
<RenameDialog
open={this.state.filenameDialogOpen}
defaultName={this.getSelectedName()}
acceptActionName={this.state.filenameSaveAs ? "SAVE AS" : "RENAME"}
onClose={() => { this.setState({ filenameDialogOpen: false }) }}
onOk={(text: string) => {
if (this.state.filenameSaveAs) {
this.handleSaveAsOk(text);
} else {
this.handleRenameOk(text);
}
}
}
/>
<IconButton color="inherit" onClick={(e) => this.handleDeleteClick()} >
<img src="/img/old_delete_outline_white_24dp.svg" alt="Delete" style={{ width: 24, height: 24, opacity: 0.6 }} />
</IconButton>
<IconButton color="inherit" onClick={(e) => { this.onMoreClick(e) }} >
<MoreVertIcon />
</IconButton>
<Menu
id="more-menu"
anchorEl={this.state.moreMenuAnchorEl}
keepMounted
open={Boolean(this.state.moreMenuAnchorEl)}
onClose={() => this.handleMoreClose()}
TransitionComponent={Fade}
>
<MenuItem onClick={() => { this.handleDownloadBank(); }} >
<ListItemIcon>
<DownloadIcon className={classes.listIcon}
/>
</ListItemIcon>
<ListItemText>
Download bank
</ListItemText>
</MenuItem>
<label htmlFor="bankDialog_UploadInput">
<MenuItem onClick={(e) => this.handleUploadBank()}>
<ListItemIcon >
<UploadIcon className={classes.listIcon} />
</ListItemIcon>
<ListItemText>
Upload bank
</ListItemText>
</MenuItem>
</label>
</Menu>
</div>
)
}
</Toolbar>
</AppBar>
</div>
<div style={{ flex: "1 1 auto", position: "relative", overflow: "hidden" }} >
<DraggableGrid
onLongPress={(item) => this.showActionBar(true)}
canDrag={this.isEditMode()}
onDragStart={(index, x, y) => { this.selectItemAtIndex(index) }}
moveElement={(from, to) => { this.moveElement(from, to); }}
scroll={ScrollDirection.Y}
defaultSelectedIndex={defaultSelectedIndex}
>
{
this.state.banks.entries.map((element) => {
return this.mapElement(element);
})
}
</DraggableGrid>
</div>
</div>
<input
id="bankDialog_UploadInput"
ref={this.refUpload}
type="file"
accept=".piBank"
multiple
onChange={(e) => this.handleUpload(e)}
style={{ display: "none" }}
/>
<DialogEx tag="deletePrompt" open={this.state.showDeletePrompt} onClose={() => this.handleDeletePromptClose()}
style={{ userSelect: "none" }}
onEnterKey={()=>{ this.handleDeletePromptOk() }}
>
<DialogContent>
<Typography>Are you sure you want to delete bank '{this.getSelectedBankName()}'?</Typography>
</DialogContent>
<DialogActions>
<Button variant="dialogSecondary" onClick={() => this.handleDeletePromptClose()} color="primary">
Cancel
</Button>
<Button variant="dialogPrimary" onClick={() => this.handleDeletePromptOk()} color="secondary" >
DELETE
</Button>
</DialogActions>
</DialogEx>
</DialogEx >
);
}
},
styles
);
export default BankDialog;
+113
View File
@@ -0,0 +1,113 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export class BankIndexEntry {
deserialize(input: any) : BankIndexEntry {
this.instanceId = input.instanceId;
this.name = input.name;
return this;
}
areEqual(other: BankIndexEntry) : boolean {
return (this.instanceId === other.instanceId)
&& (this.name === other.name);
}
static deserialize_array(input: any): BankIndexEntry[] {
let result: BankIndexEntry[] = [];
for (let i = 0; i < input.length; ++i)
{
result.push(new BankIndexEntry().deserialize(input[i]));
}
return result;
}
instanceId: number = -1;
name: string = "";
}
export class BankIndex {
deserialize(input: any) : BankIndex {
this.selectedBank = input.selectedBank;
this.entries = BankIndexEntry.deserialize_array(input.entries);
return this;
}
selectedBank: number = -1;
entries: BankIndexEntry[] = [];
areEqual(other: BankIndex, includeSelection: boolean): boolean {
if (includeSelection && this.selectedBank !== other.selectedBank) return false;
if (this.entries.length !== other.entries.length) return false;
for (let i = 0; i < this.entries.length; ++i)
{
if (!this.entries[i].areEqual(other.entries[i])) return false;
}
return true;
}
moveBank(from: number, to: number)
{
let t = this.entries[from];
this.entries.splice(from,1);
this.entries.splice(to,0,t);
}
clone(): BankIndex {
return new BankIndex().deserialize(this);
}
hasName(name: string): boolean {
for (let i = 0; i < this.entries.length; ++i)
{
let entry = this.entries[i];
if (entry.name === name) {
return true;
}
}
return false;
}
getEntry(instanceId: number): BankIndexEntry | null {
for (let i = 0; i < this.entries.length; ++i)
{
let entry = this.entries[i];
if (entry.instanceId === instanceId) return entry;
}
return null;
}
getSelectedEntryName(): string {
let entry = this.getEntry(this.selectedBank);
if (!entry) return "";
return entry.name;
}
nameExists(name: string): boolean {
for (let i = 0; i < this.entries.length; ++i)
{
let entry = this.entries[i];
if (entry.name === name) return true;
}
return false;
}
}
@@ -0,0 +1,131 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import Button from '@mui/material/Button';
import DialogEx from './DialogEx';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import Typography from '@mui/material/Typography';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import Divider from '@mui/material/Divider';
//import TextFieldEx from './TextFieldEx';
export interface ChannelBindingHelpDialogProps {
open: boolean,
onClose: () => void,
};
export interface ChannelBindingHelpDialogState {
fullScreen: boolean;
};
export default class ChannelBindingHelpDialog extends ResizeResponsiveComponent<ChannelBindingHelpDialogProps, ChannelBindingHelpDialogState> {
refText: React.RefObject<HTMLInputElement | null>;
constructor(props: ChannelBindingHelpDialogProps) {
super(props);
this.state = {
fullScreen: false
};
this.refText = React.createRef<HTMLInputElement>();
}
mounted: boolean = false;
onWindowSizeChanged(width: number, height: number): void {
this.setState({ fullScreen: height < 200 })
}
componentDidMount() {
super.componentDidMount();
this.mounted = true;
}
componentWillUnmount() {
super.componentWillUnmount();
this.mounted = false;
}
componentDidUpdate() {
}
render() {
let props = this.props;
let { open, onClose } = props;
const handleClose = () => {
onClose();
};
return (
<DialogEx tag="bindingHelp" open={open} fullWidth maxWidth="sm"
onClose={handleClose}
aria-labelledby="Rename-dialog-title"
style={{ userSelect: "none" }}
fullwidth
onEnterKey={() => { }}
>
<DialogContent style={{ minHeight: 96 }}>
<Typography variant="h5" style={{ marginBottom: 8 }}>
MIDI Control Binding Priority
</Typography>
<div style={{ fontSize: "0.9em", lineHeight: "18pt" }}>
<p style={{ lineHeight: "1.4em" }}>MIDI Channel Filters determine which MIDI messages get sent to a plugin that accepts MIDI messages. Note that the
MIDI Channel Filters do NOT affect system MIDI bindings or control bindings.
</p>
<p>
MIDI message processing occurs in the following order
</p>
<ul>
<li>If the message is a Program Change message, the message is first offered to any MIDI plugins in the currently loaded preset (Message Filters permitting).
The message is sent to each MIDI plugin in the current preset that wants it. If any MIDI plugin accepts the program change,
no futher processing occurs. Specifically, the program change will not change the currently selected PiPedal preset.
<br />
</li>
<li>
If the message is a Program Change message, and no MIDI plugin has accepted the message, PiPedal selects the PiPedal preset that corresponds
to the requested program.
<br />
</li>
<li>
The message is then checked to see if it has been bound to a Pipedal feature using the System Midi Bindings settings. If it has,
Pipedal processes the message, and it is not forwarded to MIDI plugins.
<br />
</li>
<li>
Otherwise, the message is sent to each MIDI plugin in the currently loaded Pipedal preset (channel filters permitting).)
<br />
</li>
</ul>
</div>
</DialogContent>
<Divider />
<DialogActions style={{ flexShrink: 1 }}>
<Button onClick={handleClose} variant="dialogPrimary" >
OK
</Button>
</DialogActions>
</DialogEx>
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import React, { useState, useEffect } from 'react';
import ButtonBase from '@mui/material/ButtonBase';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { colorKeys, getBackgroundColor } from './MaterialColors';
import { useTheme } from '@mui/material/styles';
const colors: string[] = colorKeys;
export enum DropdownAlignment {
SE, SW
}
interface ColorDropdownButtonProps {
currentColor?: string;
onColorChange: (color: string) => void;
dropdownAlignment: DropdownAlignment;
}
const ColorDropdownButton: React.FC<ColorDropdownButtonProps> = ({
currentColor = colors[0],
onColorChange,
dropdownAlignment = DropdownAlignment.SE
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedColor, setSelectedColor] = useState<string>(currentColor);
const theme = useTheme();
useEffect(() => {
setSelectedColor(currentColor);
}, [currentColor]);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleColorSelect = (color: string) => {
handleClose();
setSelectedColor(color);
onColorChange(color);
};
return (
<div>
<ButtonBase
onClick={handleClick}
style={{
width: 48, height: 48, padding: 12, borderRadius: 18
}}
>
<div style={{
width: 24, height: 24,
background: getBackgroundColor(selectedColor),
borderRadius: 6,
borderColor: theme.palette.text.secondary,
borderWidth: 1,
borderStyle: "solid"
}} />
</ButtonBase>
<Menu
anchorEl={anchorEl}
open={anchorEl !== null}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: (dropdownAlignment === DropdownAlignment.SW ? 'right' : 'left')
}}
transformOrigin={{
vertical: 'top',
horizontal: (dropdownAlignment === DropdownAlignment.SW ? 'right' : 'left'),
}}
>
<div style={{ display: "flex", flexFlow: "row wrap", width: 56 * 4 + 2 }}>
{colors.map((color) => {
let selected = color === selectedColor;
return (
<MenuItem key={color} onClick={() => handleColorSelect(color)}
style={{
width: 48, height: 48, borderRadius: 6, margin: 4,
borderStyle: "solid",
borderWidth: selected ? 2 : 0.25,
borderColor: color === selectedColor ?
theme.palette.text.primary
: theme.palette.text.secondary,
backgroundColor: getBackgroundColor(color)
}}
></MenuItem>
)
}
)}
</div>
</Menu>
</div>
);
};
export default ColorDropdownButton;
+41
View File
@@ -0,0 +1,41 @@
import React, { ReactElement } from 'react';
import Tooltip from "@mui/material/Tooltip"
import { UiControl } from './Lv2Plugin';
import Typography from "@mui/material/Typography";
import Divider from '@mui/material/Divider';
interface ControlTooltipProps {
children: ReactElement,
uiControl: UiControl
}
export default function ControlTooltip(props: ControlTooltipProps) {
let { children, uiControl } = props;
if (uiControl.comment && (uiControl.comment !== uiControl.name)) {
return (
<Tooltip title={(
<React.Fragment>
<Typography variant="caption">{uiControl.name}</Typography>
<Divider />
<Typography variant="caption">{uiControl.comment}</Typography>
</React.Fragment>
)}
placement="top-start" arrow enterDelay={1500} enterNextDelay={1500}
>
{children}
</Tooltip>
);
} else {
return (
<Tooltip title={uiControl.name}
placement="top-start" arrow enterDelay={1500} enterNextDelay={1500}
>
{children}
</Tooltip>
);
}
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { PedalboardItem, PedalboardSplitItem } from './Pedalboard';
import PluginControlView from './PluginControlView';
import SplitControlView from './SplitControlView';
import Typography from '@mui/material/Typography';
import IControlViewFactory from './IControlViewFactory';
import { GxTunerViewFactory } from './GxTunerView';
import ToobPowerstage2ViewFactory from './ToobPowerStage2View';
import ToobSpectrumAnalyzerViewFactory from './ToobSpectrumAnalyzerView';
import ToobMLViewFactory from './ToobMLView';
let pluginFactories: IControlViewFactory[] = [
new GxTunerViewFactory(),
new ToobPowerstage2ViewFactory(),
new ToobSpectrumAnalyzerViewFactory(),
new ToobMLViewFactory()
];
export function GetControlView(pedalboardItem?: PedalboardItem | null): React.ReactNode {
let model: PiPedalModel = PiPedalModelFactory.getInstance();
if (!pedalboardItem) {
return (<div />);
}
if (pedalboardItem.isStart() || pedalboardItem.isEnd()) {
return (
<PluginControlView instanceId={pedalboardItem.instanceId} item={pedalboardItem} />
);
}
if (pedalboardItem.isSplit()) {
return (
<SplitControlView item={pedalboardItem as PedalboardSplitItem} instanceId={pedalboardItem!.instanceId}
/>
);
} else {
for (let i = 0; i < pluginFactories.length; ++i) {
let factory = pluginFactories[i];
if (factory.uri === pedalboardItem.uri) {
return factory.Create(model, pedalboardItem);
}
}
let uiPlugin = model.getUiPlugin(pedalboardItem.uri);
if (!uiPlugin) {
<div style={{ paddingLeft: 40, paddingRight: 40 }}>
<Typography color="error" variant="h6" >Missing plugin.</Typography>
<Typography>The plugin '{pedalboardItem.pluginName}' ({pedalboardItem.uri}) is not currently installed.</Typography>
</div>
} else {
return (
<PluginControlView instanceId={pedalboardItem.instanceId} item={pedalboardItem} />
)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { AndroidHostInterface, getAndroidHost } from "./AndroidHost";
export enum ColorTheme {
Light,
Dark,
System
};
export function getEffectiveColorScheme(): ColorTheme {
let androidHost = (window as any).AndroidHost as AndroidHostInterface;
if (androidHost) {
if (androidHost.isDarkTheme)
return androidHost.isDarkTheme() ? ColorTheme.Dark: ColorTheme.Light;
return ColorTheme.Dark;
}
return getColorScheme();
}
export function getColorScheme(): ColorTheme {
let androidHost = (window as any).AndroidHost as AndroidHostInterface;
if (androidHost) {
switch (androidHost.getThemePreference() as number)
{
case 0: return ColorTheme.Light;
case 1: return ColorTheme.Dark;
default:
case 2: return ColorTheme.System;
}
}
switch (localStorage.getItem('colorScheme')) {
case null:
default:
return ColorTheme.Light;
case "Light":
return ColorTheme.Light;
case "Dark":
return ColorTheme.Dark;
case "System":
return ColorTheme.System;
}
}
export function setColorScheme(value: ColorTheme): void {
let androidHost = (window as any).AndroidHost as AndroidHostInterface;
if (androidHost) {
switch (value) {
case ColorTheme.Light:
androidHost.setThemePreference(0);
break;
case ColorTheme.Dark:
androidHost.setThemePreference(1);
break;
default:
case ColorTheme.System:
androidHost.setThemePreference(2);
break;
}
}
var storageValue;
switch (value) {
default:
case ColorTheme.Light:
storageValue = "Light";
break;
case ColorTheme.Dark:
storageValue = "Dark";
break;
case ColorTheme.System:
storageValue = "System";
break;
}
localStorage.setItem("colorScheme", storageValue);
}
var gIsDarkTheme: boolean| undefined = undefined;
export function isDarkMode(): boolean {
if (gIsDarkTheme !== undefined)
{
return gIsDarkTheme;
}
var colorTheme = getColorScheme();
if (colorTheme === ColorTheme.System) {
let androidHost = getAndroidHost();
if (androidHost)
{
if (androidHost.isDarkTheme)
{
return gIsDarkTheme = androidHost.isDarkTheme();
}
return gIsDarkTheme = true;
}
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return gIsDarkTheme = true;
}
}
return gIsDarkTheme = (colorTheme === ColorTheme.Dark);
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { IDialogStackable, pushDialogStack, popDialogStack } from './DialogStack';
import Dialog, { DialogProps } from '@mui/material/Dialog';
interface DialogExState {
}
interface DialogExProps extends DialogProps {
tag: string;
fullwidth?: boolean;
onEnterKey: () => void;
}
class DialogEx extends React.Component<DialogExProps, DialogExState> implements IDialogStackable {
constructor(props: DialogExProps) {
super(props);
this.state = {
};
}
mounted: boolean = false;
hasHooks: boolean = false;
stateWasPopped: boolean = false;
getTag() {
return this.props.tag;
}
isOpen(): boolean {
return this.props.open;
}
onDialogStackClose() {
this.props.onClose?.({}, "backdropClick");
}
updateHooks(): void {
let wantHooks = this.mounted && this.props.open;
if (wantHooks !== this.hasHooks) {
this.hasHooks = wantHooks;
if (this.hasHooks) {
this.stateWasPopped = false;
pushDialogStack(this);
} else {
popDialogStack(this);
}
}
}
componentDidMount() {
super.componentDidMount?.();
this.mounted = true;
this.updateHooks();
}
componentWillUnmount() {
super.componentWillUnmount?.();
this.mounted = false;
this.updateHooks();
}
componentDidUpdate() {
this.updateHooks();
}
myOnClose(event: {}, reason: "backdropClick" | "escapeKeyDown") {
if (this.props.onClose) this.props.onClose(event, reason);
}
handleEnterKey() {
if (this.props.onEnterKey) {
this.props.onEnterKey();
}
}
onKeyDown(evt: React.KeyboardEvent<HTMLDivElement>) {
if (evt.key === 'Enter') {
this.handleEnterKey();
}
evt.stopPropagation();
}
render() {
let { tag, onClose, fullWidth, onEnterKey, ...extra } = this.props;
return (
<Dialog fullWidth={fullWidth ?? false}
maxWidth={fullWidth ? false : undefined} {...extra}
onClose={(event, reason) => { this.myOnClose(event, reason); }}
onKeyDown={(evt) => { this.onKeyDown(evt); }}
>
{this.props.children}
</Dialog>
);
}
};
export default DialogEx;
+212
View File
@@ -0,0 +1,212 @@
/*
* Copyright (c) 2025 Robin E. R. Davies
* All rights reserved.
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export interface IDialogStackable {
getTag: () => string;
isOpen: () => boolean;
onDialogStackClose(): void;
}
interface DialogStackEntry {
dialog: IDialogStackable | null;
historyState: DialogStackHistoryState
};
let dialogStack: DialogStackEntry[] = [];
const DLG_STATE_MAGIC = "DlgState438327"
export interface DialogStackHistoryState {
magic: string;
topmost: boolean;
stackDepth: number;
tag: string;
id: string;
previousState: DialogStackHistoryState | null;
};
export function pushDialogStack(dialog: IDialogStackable): void {
let stackId = Math.random().toString(36).substring(7);
let dialogStackState: DialogStackHistoryState = {
magic: DLG_STATE_MAGIC,
topmost: false,
stackDepth: dialogStack.length + 1,
tag: dialog.getTag(),
id: stackId, previousState:
window.history.state as DialogStackHistoryState | null
};
window.history.replaceState(
dialogStackState,
"",
"#" + dialog.getTag());
// refresh reloads the page.
let topState: DialogStackHistoryState = {
topmost: true,
magic: DLG_STATE_MAGIC,
previousState: dialogStackState,
tag: dialog.getTag(),
id: stackId,
stackDepth: dialogStack.length + 1
};
dialogStack.push(
{
dialog: dialog,
historyState: dialogStackState
});
console.log("pushDialogStack " + stackId + " #" + dialog.getTag());
window.history.pushState(
topState,
"",
"#" + dialog.getTag());
if (window.history.state.id != stackId)
{
console.log("pushDialogStack: nav deferred.");
}
if (!gWatchingHistory) {
gWatchingHistory = true;
window.addEventListener("popstate", handlePopState);
}
}
let gWatchingHistory = false;
let gPopFromBack = true;
let handlePopState = (ev: PopStateEvent) => {
let evTag: DialogStackHistoryState | null = ev.state as DialogStackHistoryState | null;
if (!evTag) return;
if (evTag.magic !== DLG_STATE_MAGIC) return;
ev.stopPropagation();
console.log("handlePopState: " + evTag?.tag + " " + evTag?.id + " " + gPopFromBack);
let id = evTag.id;
let ix = dialogStack.length-1;
while (ix >= 0) {
let entry = dialogStack[ix];
if (entry.historyState.id === id) {
break;
}
ix--;
}
if (ix < 0) {
// user must have navigated directly.
// so just reload the page.
dialogStack = [];
window.location.reload();
ev.stopPropagation();
return;
}
while (dialogStack.length > ix) {
let topEntry = dialogStack[dialogStack.length - 1];
if (topEntry.dialog) {
try {
if (topEntry.dialog.isOpen()) {
topEntry.dialog.onDialogStackClose();
}
} catch (_e) {
}
topEntry.dialog = null;
}
dialogStack.pop();
}
let previousState = evTag.previousState;
if (previousState)
{
let newState = {...previousState, topmost: true};
window.history.replaceState(newState,"","#"+previousState.tag);
} else {
window.history.replaceState(null,"",window.location.pathname);
}
if (dialogStack.length != 0 && gPopFromBack) {
if (dialogStack[dialogStack.length -1].dialog === null)
{
if (window.history.state != null)
{
console.log("handlePopState: nav back");
window.setTimeout(() => { window.history.back(); }, 0);
console.log("handlePopState: id= "+ (window.history?.state?.id??"null"));
} else {
dialogStack.pop();
}
}
}
ev.stopPropagation();
}
export function popDialogStack(dialog: IDialogStackable): void {
let ix = dialogStack.length-1;
while (ix >= 0) {
let entry = dialogStack[ix];
if (entry.dialog === dialog) {
console.log("popDialogStack " + entry.historyState.id + " #" + dialog.getTag());
break;
}
ix--;
}
if (ix < 0)
{
return;
}
dialogStack[ix].dialog = null;
if (dialogStack.length != 0 && dialogStack[dialogStack.length - 1].dialog === null)
{
console.log("popDialogStack nav back: " + dialogStack[ix].historyState.id );
window.history.back();
}
}
// Close all dialogs higher in the dialog stack than "tag".
export function removeDialogStackEntriesAbove(tag: string) {
while (dialogStack.length > 0) {
let entry = dialogStack[dialogStack.length - 1];
if (entry.dialog && entry.dialog.getTag() === tag) {
return;
}
dialogStack.pop();
if (entry.dialog && entry.dialog.isOpen()) {
entry.dialog.onDialogStackClose();
}
}
}
+509
View File
@@ -0,0 +1,509 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { MouseEvent, PointerEvent, ReactNode, Component } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { PiPedalStateError } from './PiPedalError';
import { css } from '@emotion/react';
const SELECT_SCALE = 1.5;
const AUTOSCROLL_TICK_DELAY = 30;
const AUTOSCROLL_THRESHOLD = 48;
const AUTOSCROLL_SCROLL_PER_SECOND = 100;
const AUTOSCROLL_SCROLL_RATE = AUTOSCROLL_SCROLL_PER_SECOND * AUTOSCROLL_TICK_DELAY / 1000;
const LONG_PRESS_TIME_MS = 250;
const styles = (theme: Theme) => createStyles({
frame: css({
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative"
})
});
type Point = { x: number, y: number };
export interface DraggableProps extends WithStyles<typeof styles> {
onDragStart?: (clientX: number, clientY: number) => void;
onDragMove?: (clientX: number, clientY: number) => void;
onDragEnd?: (clientX: number, clientY: number) => void;
onDragCancel?: (clientX: number, clientY: number) => void;
getScrollContainer: () => HTMLDivElement | null | undefined;
children?: ReactNode | ReactNode[];
draggable?: boolean;
}
type DraggableState = {
};
const Draggable =
withStyles(
class extends Component<DraggableProps, DraggableState>
{
constructor(props: DraggableProps) {
super(props);
this.onPointerDownCapture = this.onPointerDownCapture.bind(this);
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerCancel = this.onPointerCancel.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.onClick = this.onClick.bind(this);
this.autoScrollTick = this.autoScrollTick.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
}
handleTouchStart(e: any) {
if (this.dragStarted) {
//e.preventDefault();
}
}
handleTouchMove(e: any) {
if (this.dragStarted) {
e.preventDefault();
}
}
handleTouchEnd(e: any) {
if (this.dragStarted) {
e.preventDefault();
}
}
isValidPointer(e: PointerEvent<HTMLDivElement>): boolean {
if (e.pointerType === "mouse") {
return e.button === 0;
} else if (e.pointerType === "pen") {
return true;
} else if (e.pointerType === "touch") {
return true;
}
return false;
}
mouseDown: boolean = false;
pointerId: number = 0;
pointerType: string = "";
lastOnDragTime: number = 0;
onClick(e: MouseEvent<HTMLDivElement>) {
// if the click event immediately follows drag end, suppress it.
let dt = new Date().getTime() - this.lastOnDragTime;
if (dt < 150) {
e.preventDefault();
e.stopPropagation();
}
}
componentDidMount()
{
}
componentWillUnmount()
{
}
isCapturedPointer(e: PointerEvent<HTMLDivElement>): boolean {
return this.mouseDown
&& e.pointerId === this.pointerId
&& e.pointerType === this.pointerType;
}
startX: number = 0;
startY: number = 0;
startClientX: number = 0;
startClientY: number = 0;
dragStarted: boolean = false;
savedIndex: string = "";
lastPointerDown: number = 0;
originalBounds?: DOMRect;
captureElement?: HTMLDivElement;
pointerDownTime: number = 0;
onPointerDownCapture(e: any) {
// a new pointer down of any type cancels capture
this.cancelDrag();
e.preventDefault();
e.stopPropagation();
}
longPressTimer?: number = undefined;
longPressTimerTick() {
if (this.pointerType === "touch") {
if (navigator.vibrate) {
navigator.vibrate([10]);
}
if (!this.dragStarted) {
this.dragTarget!.style.transform = "scale(" + SELECT_SCALE + ")";
this.dragTarget!.style.zIndex = "3";
}
this.startDrag();
}
}
cancelDrag() {
window.removeEventListener("touchmove", this.handleTouchMove);
window.removeEventListener("touchend", this.handleTouchEnd);
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = undefined;
}
this.stopAutoScroll();
if (this.dragStarted && this.captureElement) {
try {
this.captureElement.releasePointerCapture(this.pointerId);
} catch (error)
{
// throws if we've already lost it.
}
}
this.dragStarted = false;
if (this.captureElement) {
this.clearDragTransform(this.captureElement);
this.captureElement = undefined;
}
this.mouseDown = false;
document.body.removeEventListener("pointerdown", this.onPointerDownCapture, true);
this.dragTarget = undefined;
}
dragTarget?: HTMLDivElement;
onPointerDown(e: PointerEvent<HTMLDivElement>): void {
if (this.props.draggable !== undefined && !this.props.draggable) return;
// any new pointer down cancles a drag in progress.
this.cancelDrag();
// avoid 2nd click of a double click (which is difficult to cancel)
let timeMs = new Date().getTime();
if (timeMs - this.lastPointerDown < 300) {
this.lastPointerDown = timeMs;
return;
}
this.lastPointerDown = timeMs;
if (!this.mouseDown && this.isValidPointer(e)) {
this.longPressTimer = setTimeout(() => this.longPressTimerTick(), LONG_PRESS_TIME_MS);
this.dragTarget = e.currentTarget as HTMLDivElement;
document.body.addEventListener("pointerdown", this.onPointerDownCapture, true);
this.captureElement = e.currentTarget;
// e.currentTarget.setPointerCapture(e.pointerId);
this.mouseDown = true;
this.dragStarted = false;
this.startX = e.clientX;
this.startY = e.clientY;
this.startClientX = e.clientX;
this.startClientY = e.clientY;
this.pointerId = e.pointerId;
this.pointerType = e.pointerType;
this.savedIndex = e.currentTarget.style.zIndex;
if (this.pointerType !== "touch") {
this.dragTarget.style.transform = "scale(" + SELECT_SCALE + ")";
this.dragTarget.style.zIndex = "3";
}
}
}
clearDragTransform(element: HTMLDivElement): void {
element.style.transform = "";
element.style.zIndex = this.savedIndex;
}
onPointerCancel(e: PointerEvent<HTMLDivElement>) {
if (this.isCapturedPointer(e)) {
this.cancelDrag();
if (this.props.onDragCancel) {
this.props.onDragCancel(e.clientX, e.clientY);
}
}
}
onPointerUp(e: PointerEvent<HTMLDivElement>) {
if (this.isCapturedPointer(e)) {
e.preventDefault();
e.stopPropagation();
if (this.dragStarted) {
this.lastOnDragTime = new Date().getTime();
if (this.props.onDragEnd) {
this.props.onDragEnd(e.clientX, e.clientY);
}
}
this.cancelDrag();
}
}
dragThresholdExceeded(e: PointerEvent<HTMLDivElement>) {
let dx = e.clientX - this.startX;
let dy = e.clientY - this.startY;
let d2 = dx * dx + dy * dy;
if (this.pointerType === "touch") {
return false; // wait for long press.
}
let DRAG_THRESHOLD = 5;
if (this.pointerType === "touch") {
DRAG_THRESHOLD = 15;
}
let result = (d2 > DRAG_THRESHOLD * DRAG_THRESHOLD);
return result;
}
lastX: number = 0;
lastY: number = 0;
translateViewPortPoint(fromElement: HTMLElement, toElement: HTMLElement, pt: Point): Point {
let rcFrom = fromElement.getBoundingClientRect();
let rcTo = toElement.getBoundingClientRect();
return {
x: pt.x + rcFrom.left + fromElement.scrollLeft - rcTo.left - toElement.scrollLeft,
y: pt.y + rcFrom.top + fromElement.scrollTop - rcTo.top - toElement.scrollTop
};
}
translateViewPortRect(fromElement: HTMLElement, toElement: HTMLElement, rect: DOMRect): DOMRect {
let rcFrom = fromElement.getBoundingClientRect();
let rcTo = toElement.getBoundingClientRect();
return new DOMRect(
rect.x + rcFrom.left - fromElement.scrollLeft - rcTo.left + toElement.scrollLeft,
rect.y + rcFrom.top - fromElement.scrollTop - rcTo.top + toElement.scrollTop,
rect.width,
rect.height);
}
makeTransform(targetElement: HTMLElement, clientX: number, clientY: number): string {
let scrollContainer = this.getScrollContainer();
if (scrollContainer && this.originalBounds) {
// the bounds of the translated element must COMPLETELY fit within the scroll range of the container.
let newLocation = new DOMRect(
this.originalBounds.x + clientX - this.startX,
this.originalBounds.y + clientY - this.startY,
this.originalBounds.width,
this.originalBounds.height);
if (newLocation.x < 0) {
clientX -= newLocation.x;
} else if (newLocation.right >= scrollContainer.scrollWidth) {
clientX -= newLocation.right - scrollContainer.scrollWidth;
}
if (newLocation.top < 0) {
clientY -= newLocation.top;
} else if (newLocation.bottom > scrollContainer.scrollHeight) {
clientY -= newLocation.bottom - scrollContainer.scrollHeight;
}
}
return "translate(" + (clientX - this.startX) + "px," + (clientY - this.startY) + "px) scale(" + SELECT_SCALE + ")";
}
createOriginalBounds() {
// bounds of the target element, in ScrollContainer coordinates.
let rcTarget = this.dragTarget!.getBoundingClientRect();
rcTarget.x = 0;
rcTarget.y = 0;
let scrollContainer = this.props.getScrollContainer();
if (!scrollContainer) {
throw new PiPedalStateError("ScrollContainer reference not valid.");
}
return this.translateViewPortRect(this.dragTarget!, scrollContainer, rcTarget);
}
startDrag() {
window.addEventListener("touchmove", this.handleTouchMove, { passive: false });
window.addEventListener("touchend", this.handleTouchEnd, { passive: false });
this.dragStarted = true;
if (this.props.onDragStart) {
this.props.onDragStart(this.startX, this.startY);
}
this.dragTarget!.setPointerCapture(this.pointerId);
this.originalBounds = this.createOriginalBounds();
}
onPointerMove(e: PointerEvent<HTMLDivElement>): void {
if (this.isCapturedPointer(e)) {
if (!this.dragStarted && this.dragThresholdExceeded(e)) {
this.startDrag();
}
if (this.dragStarted) {
this.lastX = e.clientX;
this.lastY = e.clientY;
e.currentTarget.style.transform = this.makeTransform(e.currentTarget, e.clientX, e.clientY);
e.stopPropagation();
e.preventDefault();
if (this.props.onDragMove) {
this.props.onDragMove(e.clientX, e.clientY);
}
this.checkForAutoScroll(e.currentTarget);
}
}
}
autoScrollTimer?: number;
autoScrollTick(currentTarget: HTMLElement, dx: number, dy: number) {
if (!this.mouseDown) return;
let scrollContainer = this.getScrollContainer();
if (scrollContainer) {
let tx = scrollContainer.scrollLeft;
let ty = scrollContainer.scrollTop;
scrollContainer.scrollBy(dx, dy);
this.startX -= (scrollContainer.scrollLeft - tx);
this.startY -= (scrollContainer.scrollTop - ty);
currentTarget.style.transform = this.makeTransform(currentTarget, this.lastX, this.lastY);
this.checkForAutoScroll(currentTarget);
}
}
stopAutoScroll() {
if (this.autoScrollTimer) {
cancelAnimationFrame(this.autoScrollTimer);
this.autoScrollTimer = undefined;
}
}
startAutoScroll(scrollContainer: HTMLElement, dx: number, dy: number) {
this.stopAutoScroll();
this.autoScrollTimer = requestAnimationFrame(
() => { this.autoScrollTick(scrollContainer, dx, dy) });
}
checkForAutoScroll(currentTarget: HTMLElement) {
let scrollContainer = this.getScrollContainer();
if (!scrollContainer) {
this.stopAutoScroll();
return;
}
let scrollContainerRect = scrollContainer.getBoundingClientRect();
let dy: number = 0;
let dx: number = 0;
if (this.lastX < scrollContainerRect.x + AUTOSCROLL_THRESHOLD) {
dx = -AUTOSCROLL_SCROLL_RATE;
if (dx < -scrollContainer.scrollLeft) {
dx = -scrollContainer.scrollLeft;
}
} else if (this.lastX >= scrollContainerRect.right - AUTOSCROLL_THRESHOLD) {
dx = AUTOSCROLL_SCROLL_RATE;
let maxScroll = Math.max(scrollContainer.scrollWidth - scrollContainer.clientWidth, 0);
if (dx > maxScroll) dx = maxScroll;
}
if (this.lastY < scrollContainerRect.top) {
dy = -AUTOSCROLL_SCROLL_RATE;
if (dy < -scrollContainer.scrollTop) dy = -scrollContainer.scrollTop;
} else if (this.lastY >= scrollContainerRect.top - AUTOSCROLL_THRESHOLD) {
dy = AUTOSCROLL_SCROLL_RATE;
let maxScroll = Math.max(scrollContainer.scrollHeight - scrollContainer.clientHeight);
if (dy > maxScroll) dy = maxScroll;
}
if (dx === 0 && dy === 0) {
this.stopAutoScroll();
return;
} else {
this.startAutoScroll(currentTarget, dx, dy);
}
}
getScrollContainer(): HTMLDivElement | null {
if (this.props.getScrollContainer) {
let t = this.props.getScrollContainer();
if (!t) return null;
return t;
}
return null;
}
render() {
const classes = withStyles.getClasses(this.props);
return (
<div className={classes.frame} style={{ transform: "" }}
onPointerDown={this.onPointerDown}
onPointerMove={this.onPointerMove}
onPointerCancel={this.onPointerCancel}
onPointerCancelCapture={this.onPointerCancel}
onPointerUp={this.onPointerUp}
onDragStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={this.onClick}
>
{
this.props.children
}
</div>
)
}
},
styles
);
export default Draggable;
+932
View File
@@ -0,0 +1,932 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { MouseEvent, PointerEvent, Component } from 'react';
import { Property } from "csstype";
import { nullCast} from './Utility'
import {isDarkMode} from './DarkMode';
const SELECT_SCALE = 1.0;
const AUTOSCROLL_TICK_DELAY = 30;
const AUTOSCROLL_THRESHOLD = 48;
const AUTOSCROLL_SCROLL_PER_SECOND = 300;
const AUTOSCROLL_SCROLL_RATE = AUTOSCROLL_SCROLL_PER_SECOND * AUTOSCROLL_TICK_DELAY / 1000;
const LONG_PRESS_TIME_MS = 250;
const ANIMATION_DURATION_MS = 100;
class AnimationData {
constructor(element: HTMLElement, originalPosition: DOMRect) {
this.element = element;
this.originalPosition = originalPosition;
this.fromY = this.toY = this.currentY = this.originalPosition.y;
this.key = (element as any)["key"];
}
key: any;
element: HTMLElement;
originalPosition: DOMRect;
fromY: number;
toY: number;
currentY: number;
animating: boolean = false;
setPosition(y: number)
{
this.fromY = this.toY;
this.toY = y;
this.animating = true;
}
snapToPosition() {
this.fromY = this.toY;
this.currentY = this.toY;
this.animating = true;
}
}
type Point = { x: number, y: number };
export enum ScrollDirection {
None, X, Y
}
export interface DraggableGridProps
{
fullWidth?: boolean; // defaults to true
fullHeight?: boolean; // defaults to true.
onDragStart?: (itemIndex: number, clientX: number, clientY: number) => void;
onDragMove?: (currentItemIndex: number, clientX: number, clientY: number) => void;
onDragEnd?: (fromIndex: number, toIndex: number, clientX: number, clientY: number) => void;
onDragCancel?: (clientX: number, clientY: number) => void;
defaultSelectedIndex?: number;
onLongPress?: (itemIndex: number) => void;
canDrag?: boolean;
children: React.ReactNode[];
moveElement: (from: number, to: number) => void;
scroll: ScrollDirection;
}
type DraggableGridState = {
indexToSelect?: number;
};
const DraggableGrid =
(
class extends Component<DraggableGridProps, DraggableGridState>
{
refScrollContainer: React.RefObject<HTMLDivElement|null>;
refGrid: React.RefObject<HTMLDivElement|null>;
constructor(props: DraggableGridProps) {
super(props);
this.refScrollContainer = React.createRef();
this.refGrid = React.createRef();
this.state = {
indexToSelect: props.defaultSelectedIndex
};
this.handlePointerDownCapture = this.handlePointerDownCapture.bind(this);
this.handlePointerDown = this.handlePointerDown.bind(this);
this.handlePointerCancel = this.handlePointerCancel.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.onClick = this.onClick.bind(this);
this.handleAutoScrollTick = this.handleAutoScrollTick.bind(this);
this.animationTick = this.animationTick.bind(this);
this.longPressTimerTick = this.longPressTimerTick.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
}
animationData: AnimationData[] = [];
handleTouchMove(e: any)
{
if (this.dragStarted)
{
e.preventDefault();
}
}
handleTouchEnd(e: any)
{
if (this.dragStarted)
{
e.preventDefault();
}
}
animationStartTime: number = 0;
timerHandle: number| null = null;
animationTick() {
let t = (new Date().getTime() - this.animationStartTime) / ANIMATION_DURATION_MS;
if (t > 1) t = 1;
// easing function.
//t = 1-(1-t)*(1-t);
let didAnimate: boolean = false;
for (let i = 0; i < this.animationData.length; ++i) {
let animationData = this.animationData[i];
if (animationData.animating)
{
if (i !== this.startIndex) {
//if ((animationData.xFrom !== animationData.xTo) || (animationData.yFrom !== animationData.yTo))
let y = t * (animationData.toY - animationData.fromY) + animationData.fromY;
let style = animationData.element.style;
style.left = "0px";
style.top = y-animationData.originalPosition.y + "px";
animationData.currentY = y;
animationData.animating = animationData.toY !== animationData.fromY;
if (animationData.animating) didAnimate = true;
}
}
}
if (t < 1 && didAnimate) {
this.timerHandle = requestAnimationFrame(this.animationTick)
} else {
this.timerHandle = null;
}
}
startAnimation() {
this.animationStartTime = new Date().getTime();
if (this.timerHandle === null) {
this.timerHandle = requestAnimationFrame(this.animationTick);
this.animationTick();
}
}
stopAnimation() {
if (this.timerHandle !== null) {
cancelAnimationFrame(this.timerHandle);
}
this.timerHandle = null;
}
isValidPointer(e: PointerEvent<HTMLDivElement>): boolean
{
if (e.pointerType === "mouse") {
return e.button === 0;
} else if (e.pointerType === "pen") {
return true;
} else if (e.pointerType === "touch") {
return true;
}
return false;
}
bringSelectedItemIntoView() {
if (this.state.indexToSelect && this.state.indexToSelect >= 0)
{
let grid = this.refGrid.current;
if (grid)
{
if (this.state.indexToSelect >= 0 && this.state.indexToSelect < grid.childNodes.length)
{
let element = grid.childNodes[this.state.indexToSelect] as HTMLElement;
element.scrollIntoView({
block: "nearest",
inline: "nearest"
});
}
}
}
}
componentDidMount() {
this.bringSelectedItemIntoView();
// this.refGrid.current!.addEventListener("touchstart",(e)=> this.handleTouchStart(e),{passive: false});
}
componentWillUnmount()
{
//this.refGrid.current!.removeEventListener("touchstart",(e) => this.handleTouchStart(e));
this.stopAnimation();
}
componentDidUpdate() {
if (!this.mouseDown)
{
this.bringSelectedItemIntoView();
}
if (this.mouseDown)
{
this.reprepareChildren();
this.reflowChildren();
this.stopAnimation();
this.animationTick();
}
}
mouseDown: boolean = false;
pointerId: number = 0;
pointerType: string = "";
lastOnDragTime: number = 0;
onClick(e: MouseEvent<HTMLDivElement>) {
// if the click event immediately follows drag end, suppress it.
let dt = new Date().getTime() - this.lastOnDragTime;
if (dt < 150) {
e.preventDefault();
e.stopPropagation();
}
}
hasClass(element: HTMLElement, className: string) {
let classes = element.classList;
for (let i = 0; i < classes.length; ++i) {
if (classes[i] === className) return true;
}
return false;
}
findGridItem(element: any | null): HTMLDivElement | null {
let grid = this.refGrid.current;
if(!grid) return null;
while (element !== null && element !== grid) {
if (element.parentElement === grid) {
return element as HTMLDivElement;
}
element = element.parentElement;
}
return null;
}
isCapturedPointer(e: PointerEvent<HTMLDivElement>): boolean {
return this.mouseDown
&& e.pointerId === this.pointerId
&& e.pointerType === this.pointerType;
}
startX: number = 0;
startY: number = 0;
startClientX: number = 0;
startClientY: number = 0;
dragStarted: boolean = false;
savedIndex: string = "";
savedBackground: string = "";
lastPointerDown: number = 0;
ptItemStart: DOMPoint = new DOMPoint(0, 0);
captureElement?: HTMLDivElement;
handlePointerDownCapture(e: any) {
// a new pointer down of any type cancels capture
this.cancelDrag();
e.preventDefault();
e.stopPropagation();
}
cancelDrag() {
//window.removeEventListener("touchstart",this.handleTouchStart);
window.removeEventListener("touchmove",this.handleTouchMove);
window.removeEventListener("touchend",this.handleTouchEnd);
if (this.originalOverscrollBehaviourY)
{
this.getScrollContainer()!.style.setProperty("overscroll-behaviour-y",this.originalOverscrollBehaviourY);
this.originalOverscrollBehaviourY = undefined;
}
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = undefined;
}
this.stopAnimation();
this.stopAutoScroll();
if (this.dragStarted && this.captureElement) {
try {
this.captureElement.releasePointerCapture(this.pointerId);
} catch (error)
{
// throws if we've already lost it.
}
}
this.dragStarted = false;
if (this.dragTarget) {
this.clearDragTransform(this.dragTarget);
this.dragTarget = null;
}
this.captureElement = undefined;
this.mouseDown = false;
this.unprepareChildren();
document.body.removeEventListener("pointerdown", this.handlePointerDownCapture, true);
}
prepareChildren() {
if (this.refGrid.current === null) return;
let grid = this.refGrid.current;
let nodes = grid.childNodes;
let childRects = [];
let gridClientRect = grid.getBoundingClientRect();
for (let i = 0; i < nodes.length; ++i) {
let child = nodes[i] as HTMLDivElement;
let rect = child.getBoundingClientRect();
rect.x -= gridClientRect.left;
rect.y -= gridClientRect.top;
childRects[i] = rect;
}
for (let i = 0; i < nodes.length; ++i) {
let child = nodes[i] as HTMLDivElement;
child.style.position = "relative";
child.style.left = "0px";
child.style.top = "0px";
}
this.animationData = [];
for (let i = 0; i < nodes.length; ++i) {
this.animationData[i] = new AnimationData(nodes[i] as HTMLElement, childRects[i]);
}
grid.style.height = this.gridBounds.height + "px";
grid.style.width = this.gridBounds.width + "px";
}
reprepareChildren()
{
if (this.refGrid.current === null) return;
let grid = this.refGrid.current;
let nodes = grid.childNodes;
for (let i = 0; i < nodes.length; ++i) {
let child = nodes[i] as HTMLDivElement;
child.style.position = "relative";
child.style.left = "0px";
child.style.top = "0px";
}
if (nodes.length !== this.animationData.length)
{
this.cancelDrag();
return;
}
for (let i = 0; i < nodes.length; ++i) {
let key: any = (nodes[i] as any).key;
if (nodes[i] as HTMLElement !== this.animationData[i].element) {
console.log("Animation failed: Element " + i + " changed.");
}
if (key !== this.animationData[i].key) {
console.log("Animation failed: Element " + i + " key has changed.");
}
this.animationData[i].element = nodes[i] as HTMLElement;
if (i !== this.startIndex)
{
this.animationData[i].animating = true;
}
}
grid.style.height = this.gridBounds.height + "px";
grid.style.width = this.gridBounds.width + "px";
this.animationTick();
}
getIndexFromPosition(clientX: number, clientY: number)
{
let grid = nullCast(this.refGrid.current);
let gridBounds = grid.getBoundingClientRect();
clientX -= gridBounds.left;
clientY -= gridBounds.top;
if (clientY < 0) return -1;
for (let i = 0; i < this.animationData.length; ++i)
{
let item = this.animationData[i];
if (clientY >= item.originalPosition.x && clientY < item.originalPosition.bottom)
{
return i;
}
}
return -1;
}
getDragIndexFromPosition(clientX: number, clientY: number): number {
let grid = nullCast(this.refGrid.current);
let originalBounds = this.animationData[this.startIndex].originalPosition;
// Base test on the CENTER point of the currently dragged item.
clientX += originalBounds.width / 2 - this.ptItemStart.x;
clientY += originalBounds.height / 2 - this.ptItemStart.y;
let gridBounds = grid.getBoundingClientRect();
clientX -= gridBounds.x;
clientY -= gridBounds.top;
// clientX/Y is now the position of the center of the currently-dragged item, in the coordinates of the contenst of the scroll view (scroll applied)
if (clientY < 0) return 0;
for (let i = 0; i < this.animationData.length; ++i) {
let rect = this.animationData[i].originalPosition;
if (clientY >= rect.top && clientY < rect.bottom)
{
return i;
}
}
return this.animationData.length-1;
}
unprepareChildren() {
if (this.refGrid.current === null) return;
let grid = this.refGrid.current;
let nodes = grid.childNodes;
for (let i = 0; i < nodes.length; ++i) {
let child = nodes[i] as HTMLDivElement;
child.style.position = "";
child.style.top = ""
child.style.left = "";
child.style.width = "";
child.style.height = "";
}
grid.style.height = "";
grid.style.width = "100%";
// releast refs and storage.
this.dragTarget = null;
this.animationData = [];
}
dragTarget: HTMLDivElement | null = null;
elementKeys: any[] = [];
clearDragTransform(element: HTMLDivElement): void {
element.style.transform = "";
element.style.zIndex = this.savedIndex;
element.style.background = this.savedBackground;
element.style.opacity = "1";
}
handlePointerCancel(e: PointerEvent<HTMLDivElement>) {
if (this.isCapturedPointer(e)) {
this.cancelDrag();
if (this.props.onDragCancel) {
this.props.onDragCancel(e.clientX, e.clientY);
}
}
}
handlePointerUp(e: PointerEvent<HTMLDivElement>) {
if (this.isCapturedPointer(e)) {
this.assertKeysNotChanged(this.elementKeys); // make sure that a late render hasn't swapped elements while we're dragging.
this.handlePointerMove(e);
e.preventDefault();
e.stopPropagation();
if (this.dragStarted) {
this.lastOnDragTime = new Date().getTime();
if (this.props.onDragEnd) {
this.props.onDragEnd(
this.startIndex, this.currentIndex,
e.clientX, e.clientY);
}
if (this.startIndex !== this.currentIndex) {
this.props.moveElement(this.startIndex, this.currentIndex);
}
}
this.cancelDrag();
}
}
dragThresholdExceeded(e: PointerEvent<HTMLDivElement>) {
if (this.pointerType === "touch")
{
let timeMs = new Date().getTime();
let dt = timeMs -this.lastPointerDown;
if (dt < LONG_PRESS_TIME_MS) return false;
}
let dx = e.clientX - this.startX;
let dy = e.clientY - this.startY;
let d2 = dx * dx + dy * dy;
let DRAG_THRESHOLD = 5;
if (this.pointerType === "touch")
{
DRAG_THRESHOLD = 15;
}
return (d2 > DRAG_THRESHOLD * DRAG_THRESHOLD);
}
lastX: number = 0;
lastY: number = 0;
translateViewPortPoint(fromElement: HTMLElement, toElement: HTMLElement, pt: Point): Point {
let rcFrom = fromElement.getBoundingClientRect();
let rcTo = toElement.getBoundingClientRect();
return {
x: pt.x + rcFrom.left + fromElement.scrollLeft - rcTo.left - toElement.scrollLeft,
y: pt.y + rcFrom.top + fromElement.scrollTop - rcTo.top - toElement.scrollTop
};
}
makeTransform(targetElement: HTMLElement, clientX: number, clientY: number): string {
// the bounds of the translated element must COMPLETELY fit within the scroll range of the container.
let originalBounds = this.animationData[this.startIndex].originalPosition;
let newLocation = new DOMRect(
originalBounds.x + clientX - this.startX,
originalBounds.y + clientY - this.startY,
originalBounds.width,
originalBounds.height);
if (newLocation.x < 0) {
newLocation.x = 0;
} else if (newLocation.right >= this.gridBounds.width) {
newLocation.x -= newLocation.right - this.gridBounds.width;
}
// chrome: inconsistent scroll height calculations.
let maxY = this.gridBounds.height;
if (newLocation.top < 0) {
newLocation.y = 0;
} else if (newLocation.bottom > maxY) {
newLocation.y -= newLocation.bottom - maxY;
}
return "translate(" + (newLocation.x - originalBounds.x) + "px,"
+ (newLocation.y - originalBounds.y) + "px) scale(" + SELECT_SCALE + ")";
}
createOriginalBounds(): DOMRect {
// bounds of the target element, in ScrollContainer coordinates.
let rcTarget = nullCast(this.dragTarget).getBoundingClientRect();
let rcScroll = nullCast(this.getScrollContainer()).getBoundingClientRect();
rcTarget.x -= rcScroll.x;
rcTarget.y -= rcScroll.y;
return rcTarget;
}
startIndex: number = 0;
currentIndex: number = 0;
reflowChildren(): void {
let y = 0;
for (let i = 0; i < this.animationData.length; ++i) {
if (i === this.startIndex) {
if (i === this.currentIndex) {
y += this.animationData[this.startIndex].originalPosition.height;
}
} else {
if (i === this.currentIndex && this.currentIndex < this.startIndex)
{
y += this.animationData[this.startIndex].originalPosition.height;
}
let animationItem = this.animationData[i];
animationItem.setPosition(y);
y += animationItem.originalPosition.height;
if (i === this.currentIndex && this.currentIndex > this.startIndex)
{
y += this.animationData[this.startIndex].originalPosition.height;
}
}
}
this.stopAnimation();
this.animationStartTime = new Date().getTime();
this.animationTick();
}
itemOffsetX: number = 0;
itemOffsetY: number = 0;
longPressTimer?: number = undefined;
longPressTimerTick() {
if (navigator.vibrate)
{
navigator.vibrate([5]);
}
if (!this.dragStarted)
{
this.startDrag();
}
}
handlePointerDown(e: PointerEvent<HTMLDivElement>): void {
if (this.props.canDrag !== undefined && !this.props.canDrag) return;
// any new pointer down cancels a drag in progress.
this.cancelDrag();
// avoid 2nd click of a double click (which is difficult to cancel)
let timeMs = new Date().getTime();
if (timeMs - this.lastPointerDown < 300) {
this.lastPointerDown = timeMs;
return;
}
this.lastPointerDown = timeMs;
let gridElement = this.findGridItem(e.target);
if (gridElement === null) return;
if (!this.mouseDown && this.isValidPointer(e)) {
this.dragTarget = gridElement;
document.body.addEventListener("pointerdown", this.handlePointerDownCapture, true);
this.captureElement = e.currentTarget;
this.elementKeys = this.getElementKeys();
this.longPressTimer = setTimeout(this.longPressTimerTick,LONG_PRESS_TIME_MS);
this.mouseDown = true;
///window.addEventListener("touchstart",this.handleTouchStart, {passive: false});
this.dragStarted = false;
this.startX = e.clientX;
this.startY = e.clientY;
this.startClientX = e.clientX;
this.startClientY = e.clientY;
this.pointerId = e.pointerId;
this.pointerType = e.pointerType;
this.savedIndex = gridElement.style.zIndex;
gridElement.style.zIndex = "3";
this.savedBackground = gridElement.style.background;
gridElement.style.background = isDarkMode()? "333": "#EEE";
gridElement.style.opacity = "0.8";
}
}
originalOverscrollBehaviourY?: string;
gridBounds: DOMRect = new DOMRect();
startDrag() {
this.dragStarted = true;
let grid = this.refGrid.current!;
this.gridBounds = grid.getBoundingClientRect();
window.addEventListener("touchmove",this.handleTouchMove, {passive: false});
window.addEventListener("touchend",this.handleTouchEnd, {passive: false});
this.prepareChildren();
this.startIndex = this.getIndexFromPosition(this.startX, this.startY);
if (this.startIndex === -1)
{
this.cancelDrag();
return;
}
let t = grid.childNodes[this.startIndex];
if (t !== this.dragTarget) {
console.count("dragTarget mismatch!");
this.startIndex = this.getIndexFromPosition(this.startX,this.startY);
this.dragTarget = grid.childNodes[this.startIndex] as HTMLDivElement;
}
this.currentIndex = this.startIndex;
if (this.props.onDragStart) {
this.props.onDragStart(this.startIndex, this.startX, this.startY);
}
this.dragTarget!.setPointerCapture(this.pointerId);
let rcItem = this.dragTarget!.getBoundingClientRect();
this.ptItemStart = new DOMPoint(this.startX - rcItem.x, this.startY - rcItem.y);
this.originalOverscrollBehaviourY = this.getScrollContainer()!.style.getPropertyValue("overscroll-behaviour-y");
this.getScrollContainer()!.style.setProperty("overscroll-behaviour-y","none");
if (this.pointerType === "touch")
{
this.startY -= 4; // causes the selected item to "pop"
}
}
handlePointerMove(e: PointerEvent<HTMLDivElement>): void {
if (this.isCapturedPointer(e)) {
if (!this.dragStarted && this.dragThresholdExceeded(e)) {
this.startDrag();
}
if (this.dragStarted) {
this.lastX = e.clientX;
this.lastY = e.clientY;
if (this.dragTarget !== null) {
this.dragTarget.style.transform = this.makeTransform(this.dragTarget, e.clientX, e.clientY);
let index = this.getDragIndexFromPosition(e.clientX, e.clientY);
if (index !== this.currentIndex) {
this.currentIndex = index;
this.reflowChildren();
}
}
e.stopPropagation();
e.preventDefault();
if (this.props.onDragMove) {
this.props.onDragMove(this.currentIndex, e.clientX, e.clientY);
}
this.checkForAutoScroll(e.currentTarget);
}
}
}
autoScrollTimer?: number;
getElementKeys(): any[] {
let grid = nullCast(this.refGrid.current);
let children = grid.childNodes;
let result: any[] = [];
for (let i = 0; i < children.length; ++i)
{
result[i] = (children[i] as any).key;
}
return result;
}
assertKeysNotChanged(oldKeys: any[])
{
let newKeys = this.getElementKeys();
for (let i = 0; i < newKeys.length; ++i)
{
if (newKeys[i] !== oldKeys[i])
{
throw new Error("Keys have changed.");
}
}
}
handleAutoScrollTick(currentTarget: HTMLElement, dx: number, dy: number) {
if (!this.mouseDown) return;
let scrollContainer = this.getScrollContainer();
if (scrollContainer) {
let tx = scrollContainer.scrollLeft;
let ty = scrollContainer.scrollTop;
let x = scrollContainer.scrollLeft + dx;
let y = scrollContainer.scrollTop + dy;
x = Math.min(x,scrollContainer.scrollWidth-scrollContainer.clientWidth);
y = Math.min(y,scrollContainer.scrollHeight-scrollContainer.clientHeight);
if (x < 0) x = 0;
if (y < 0) y = 0;
scrollContainer.scrollTo(x, y);
this.startX -= (scrollContainer.scrollLeft - tx);
this.startY -= (scrollContainer.scrollTop - ty);
if (this.dragTarget) {
this.dragTarget.style.transform = this.makeTransform(this.dragTarget, this.lastX, this.lastY);
}
this.checkForAutoScroll(currentTarget);
}
}
stopAutoScroll() {
if (this.autoScrollTimer !== undefined) {
clearTimeout(this.autoScrollTimer);
this.autoScrollTimer = undefined;
}
}
startAutoScroll(scrollContainer: HTMLElement, dx: number, dy: number) {
this.stopAutoScroll();
this.autoScrollTimer = setTimeout(
() => { this.handleAutoScrollTick(scrollContainer, dx, dy) }, AUTOSCROLL_TICK_DELAY);
}
checkForAutoScroll(currentTarget: HTMLElement) {
let scrollContainer = this.getScrollContainer();
if (!scrollContainer) {
this.stopAutoScroll();
return;
}
let scrollContainerRect = scrollContainer.getBoundingClientRect();
let dy: number = 0;
let dx: number = 0;
if (this.lastX < scrollContainerRect.x + AUTOSCROLL_THRESHOLD) {
dx = -AUTOSCROLL_SCROLL_RATE;
if (dx < -scrollContainer.scrollLeft) {
dx = -scrollContainer.scrollLeft;
}
} else if (this.lastX >= scrollContainerRect.right - AUTOSCROLL_THRESHOLD) {
dx = AUTOSCROLL_SCROLL_RATE;
let maxScroll = Math.max(scrollContainer.scrollWidth - scrollContainer.clientWidth, 0);
if (dx > maxScroll) dx = maxScroll;
}
if (this.lastY < scrollContainerRect.top) {
dy = -AUTOSCROLL_SCROLL_RATE;
if (dy < -scrollContainer.scrollTop) dy = -scrollContainer.scrollTop;
} else if (this.lastY >= scrollContainerRect.bottom - AUTOSCROLL_THRESHOLD) {
dy = AUTOSCROLL_SCROLL_RATE;
let maxScroll = Math.max(scrollContainer.scrollHeight - scrollContainer.clientHeight);
if (dy > maxScroll) dy = maxScroll;
}
if (dx === 0 && dy === 0) {
this.stopAutoScroll();
return;
} else {
this.startAutoScroll(currentTarget, dx, dy);
}
}
getScrollContainer(): HTMLDivElement | null {
return this.refScrollContainer.current;
}
overflowX(): Property.OverflowX {
return this.props.scroll === ScrollDirection.X ? "auto" : "hidden";
}
overflowY(): Property.OverflowX {
return this.props.scroll === ScrollDirection.Y ? "auto" : "hidden";
}
render() {
return (
<div
style={{
transform: "",
overflowX: this.overflowX(),
overflowY: this.overflowY(),
width: this.props.fullWidth??true? "100%": "auto",
height: this.props.fullHeight??true? "100%": "auto",
}}
onPointerDown={this.handlePointerDown}
onPointerMove={this.handlePointerMove}
onPointerCancel={this.handlePointerCancel}
onPointerCancelCapture={this.handlePointerCancel}
onPointerUp={this.handlePointerUp}
onDragStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={this.onClick}
ref={this.refScrollContainer}
>
<div style={{ display: "flex", flexDirection: "column", flexWrap: "nowrap", position: "relative" }}
ref={this.refGrid}
>
{this.props.children}
</div>
</div>
)
}
}
);
export default DraggableGrid;
+268
View File
@@ -0,0 +1,268 @@
/*
* MIT License
*
* Copyright (c) 2023 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Component, SyntheticEvent } from 'react';
import { Theme } from '@mui/material/styles';
import { css } from '@emotion/react';
import { UiFileProperty } from './Lv2Plugin';
import Typography from '@mui/material/Typography';
import { PiPedalModel, PiPedalModelFactory, ListenHandle, State } from './PiPedalModel';
import ButtonBase from '@mui/material/ButtonBase'
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import { PedalboardItem } from './Pedalboard';
import { isDarkMode } from './DarkMode';
import Tooltip from "@mui/material/Tooltip"
export const StandardItemSize = { width: 80, height: 140 }
import { withStyles } from "tss-react/mui";
import WithStyles from './WithStyles';
import { withTheme } from './WithStyles';
const styles = (theme: Theme) => {
return {
frame: css({
position: "relative",
margin: "12px"
}),
switchTrack: css({
height: '100%',
width: '100%',
borderRadius: 14 / 2,
zIndex: -1,
transition: theme.transitions.create(['opacity', 'background-color'], {
duration: theme.transitions.duration.shortest
}),
backgroundColor: theme.palette.secondary.main,
opacity: theme.palette.mode === 'light' ? 0.38 : 0.3
}),
controlFrame: css({
display: "flex", flexDirection: "column", alignItems: "start", justifyContent: "space-between", height: 116
}),
displayValue: css({
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 4,
textAlign: "center",
background: theme.mainBackground,
color: theme.palette.text.secondary,
// zIndex: -1,
}),
titleSection: css({
flex: "0 0 auto", alignSelf: "stretch", marginBottom: 8, marginLeft: 8, marginRight: 0
}),
midSection: css({
flex: "1 1 1", display: "flex", flexFlow: "column nowrap", alignContent: "center", justifyContent: "center"
}),
editSection: css({
flex: "0 0 0", position: "relative", width: 60, height: 28, minHeight: 28
})
}
}
;
export interface FilePropertyControlProps extends WithStyles<typeof styles> {
fileProperty: UiFileProperty;
pedalboardItem: PedalboardItem;
onFileClick: (fileProperty: UiFileProperty, value: string) => void;
theme: Theme;
}
type FilePropertyControlState = {
error: boolean;
hasValue: boolean;
value: string;
};
const FilePropertyControl =
withTheme(withStyles(
class extends Component<FilePropertyControlProps, FilePropertyControlState> {
model: PiPedalModel;
constructor(props: FilePropertyControlProps) {
super(props);
this.state = {
error: false,
hasValue: false,
value: "",
};
this.model = PiPedalModelFactory.getInstance();
this.onStateChanged = this.onStateChanged.bind(this);
}
onStateChanged(state: State) {
if (this.mounted) {
if (state === State.Ready) {
this.subscribeToPatchProperty();
}
}
}
// private propertyGetHandle?: ListenHandle;
monitorPropertyHandle?: ListenHandle;
subscribeToPatchProperty() {
// this.propertyGetHandle = this.model.listenForAtomOutput(this.props.instanceId,(instanceId,atomOutput)=>{
// // PARSE THE OBJECT!
// // this.setState({value: atomOutput?.value as string});
// });
this.unsubscribeToPatchProperty();
this.monitorPropertyHandle = this.model.monitorPatchProperty(this.props.pedalboardItem.instanceId,
this.props.fileProperty.patchProperty,
(instanceId, property, propertyValue) => {
if (propertyValue.otype_ === "Path") {
this.setState({ value: propertyValue.value as string, hasValue: true });
}
}
);
}
unsubscribeToPatchProperty() {
if (this.monitorPropertyHandle !== undefined) {
this.model.cancelMonitorPatchProperty(this.monitorPropertyHandle);
this.monitorPropertyHandle = undefined;
}
}
private mounted: boolean = false;
componentDidMount() {
this.model.state.addOnChangedHandler(this.onStateChanged);
this.mounted = true;
this.subscribeToPatchProperty();
}
componentWillUnmount() {
this.unsubscribeToPatchProperty();
this.mounted = false;
this.model.state.removeOnChangedHandler(this.onStateChanged);
}
componentDidUpdate(prevProps: Readonly<FilePropertyControlProps>, prevState: Readonly<FilePropertyControlState>, snapshot?: any): void {
if (prevProps.fileProperty.patchProperty !== this.props.fileProperty.patchProperty
|| prevProps.pedalboardItem.instanceId !== this.props.pedalboardItem.instanceId
|| prevProps.pedalboardItem.stateUpdateCount !== this.props.pedalboardItem.stateUpdateCount
) {
this.unsubscribeToPatchProperty();
this.subscribeToPatchProperty();
}
}
inputChanged: boolean = false;
onDrag(e: SyntheticEvent) {
e.preventDefault();
}
onFileClick() {
this.props.onFileClick(this.props.fileProperty, this.state.value);
}
private fileNameOnly(path: string): string {
let slashPos = path.lastIndexOf('/');
if (slashPos < 0) {
slashPos = 0;
} else {
++slashPos;
}
let extPos = path.lastIndexOf('.');
if (extPos < 0 || extPos < slashPos) {
extPos = path.length;
}
return path.substring(slashPos, extPos);
}
render() {
//const classes = withStyles.getClasses(this.props);
const classes = withStyles.getClasses(this.props);
let fileProperty = this.props.fileProperty;
let value = "\u00A0";
if (this.state.hasValue) {
if (this.state.value.length === 0) {
value = "<none>";
} else {
value = this.fileNameOnly(this.state.value);
}
}
let item_width = 264;
return (
<div className={classes.controlFrame}
style={{ width: item_width }}>
{/* TITLE SECTION */}
<div className={classes.titleSection}
>
<Tooltip title={fileProperty.label} placement="top-start" arrow
enterDelay={1000} enterNextDelay={1000}
>
<Typography variant="caption" display="block" noWrap style={{
width: "100%",
textAlign: "start"
}}> {fileProperty.label}</Typography>
</Tooltip>
</div>
{/* CONTROL SECTION */}
<div className={classes.midSection} style={{ width: "100%", paddingLeft: 8 }}>
<ButtonBase style={{ width: "100%", borderRadius: "4px 4px 0px 0px", overflow: "hidden" }} onClick={() => { this.onFileClick() }} >
<div style={{
width: "100%", background:
isDarkMode() ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.07)",
borderRadius: "4px 4px 0px 0px"
}}>
<div style={{ display: "flex", alignItems: "center", flexFlow: "row nowrap" }}>
<Typography noWrap={true} style={{ flex: "1 1 100%", textAlign: "start", verticalAlign: "center", paddingTop: 4, paddingBottom: 4, paddingLeft: 4 }}
variant='caption'
>{value}</Typography>
<MoreHorizIcon style={{ flex: "0 0 auto", width: "16px", height: "16px", verticalAlign: "center", opacity: 0.5, marginRight: 4 }} />
</div>
<div style={{ height: "1px", width: "100%", background: this.props.theme.palette.text.secondary }}>&nbsp;</div>
</div>
</ButtonBase>
</div>
{/* LABEL/EDIT SECTION*/}
<div className={classes.editSection} >
</div>
</div >
);
}
}
,styles)
)
;
export default FilePropertyControl;
+914
View File
@@ -0,0 +1,914 @@
// Copyright (c) 2023 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import {createStyles} from './WithStyles';
import { Theme } from '@mui/material/styles';
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
import RenameDialog from './RenameDialog';
import Divider from '@mui/material/Divider';
import MenuItem from '@mui/material/MenuItem';
import Menu from '@mui/material/Menu';
import MoreIcon from '@mui/icons-material/MoreVert';
import { PiPedalModel, PiPedalModelFactory, FileEntry, BreadcrumbEntry, FileRequestResult } from './PiPedalModel';
import { isDarkMode } from './DarkMode';
import Button from '@mui/material/Button';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import AudioFileIcon from '@mui/icons-material/AudioFile';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import FolderIcon from '@mui/icons-material/Folder';
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
import OldDeleteIcon from './OldDeleteIcon';
import Toolbar from '@mui/material/Toolbar';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { UiFileProperty } from './Lv2Plugin';
import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography';
import DialogEx from './DialogEx';
import UploadFileDialog from './UploadFileDialog';
import OkCancelDialog from './OkCancelDialog';
import HomeIcon from '@mui/icons-material/Home';
import FilePropertyDirectorySelectDialog from './FilePropertyDirectorySelectDialog';
const styles = (theme: Theme) => createStyles({
secondaryText: {
color: theme.palette.text.secondary
},
});
const audioFileExtensions: { [name: string]: boolean } = {
".wav": true,
".flac": true,
".ogg": true,
".aac": true,
".au": true,
".snd": true,
".mid": true,
".rmi": true,
".mp3": true,
".mp4": true,
".aif": true,
".aifc": true,
".aiff": true,
".ra": true
};
function isAudioFile(filename: string) {
let npos = filename.lastIndexOf('.');
let nposSlash = filename.lastIndexOf('/');
if (nposSlash >= npos) {
return false;
}
let extension = filename.substring(npos);
return audioFileExtensions[extension] !== undefined;
}
export interface FilePropertyDialogProps extends WithStyles<typeof styles> {
open: boolean,
fileProperty: UiFileProperty,
instanceId: number,
selectedFile: string,
onOk: (fileProperty: UiFileProperty, selectedItem: string) => void,
onCancel: () => void
};
export interface FilePropertyDialogState {
fullScreen: boolean;
selectedFile: string;
selectedFileIsDirectory: boolean;
selectedFileProtected: boolean;
hasSelection: boolean;
canDelete: boolean;
fileResult: FileRequestResult;
navDirectory: string;
uploadDirectory: string;
isProtectedDirectory: boolean;
columns: number;
columnWidth: number;
openUploadFileDialog: boolean;
openConfirmDeleteDialog: boolean;
menuAnchorEl: null | HTMLElement;
newFolderDialogOpen: boolean;
renameDialogOpen: boolean;
moveDialogOpen: boolean;
};
function pathExtension(path: string) {
let dotPos = path.lastIndexOf('.');
if (dotPos === -1) return "";
let slashPos = path.lastIndexOf('/');
if (slashPos !== -1) {
if (dotPos <= slashPos + 1) return "";
}
return path.substring(dotPos); // include the '.'.
}
function pathParentDirectory(path: string) {
let npos = path.lastIndexOf('/');
if (npos === -1) return "";
return path.substring(0, npos);
}
function pathConcat(left: string, right: string) {
if (left === "") return right;
if (right === "") return left;
if (left.endsWith('/')) {
left = left.substring(0, left.length - 1);
}
if (right.startsWith("/")) {
right = right.substring(1);
}
return left + "/" + right;
}
export function pathFileNameOnly(path: string): string {
if (path === "..") return path;
let slashPos = path.lastIndexOf('/');
if (slashPos < 0) {
slashPos = 0;
} else {
++slashPos;
}
let extPos = path.lastIndexOf('.');
if (extPos < 0 || extPos < slashPos) {
extPos = path.length;
}
return path.substring(slashPos, extPos);
}
export function pathFileName(path: string): string {
if (path === "..") return path;
let slashPos = path.lastIndexOf('/');
if (slashPos < 0) {
slashPos = 0;
} else {
++slashPos;
}
return path.substring(slashPos);
}
export default withStyles(
class FilePropertyDialog extends ResizeResponsiveComponent<FilePropertyDialogProps, FilePropertyDialogState> {
getNavDirectoryFromFile(selectedFile: string, fileProperty: UiFileProperty): string {
if (selectedFile === "") {
return "";
}
return pathParentDirectory(selectedFile);
}
constructor(props: FilePropertyDialogProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
let selectedFile = props.selectedFile;
this.state = {
fullScreen: this.getFullScreen(),
selectedFile: selectedFile,
selectedFileProtected: true,
selectedFileIsDirectory: false,
navDirectory: this.getNavDirectoryFromFile(selectedFile, props.fileProperty),
uploadDirectory: "",
hasSelection: false,
canDelete: false,
columns: 0,
columnWidth: 1,
fileResult: new FileRequestResult(),
isProtectedDirectory: true,
openUploadFileDialog: false,
openConfirmDeleteDialog: false,
menuAnchorEl: null,
newFolderDialogOpen: false,
renameDialogOpen: false,
moveDialogOpen: false
};
this.requestScroll = true;
}
getFullScreen() {
return window.innerWidth < 450 || window.innerHeight < 450;
}
private scrollRef: HTMLDivElement | null = null;
onScrollRef(element: HTMLDivElement | null) {
this.scrollRef = element;
this.maybeScrollIntoView();
}
private maybeScrollIntoView() {
if (this.scrollRef !== null && this.state.fileResult.files.length !== 0 && this.mounted && this.requestScroll) {
this.requestScroll = false;
let options: ScrollIntoViewOptions = { block: "nearest" };
options.block = "nearest";
this.scrollRef.scrollIntoView(options);
}
}
private mounted: boolean = false;
private model: PiPedalModel;
private requestFiles(navPath: string) {
if (!this.props.open) {
return;
}
if (this.props.fileProperty.directory === "") {
return;
}
this.model.requestFileList2(navPath, this.props.fileProperty)
.then((filesResult) => {
if (this.mounted) {
// let insertionPoint = files.length;
// for (let i = 0; i < files.length; ++i) {
// if (!files[i].isDirectory) {
// insertionPoint = i;
// break;
// }
// }
filesResult.files.splice(0, 0, { pathname: "", displayName: "<none>", isDirectory: false, isProtected: true });
let fileEntry = this.getFileEntry(filesResult.files, this.state.selectedFile);
this.setState({
isProtectedDirectory: filesResult.isProtected,
fileResult: filesResult,
hasSelection: !!fileEntry,
selectedFileProtected: fileEntry ? fileEntry.isProtected: true,
navDirectory: navPath,
uploadDirectory: filesResult.currentDirectory
});
}
}).catch((error) => {
if (!this.mounted) return;
if (navPath !== "") // deleted sample directory maybe?
{
this.navigate("");
} else {
this.model.showAlert(error.toString())
}
});
}
private lastDivRef: HTMLDivElement | null = null;
onMeasureRef(div: HTMLDivElement | null) {
this.lastDivRef = div;
if (div) {
let width = div.offsetWidth;
if (width === 0) return;
let columns = 1;
let columnWidth = (width - 40) / columns;
if (columns !== this.state.columns || columnWidth !== this.state.columnWidth) {
this.setState({ columns: columns, columnWidth: columnWidth });
}
}
}
onWindowSizeChanged(width: number, height: number): void {
this.setState({
fullScreen: this.getFullScreen()
})
if (this.lastDivRef !== null) {
this.onMeasureRef(this.lastDivRef);
}
}
private requestScroll: boolean = false;
componentDidMount() {
super.componentDidMount();
this.mounted = true;
this.requestFiles(this.state.navDirectory)
this.requestScroll = true;
}
componentWillUnmount() {
super.componentWillUnmount();
this.mounted = false;
this.lastDivRef = null;
}
componentDidUpdate(prevProps: Readonly<FilePropertyDialogProps>, prevState: Readonly<FilePropertyDialogState>, snapshot?: any): void {
super.componentDidUpdate?.(prevProps, prevState, snapshot);
if (prevProps.open !== this.props.open || prevProps.fileProperty !== this.props.fileProperty || prevProps.selectedFile !== this.props.selectedFile) {
if (this.props.open) {
let selectedFile = this.props.selectedFile;
let navDirectory = this.getNavDirectoryFromFile(selectedFile, this.props.fileProperty);
this.setState({
selectedFile: selectedFile,
selectedFileIsDirectory: false,
selectedFileProtected: true,
newFolderDialogOpen: false,
renameDialogOpen: false,
moveDialogOpen: false,
navDirectory: navDirectory
});
this.requestFiles(navDirectory)
this.requestScroll = true;
}
}
}
private isDirectory(path: string): boolean {
for (var fileEntry of this.state.fileResult.files) {
if (fileEntry.pathname === path) {
return fileEntry.isDirectory;
}
}
return false;
}
private isFileInList(files: FileEntry[], file: string) {
let hasSelection = false;
if (file === "") return true;
for (var listFile of files) {
if (listFile.pathname === file) {
hasSelection = true;
break;
}
}
return hasSelection;
}
private getFileEntry(files: FileEntry[], file: string): FileEntry | undefined {
if (file === "") return undefined;
for (var listFile of files) {
if (listFile.pathname === file) {
return listFile;
}
}
return undefined;
}
onSelectValue(fileEntry: FileEntry) {
this.requestScroll = true;
this.setState({
selectedFile: fileEntry.pathname,
selectedFileIsDirectory: fileEntry.isDirectory,
selectedFileProtected: fileEntry.isProtected,
hasSelection: this.isFileInList(this.state.fileResult.files, fileEntry.pathname)
})
}
onDoubleClickValue(selectedFile: string) {
this.openSelectedFile();
}
handleMenuOpen(event: React.MouseEvent<HTMLElement>) {
this.setState({ menuAnchorEl: event.currentTarget });
}
handleMenuClose() {
this.setState({ menuAnchorEl: null });
}
handleDelete() {
if (this.state.selectedFileProtected) {
return;
}
this.setState({ openConfirmDeleteDialog: true });
}
handleConfirmDelete() {
if (this.state.selectedFileProtected) {
return;
}
this.setState({ openConfirmDeleteDialog: false });
if (this.state.hasSelection) {
let selectedFile = this.state.selectedFile;
let position = -1;
for (let i = 0; i < this.state.fileResult.files.length; ++i) {
let file = this.state.fileResult.files[i];
if (file.pathname === selectedFile) {
position = i;
}
}
let newSelection: FileEntry | undefined = undefined;
if (position >= 0 && position < this.state.fileResult.files.length - 1) {
newSelection = this.state.fileResult.files[position + 1];
} else if (position === this.state.fileResult.files.length - 1) {
if (position !== 0) {
newSelection = this.state.fileResult.files[position - 1];
}
}
this.model.deleteUserFile(this.state.selectedFile)
.then(
() => {
if (newSelection)
{
this.setState({
selectedFile: newSelection.pathname,
selectedFileIsDirectory: newSelection.isDirectory,
selectedFileProtected: newSelection.isProtected,
hasSelection: true
});
} else {
this.setState({
selectedFile: "",
selectedFileIsDirectory: false,
selectedFileProtected: true,
hasSelection: false
});
}
this.requestFiles(this.state.navDirectory);
}
).catch(
(e: any) => {
this.model.showAlert(e.toString());
}
);
}
}
navigate(relativeDirectory: string) {
this.requestFiles(relativeDirectory);
this.setState({ navDirectory: relativeDirectory });
}
renderBreadcrumbs() {
if (this.state.navDirectory === "") {
return (<Divider />);
}
let breadcrumbs: React.ReactElement[] = [(
<Button variant="text"
color="inherit"
key="h" onClick={() => { this.navigate(""); }}
style={{ textTransform: "none", flexShrink: 0 }}
startIcon={(<HomeIcon sx={{ mr: 0.6 }} fontSize="inherit" />)}
>
<Typography style={{}} variant="body2" noWrap>Home</Typography>
</Button>
)
];
for (let i = 1; i < this.state.fileResult.breadcrumbs.length - 1; ++i) {
let breadcrumb: BreadcrumbEntry = this.state.fileResult.breadcrumbs[i];
breadcrumbs.push((
<Typography key={"x" + (i)} variant="body2" style={{ marginTop: 6, marginBottom: 6 }} noWrap>/</Typography>
)
);
breadcrumbs.push((
<Button variant="text"
key={"bc" + i}
color="inherit"
onClick={() => { this.navigate(breadcrumb.pathname) }}
style={{ minWidth: 0, textTransform: "none", flexShrink: 0 }}
>
<Typography variant="body2" style={{}} noWrap>{breadcrumb.displayName}</Typography>
</Button>
));
}
if (this.state.fileResult.breadcrumbs.length > 1) {
let lastdirectory = this.state.fileResult.breadcrumbs[this.state.fileResult.breadcrumbs.length - 1];
breadcrumbs.push((
<Typography key={"xLast"} variant="body2" style={{ userSelect: "none", marginTop: 6, marginBottom: 6 }} noWrap>/</Typography>
)
);
breadcrumbs.push((
<div key={"bc-last"} style={{ flexShrink: 0, paddingLeft: 8, paddingRight: 8, paddingTop: 6, paddingBottom: 6, overflowX: "clip" }}>
<Typography noWrap
style={{}} variant="body2"
color="text.secondary"> {lastdirectory.displayName}</Typography>
</div>
));
}
return (
<div>
<div aria-label="breadcrumb"
style={{ marginLeft: 16, marginBottom: 8, marginRight: 8, textOverflow: "", display: "flex", flexFlow: "row wrap", alignItems: "center", justifyContent: "start" }}>
{breadcrumbs}
</div>
<Divider />
</div>
);
}
getDefaultPath(): string {
try {
let storage = window.localStorage;
let result = storage.getItem("fpDefaultPath");
if (result) {
return result;
}
} catch (e) {
}
return this.state.navDirectory;
}
setDefaultPath(path: string) {
try {
let storage = window.localStorage;
storage.setItem("fpDefaultPath", path);
} catch (e) {
}
}
hasSelectedFileOrFolder(): boolean {
return this.state.hasSelection && this.state.selectedFile !== "";
}
getFileExtensionList(uiFileProperty: UiFileProperty): string {
let result = "";
for (var fileType of uiFileProperty.fileTypes) {
if (fileType.fileExtension !== "" && fileType.fileExtension !== ".zip") {
if (result !== "") result = result + ",";
result += fileType.fileExtension;
}
}
return result;
}
private getIcon(fileEntry: FileEntry) {
if (fileEntry.pathname === "") {
return (<InsertDriveFileOutlinedIcon style={{ flex: "0 0 auto", opacity: 0.5, marginRight: 8, marginLeft: 8 }} />);
}
if (fileEntry.isDirectory) {
return (
<FolderIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />
);
}
if (isAudioFile(fileEntry.pathname)) {
return (<AudioFileIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />);
}
return (<InsertDriveFileIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />);
}
render() {
const classes = withStyles.getClasses(this.props);
let columnWidth = this.state.columnWidth;
let okButtonText = "Select";
if (this.state.hasSelection && this.state.selectedFileIsDirectory) {
okButtonText = "Open";
}
let protectedDirectory = this.state.fileResult.isProtected;
let protectedItem = true;
if (this.state.hasSelection)
{
protectedItem = this.state.selectedFileProtected;
}
let canMoveOrRename = this.hasSelectedFileOrFolder() && !protectedItem;
return this.props.open &&
(
<DialogEx
fullScreen={this.state.fullScreen}
onClose={() => {
this.props.onCancel();
}}
onEnterKey={() => {
this.openSelectedFile();
}}
open={this.props.open} tag="fileProperty"
fullWidth maxWidth="md"
PaperProps={
this.state.fullScreen ?
{}
:
{
style: {
minHeight: "90%",
maxHeight: "90%",
}
}}
>
<DialogTitle style={{ paddingBottom: 0 }} >
<Toolbar style={{ padding: 0 }}>
<IconButton
edge="start"
color="inherit"
aria-label="back"
style={{ opacity: 0.6 }}
onClick={() => { this.props.onCancel(); }}
>
<ArrowBackIcon style={{ width: 24, height: 24 }} />
</IconButton>
<Typography noWrap component="div" sx={{ flexGrow: 1 }}>
{this.props.fileProperty.label}
</Typography>
<IconButton
aria-label="new folder"
edge="end"
color="inherit"
style={{ opacity: 0.6, marginRight: 8 }}
onClick={(ev) => { this.onNewFolder(); }}
disabled={protectedDirectory}
>
<CreateNewFolderIcon />
</IconButton>
<IconButton
aria-label="display more actions"
edge="end"
color="inherit"
style={{ opacity: 0.6 }}
onClick={(ev) => { this.handleMenuOpen(ev); }}
disabled={protectedDirectory}
>
<MoreIcon />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={this.state.menuAnchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={this.state.menuAnchorEl !== null}
onClose={() => { this.handleMenuClose(); }}
>
<MenuItem onClick={() => { this.handleMenuClose(); this.onNewFolder(); }}>New folder</MenuItem>
{canMoveOrRename && (<Divider />)}
{canMoveOrRename && (<MenuItem onClick={() => { this.handleMenuClose(); this.onMove(); }}>Move</MenuItem>)}
{canMoveOrRename && this.hasSelectedFileOrFolder() && !protectedItem && (<MenuItem onClick={() => { this.handleMenuClose(); this.onRename(); }}>Rename</MenuItem>)}
</Menu>
</Toolbar>
<div style={{ flex: "0 0 auto", marginLeft: 0, marginRight: 0, overflowX: "clip" }}>
{this.renderBreadcrumbs()}
</div>
</DialogTitle>
<DialogContent style={{ paddingLeft: 0, paddingRight: 0, paddingTop: 0, colorScheme: (isDarkMode() ? "dark" : "light") }}>
<div
ref={(element) => this.onMeasureRef(element)}
style={{
flex: "1 1 100%", display: "flex", flexFlow: "row wrap",
justifyContent: "flex-start", alignContent: "flex-start", paddingLeft: 16, paddingBottom: 16
}}>
{
(this.state.columns !== 0) && // don't render until we have number of columns derived from layout.
this.state.fileResult.files.map(
(value: FileEntry, index: number) => {
let displayValue = value.displayName;
if (displayValue === "") {
displayValue = "<none>";
}
let selected = value.pathname === this.state.selectedFile;
let selectBg = selected ? "rgba(0,0,0,0.15)" : "rgba(0,0,0,0.0)";
if (isDarkMode()) {
selectBg = selected ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.0)";
}
let scrollRef: ((element: HTMLDivElement | null) => void) | undefined = undefined;
if (selected) {
scrollRef = (element) => { this.onScrollRef(element) };
}
return (
<ButtonBase key={value.pathname}
style={{ width: columnWidth, flex: "0 0 auto", height: 48, position: "relative" }}
onClick={() => this.onSelectValue(value)}
onDoubleClick={() => { this.onDoubleClickValue(value.pathname); }}
>
<div ref={scrollRef} style={{ position: "absolute", background: selectBg, width: "100%", height: "100%", borderRadius: 4 }} />
<div style={{ display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis", justifyContent: "start", alignItems: "center", width: "100%", height: "100%" }}>
{this.getIcon(value)}
<Typography noWrap className={classes.secondaryText} variant="body2" style={{ flex: "1 1 auto", textAlign: "left" }}>{displayValue}</Typography>
</div>
</ButtonBase>
);
}
)
}
</div>
</DialogContent>
<Divider />
<DialogActions style={{ justifyContent: "stretch" }}>
<div style={{ display: "flex", width: "100%", alignItems: "center", flexFlow: "row nowrap" }}>
<IconButton style={{ visibility: (this.state.hasSelection ? "visible" : "hidden") }} aria-label="delete" component="label" color="primary"
disabled={!this.state.hasSelection || this.state.selectedFile === "" || protectedItem}
onClick={() => this.handleDelete()} >
<OldDeleteIcon fontSize='small' />
</IconButton>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileUploadIcon />}
onClick={() => { this.setState({ openUploadFileDialog: true }) }} disabled={protectedDirectory}
>
<div>Upload</div>
</Button>
<div style={{ flex: "1 1 auto" }}>&nbsp;</div>
<Button variant="dialogSecondary" onClick={() => { this.props.onCancel(); }} aria-label="cancel">
Cancel
</Button>
<Button variant="dialogPrimary" style={{ flex: "0 0 auto" }}
onClick={() => { this.openSelectedFile(); }}
disabled={(!this.state.hasSelection) && this.state.selectedFile !== ""} aria-label="select"
>
{okButtonText}
</Button>
</div>
</DialogActions>
<UploadFileDialog
open={this.state.openUploadFileDialog}
onClose=
{
() => {
this.setState({ openUploadFileDialog: false });
}
}
uploadPage={
"uploadUserFile?directory=" + encodeURIComponent(this.state.uploadDirectory)
+ "&id=" + this.props.instanceId.toString()
+ "&property=" + encodeURIComponent(this.props.fileProperty.patchProperty)
+ "&ext="
+ encodeURIComponent(this.getFileExtensionList(this.props.fileProperty))
}
onUploaded={() => { this.requestFiles(this.state.navDirectory); }}
fileProperty={this.props.fileProperty}
/>
<OkCancelDialog open={this.state.openConfirmDeleteDialog}
text={
(this.isDirectory(this.state.selectedFile))
? "Are you sure you want to delete the selected directory and all its contents?"
: "Are you sure you want to delete the selected file?"}
okButtonText="Delete"
onOk={() => this.handleConfirmDelete()}
onClose={() => {
this.setState({ openConfirmDeleteDialog: false });
}}
/>
{
this.state.newFolderDialogOpen && (
<RenameDialog open={this.state.newFolderDialogOpen} defaultName=""
onOk={(newName) => { this.setState({ newFolderDialogOpen: false }); this.onExecuteNewFolder(newName) }}
onClose={() => { this.setState({ newFolderDialogOpen: false }); }}
acceptActionName="OK"
/>
)
}
{
this.state.renameDialogOpen && (
<RenameDialog open={this.state.renameDialogOpen} defaultName={this.renameDefaultName()}
onOk={(newName) => { this.setState({ renameDialogOpen: false }); this.onExecuteRename(newName) }}
onClose={() => { this.setState({ renameDialogOpen: false }); }}
acceptActionName="OK"
/>
)
}
{
this.state.moveDialogOpen && (
(
<FilePropertyDirectorySelectDialog
open={this.state.moveDialogOpen}
dialogTitle="Move to"
uiFileProperty={this.props.fileProperty}
defaultPath={this.getDefaultPath()}
selectedFile={this.state.selectedFile}
excludeDirectory={
this.isDirectory(this.state.selectedFile)
? this.state.selectedFile : ""}
onClose={() => { this.setState({ moveDialogOpen: false }); }}
onOk={
(path) => {
this.setState({ moveDialogOpen: false });
this.setDefaultPath(path);
this.onExecuteMove(path);
}
}
/>
)
)
}
</DialogEx>
);
}
openSelectedFile(): void {
if (this.isDirectory(this.state.selectedFile)) {
this.requestFiles(this.state.selectedFile);
this.setState({ navDirectory: this.state.selectedFile });
} else {
this.props.onOk(this.props.fileProperty, this.state.selectedFile);
}
}
private renameDefaultName(): string {
let name = this.state.selectedFile;
if (name === "") return "";
if (this.isDirectory(name)) {
return pathFileName(name);
} else {
return pathFileNameOnly(name);
}
}
private onMove(): void {
this.setState({ moveDialogOpen: true });
}
private onExecuteMove(newDirectory: string) {
let fileName = pathFileName(this.state.selectedFile);
let oldFilePath = pathConcat(this.state.navDirectory, fileName);
let newFilePath = pathConcat(newDirectory, fileName);
this.model.renameFilePropertyFile(oldFilePath, newFilePath, this.props.fileProperty)
.then(() => {
this.requestFiles(this.state.navDirectory);
})
.catch((e) => {
this.model.showAlert(e.toString());
});
}
private onRename(): void {
this.setState({ renameDialogOpen: true });
}
private onExecuteRename(newName: string) {
let newPath: string = "";
let oldPath: string = "";
if (this.isDirectory(this.state.selectedFile)) {
let oldName = pathFileName(this.state.selectedFile);
if (oldName === newName) return;
oldPath = pathConcat(this.state.navDirectory, oldName);
newPath = pathConcat(this.state.navDirectory, newName);;
} else {
let oldName = pathFileNameOnly(this.state.selectedFile);
if (oldName === newName) return;
let extension = pathExtension(this.state.selectedFile);
oldPath = pathConcat(this.state.navDirectory, oldName + extension);
newPath = pathConcat(this.state.navDirectory, newName + extension);
}
this.model.renameFilePropertyFile(oldPath, newPath, this.props.fileProperty)
.then((newPath) => {
this.setState({ selectedFile: newPath });
this.requestFiles(this.state.navDirectory);
this.requestScroll = true
})
.catch((e) => {
this.model.showAlert(e.toString());
});
}
private onNewFolder(): void {
this.setState({ newFolderDialogOpen: true });
}
private onExecuteNewFolder(newName: string) {
this.model.createNewSampleDirectory(pathConcat(this.state.navDirectory, newName), this.props.fileProperty)
.then((newPath) => {
this.setState({
selectedFile: newPath,
selectedFileIsDirectory:
true,selectedFileProtected: false
});
this.requestFiles(this.state.navDirectory);
this.requestScroll = true
})
.catch((e) => {
this.model.showAlert(e.toString());
}
);
}
},
styles);
@@ -0,0 +1,352 @@
// Copyright (c) 2024 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import Button from '@mui/material/Button';
import DialogEx from './DialogEx';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import ArrowRightIcon from '@mui/icons-material/ArrowRight';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { UiFileProperty } from './Lv2Plugin';
import FilePropertyDirectoryTree from './FilePropertyDirectoryTree';
import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography'
import Divider from '@mui/material/Divider';
import FolderIcon from '@mui/icons-material/Folder';
import HomeIcon from '@mui/icons-material/Home';
import { isDarkMode } from './DarkMode';
import IconButton from '@mui/material/IconButton';
class DirectoryTree {
name: string = "";
path: string = "";
expanded: boolean = false;
isProtected: boolean = true;
children: DirectoryTree[] = [];
constructor(tree: FilePropertyDirectoryTree, parentTree: (DirectoryTree | null) = null) {
this.name = tree.directoryName;
this.path = tree.directoryName;
this.name = tree.displayName;
this.isProtected = tree.isProtected;
for (var treeChild of tree.children) {
this.children.push(new DirectoryTree(treeChild, this));
}
}
canExpand(): boolean {
return this.children.length !== 0;
}
expand(path: string): void {
this.expanded = true;
let currentNode: DirectoryTree = this;
for (var segment of path.split('/')) {
let found = false;
for (var child of currentNode.children) {
if (child.name === segment) {
found = true;
if (child.children.length !== 0) {
child.expanded = true;
}
currentNode = child;
break;
}
}
if (!found) {
break;
}
}
}
private static remove_(directoryTree: DirectoryTree, path: string): boolean
{
for (let i = 0; i < directoryTree.children.length; ++i)
{
let child = directoryTree.children[i];
if (child.path === path)
{
directoryTree.children.splice(i,1)
return true;
}
if (DirectoryTree.remove_(child,path))
{
return true;
}
}
return false;
}
remove(path: string): boolean
{
return DirectoryTree.remove_(this,path);
}
find(path: string): DirectoryTree | null {
let currentNode: DirectoryTree = this;
for (var segment of path.split('/')) {
let found = false;
for (var child of currentNode.children) {
if (child.name === segment) {
found = true;
currentNode = child;
break;
}
}
if (!found) {
return null;
}
}
return currentNode;
}
}
export interface FilePropertyDirectorySelectDialogProps {
open: boolean,
dialogTitle: string,
uiFileProperty: UiFileProperty;
defaultPath: string,
excludeDirectory: string,
selectedFile: string,
onOk: (path: string) => void,
onClose: () => void
};
export interface FilePropertyDirectorySelectDialogState {
fullScreen: boolean;
selectedPath: string;
directoryTree?: DirectoryTree;
hasSelection: boolean;
isProtected: boolean;
directoryTreeInvalidatecount: number;
};
export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveComponent<FilePropertyDirectorySelectDialogProps, FilePropertyDirectorySelectDialogState> {
private model: PiPedalModel;
constructor(props: FilePropertyDirectorySelectDialogProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
fullScreen: false,
selectedPath: props.defaultPath,
directoryTree: undefined,
hasSelection: false,
isProtected: true,
directoryTreeInvalidatecount: 0
};
this.requestDirectoryTree();
}
mounted: boolean = false;
onWindowSizeChanged(width: number, height: number): void {
this.setState({ fullScreen: height < 200 })
}
componentDidMount() {
super.componentDidMount();
this.mounted = true;
}
componentWillUnmount() {
super.componentWillUnmount();
this.mounted = false;
}
private requestScroll = true;
requestDirectoryTree() {
if (!this.props.open) return;
this.model.getFilePropertyDirectoryTree(this.props.uiFileProperty,this.props.selectedFile)
.then((filePropertyDirectoryTree) => {
let myTree = new DirectoryTree(filePropertyDirectoryTree);
if (this.props.excludeDirectory.length !== 0)
{
myTree.remove(this.props.excludeDirectory);
}
myTree.expand(this.state.selectedPath);
let selection = myTree.find(this.state.selectedPath);
let hasSelection = !!selection;
this.setState({
directoryTree: myTree,
hasSelection: hasSelection,
isProtected: selection ? selection.isProtected : false
});
this.requestScroll = true;
})
.catch((e) => {
this.model.showAlert(e.toString());
this.setState({ hasSelection: false });
});
}
componentDidUpdate(prevProps: Readonly<FilePropertyDirectorySelectDialogProps>, prevState: Readonly<FilePropertyDirectorySelectDialogState>, snapshot: any): void {
super.componentDidUpdate?.(prevProps, prevState, snapshot);
if (prevProps.open !== this.props.open) {
if (this.props.open) {
this.setState({ selectedPath: this.props.defaultPath, hasSelection: false });
this.requestDirectoryTree();
this.requestScroll = true;
}
}
}
onOK() {
if (this.state.isProtected) {
return;
}
if (this.state.hasSelection) {
this.props.onOk(this.state.selectedPath);
}
}
onClose() {
this.props.onClose();
}
private onToggleExpand(directoryTree: DirectoryTree)
{
directoryTree.expanded = !directoryTree.expanded;
this.setState({directoryTreeInvalidatecount: this.state.directoryTreeInvalidatecount+1});
}
private onTreeClick(directoryTree: DirectoryTree) {
if (!this.state.directoryTree) return;
this.state.directoryTree.expand(directoryTree.path);
this.setState({
selectedPath: directoryTree.path,
hasSelection: true,
isProtected: directoryTree.isProtected,
directoryTreeInvalidatecount: this.state.directoryTreeInvalidatecount + 1 });
}
private renderTree_(directoryTree: DirectoryTree) {
let ref: ((element: HTMLButtonElement | null) => void) | undefined = undefined;
let selected = directoryTree.path === this.state.selectedPath;
if (selected) {
ref = (element) => {
if (this.requestScroll) {
this.requestScroll = false;
}
}
}
let selectBg = selected ? "rgba(0,0,0,0.15)" : "rgba(0,0,0,0.0)";
if (isDarkMode()) {
selectBg = selected ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.0)";
}
let showToggle = directoryTree.canExpand() && directoryTree.path.length !== 0;
return (
<div key={directoryTree.path} style={{flexGrow: 1}}>
<div style={{ width: "100%", display: "flex", flexFlow: "row", flexWrap: "nowrap", justifyContent: "flex-start", alignItems: "center" }}>
<IconButton onClick={()=>{ this.onToggleExpand(directoryTree); }}
style={{opacity: showToggle ? 1: 0}}
disabled={!showToggle}
>
{
directoryTree.expanded &&
(
<ArrowDropDownIcon />
)
}
{
!directoryTree.expanded &&
(
<ArrowRightIcon/>
)
}
</IconButton>
<ButtonBase ref={ref} style={{ flexGrow: 1, flexShrink: 1, position: "relative" }} onClick={() => { this.onTreeClick(directoryTree); }}>
<div style={{ position: "absolute", background: selectBg, width: "100%", height: "100%", borderRadius: 4 }} />
<div style={{ width: "100%", display: "flex", flexFlow: "row", flexWrap: "nowrap", justifyContent: "flex-start", alignItems: "center", paddingLeft: 8 }}>
{
directoryTree.path === "" ? (
<HomeIcon fontSize="small" />
) : (
<FolderIcon />
)
}
<Typography noWrap style={{ marginLeft: 8, marginTop: 8, marginBottom: 8, textAlign: "left", flexGrow: 1, flexShrink: 1 }} variant="body2">{directoryTree.name === "" ? "Home" : directoryTree.name}</Typography>
</div>
</ButtonBase>
</div>
{directoryTree.expanded
&& directoryTree.canExpand()
&& (
<div style={{ marginLeft: 32 }}>
{
directoryTree.children.map(
(child, index) => {
return this.renderTree_(child);
}
)
}
</div>
)
}
</div>
);
}
private renderTree() {
if (!this.state.directoryTree) { return undefined; }
return this.renderTree_(this.state.directoryTree);
}
render() {
return (
<DialogEx tag="fpDirectorySelect" open={this.props.open} fullWidth onClose={() => { this.onClose(); }} aria-labelledby="Rename-dialog-title"
fullScreen={this.state.fullScreen}
style={{ userSelect: "none" }}
onEnterKey={()=>{ this.onOK(); }}
PaperProps={{ style: { minHeight: "80%", maxHeight: "80%", minWidth: "75%", maxWidth: "75%", overflowY: "visible" } }}
>
<DialogTitle>
<Typography variant="body1">{this.props.dialogTitle}</Typography>
</DialogTitle>
<Divider />
<DialogContent style={{marginLeft: 0, paddingLeft: 0}} >
<div style={{ display: "flex", flexGrow: 1, flexShrink: 1, overflowY: "auto" }} >
{
this.renderTree()
}
</div>
</DialogContent>
<Divider />
<DialogActions>
<Button variant="dialogSecondary" onClick={() => { this.onClose(); }} color="primary">
Cancel
</Button>
<Button variant="dialogPrimary" onClick={() => { this.onOK(); }} color="secondary"
disabled={(!this.state.hasSelection) || this.state.isProtected} >
OK
</Button>
</DialogActions>
</DialogEx>
);
}
}
@@ -0,0 +1,43 @@
// Copyright (c) 2024 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export default class FilePropertyDirectoryTree {
deserialize(input: any) : FilePropertyDirectoryTree {
this.directoryName = input.directoryName;
this.displayName = input.displayName;
this.isProtected = input.isProtected;
this.children = FilePropertyDirectoryTree.deserialize_array(input.children);
return this;
}
static deserialize_array(input: any): FilePropertyDirectoryTree[] {
let result: FilePropertyDirectoryTree[] = [];
for (let i = 0; i < input.length; ++i)
{
result[i] = new FilePropertyDirectoryTree().deserialize(input[i]);
}
return result;
}
directoryName: string = "";
displayName: string = "";
isProtected: boolean = false;
children: FilePropertyDirectoryTree[] = [];
}
+242
View File
@@ -0,0 +1,242 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { ReactNode } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles, {withTheme} from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { UiControl } from './Lv2Plugin';
import DialogEx from './DialogEx';
import Input from '@mui/material/Input';
import { nullCast } from './Utility';
import Typography from '@mui/material/Typography';
import { css } from '@emotion/react';
const styles = (theme: Theme) => createStyles({
frame: css({
display: "block",
position: "relative",
flexDirection: "row",
flexWrap: "nowrap",
paddingTop: "8px",
paddingBottom: "0px",
height: "100%",
overflowX: "auto",
overflowY: "auto"
}),
});
interface FullScreenIMEProps extends WithStyles<typeof styles> {
theme: Theme;
uiControl?: UiControl;
caption: string;
value: number;
onChange: (key: string, value: number) => void;
onClose: () => void;
initialHeight: number;
}
type FullScreenIMEState = {
error: boolean;
};
const FullScreenIME =
withTheme(withStyles(
class extends ResizeResponsiveComponent<FullScreenIMEProps, FullScreenIMEState>
{
model: PiPedalModel;
inputRef: React.RefObject<HTMLInputElement|null>;
constructor(props: FullScreenIMEProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.inputRef = React.createRef();
this.state = {
error: false
}
this.onValueChanged = this.onValueChanged.bind(this);
this.handleClose = this.handleClose.bind(this);
this.onInputChange = this.onInputChange.bind(this);
this.onInputKeyPress = this.onInputKeyPress.bind(this);
this.onInputLostFocus = this.onInputLostFocus.bind(this);
}
onValueChanged(key: string, value: number): void {
this.props.onChange(key, value);
}
onWindowSizeChanged(width: number, height: number): void {
if (this.props.uiControl) {
// detecting a keyboard IME is painfully difficult.
// we do it by comparing the current window height against the window height
// when the IME was requested.
// a width change indicates a screen flip. Cancel the fullscreen ime in that case
// because we can't determine the initial window heigth anymore.
if (this.currentWidth === undefined)
{
this.currentWidth = window.innerWidth;
}
if (this.currentWidth !== window.innerWidth)
{
this.validateInput(true);
}
// eslint-disable-next-line no-restricted-globals
if (window.innerHeight >= this.props.initialHeight) {
this.validateInput(true);
}
}
}
handleClose(e: {}, reason: string): void {
this.props.onClose();
}
onInputLostFocus() {
this.validateInput(true);
}
inputChanged: boolean = false;
onInputChange() {
this.inputChanged = true;
this.validateInput(false);
}
currentValue: number = 0;
validateInput(commitValue: boolean) {
if (!this.inputRef.current) return;
let text = this.inputRef.current.value;
let valid = false;
let result: number = this.currentValue;
try {
if (text.length === 0) {
valid = false;
} else {
let v = Number(text);
if (isNaN(v)) {
valid = false;
} else {
valid = true;
result = v;
}
}
} catch (error) {
valid = false;
}
if (commitValue) {
this.setState({ error: false });
if (!valid) {
result = this.currentValue; // reset the value!
}
// clamp and quantize.
let uiControl = nullCast(this.uiControl);
// quantize value.
result = uiControl.clampValue(result);
this.props.onChange(this.props.uiControl!.symbol, result);
}
}
onOk() {
if (this.inputChanged) {
this.inputChanged = false;
this.validateInput(true);
}
else {
this.props.onClose();
}
}
onInputKeyPress(e: any): void {
if (e.charCode === 13) {
this.onOk();
}
}
uiControl?: UiControl;
currentWidth?: number;
render(): ReactNode {
//const classes = withStyles.getClasses(this.props);
let control = this.props.uiControl;
if (!control) {
this.currentWidth = undefined;
return (<div/>);
}
this.currentWidth = window.innerWidth;
this.uiControl = control;
let value = this.props.value;
return (
<DialogEx tag="ime" fullScreen open={!!(this.props.uiControl)} onClose={(e, r) => this.handleClose(e, r)}
onEnterKey={()=>{ /*nothing*/}}
>
<div style={{
width: "100%", height: "100%", position: "relative",
display: "flex", flexDirection: "column", flexWrap: "nowrap",
justifyContent: "start", alignItems: "center", marginTop:12
}}
>
<Typography noWrap variant="body2">{this.props.caption}</Typography>
<Input key={value}
type="number"
defaultValue={control.formatShortValue(value)}
error={this.state.error}
autoFocus
inputProps={{
'aria-label':
control.symbol + " value",
style: { textAlign: "center" },
width: 80
}}
inputRef={this.inputRef} onChange={this.onInputChange}
onBlur={this.onInputLostFocus}
onKeyPress={this.onInputKeyPress} />
</div>
</DialogEx>
);
}
},
styles
));
export default FullScreenIME;
+37
View File
@@ -0,0 +1,37 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export default class GovernorSettings {
deserialize(input: any) : GovernorSettings{
this.hasGovernor = input.hasGovernor;
this.governors = input.governors;
this.governor = input.governor;
return this;
}
clone(): GovernorSettings
{
return new GovernorSettings().deserialize(this);
}
hasGovernor: boolean = true;
governors: string[] = ["ondemand","performance"];
governor: string = "performance";
}
+440
View File
@@ -0,0 +1,440 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, {Component} from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { MonitorPortHandle, PiPedalModel, State, PiPedalModelFactory } from "./PiPedalModel";
import SvgPathBuilder from './SvgPathBuilder'
import {isDarkMode} from './DarkMode';
//const char* model[] = {"12-TET","19-TET","24-TET", "31-TET", "53-TET"};
// set_adjustment(ui->widget[2]->adj,440.0, 440.0, 427.0, 453.0, 0.1, CL_CONTINUOS);
const DEFAULT_FREQUENCY_PORT_NAME = "FREQ";
const DIAL_WIDTH= 220;
const DIAL_HEIGHT = 100;
const NEEDLE_CY = 320;
const CENTS_PER_MARK = 5;
const NEEDLE_OUTER_RADIUS = 0.98;
const TICK_OUTER_RADIUS = 0.96;
const TICK_INNER_RADIUS = 0.85;
const TICK_INNER_ZERO_RADIUS = 0.82;
const DIAL_ANGLE_RADIANS = 20*Math.PI/180;
const styles = (theme: Theme) =>
createStyles({
icon: {
width: "24px",
height: "24px",
margin: "0px",
opacity: "0.6"
}
});
interface PitchInfo {
valid: boolean;
name: string;
fractionText: string;
fraction: number;
semitoneCents: number
}
const FLAT = "\u{266d}";
const SHARP = "#";
// const DOUBLE_FLAT = "\uD834\uDD2B";
// const DOUBLE_SHARP = "\uD834\uDD2A";
const HALF_FLAT = "\uD834\uDD33";
const HALF_SHARP = "\uD834\uDD32";
const NOTES_12TET: string[] = [
"C", "C" + SHARP, "D", "E"+FLAT,"E", "F", "F" + SHARP, "G", "A"+FLAT,"A","B"+FLAT,"B"
];
const NOTES_19TET: string[] = [
"C", "C" + SHARP, "D"+FLAT, "D", "D" + SHARP,"E"+FLAT,"E", "E" + SHARP, "F", "F" + SHARP, "G"+FLAT,"G","G" + SHARP,"A"+FLAT,"A","A" + SHARP,"B"+FLAT,"B","C"+FLAT
];
const NOTES_24TET: string[] = [
"C","C"+HALF_SHARP, "C" + SHARP,"D"+HALF_FLAT, "D", "D"+HALF_SHARP, "E"+FLAT, "E"+HALF_FLAT, "E","E"+HALF_SHARP,"F", "F"+HALF_SHARP, "F" + SHARP, "G"+HALF_FLAT,"G","G"+HALF_SHARP, "A"+FLAT,"A"+HALF_FLAT,"A","A"+HALF_SHARP,"B"+FLAT,"B"+HALF_FLAT,"B","C"+HALF_FLAT
];
// // eslint-disable-next-line @typescript-eslint/no-unused-vars
// const NOTES_31TET: string[] = [
// "C", "D"+DOUBLE_FLAT, "C" + SHARP,"Db","C"+DOUBLE_SHARP,"D","E"+DOUBLE_FLAT,"D" + SHARP,"Eb","D"+DOUBLE_SHARP,"E",'Fb","E" + SHARP,"F","G"+DOUBLE_FLAT,"F" + SHARP,"Gb""Fx","G","A"+DOUBLE_FLAT,"G"+DOUBLE_SHARP,"A"'
// ];
// // eslint-disable-next-line @typescript-eslint/no-unused-vars
// const NOTES_53TET = ["la","laa","lo","law","ta","teh","te","tu","tuh","ti","tih","to","taw","da","do","di","daw","ro","rih","ra","ru","ruh","reh","re ","ri","raw","ma","meh","me","mu","muh","mi","maa","mo","maw","fe","fa","fih","fu","fuh","fi","se","suh","su","sih","sol","si","saw","lo","leh","le","lu","luh"];
interface GxTunerControlProps extends WithStyles<typeof styles> {
instanceId: number;
valueIsMidi: boolean;
symbolName?: string;
}
type GxTunerControlState = {
pitchInfo: PitchInfo
tet: number;
refFrequency: number;
animationTime: number;
};
const GxTunerControl =
withStyles(
class extends Component<GxTunerControlProps, GxTunerControlState>
{
model: PiPedalModel;
refRoot: React.RefObject<HTMLDivElement|null>;
constructor(props: GxTunerControlProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.refRoot = React.createRef();
this.state = {
pitchInfo: {
valid: false,
name: "",
fractionText: "",
fraction: 0,
semitoneCents: 100},
tet: 12,
animationTime: 0,
refFrequency: 440
};
this.onFrequencyUpdated = this.onFrequencyUpdated.bind(this);
this.onStateChanged = this.onStateChanged.bind(this);
}
frequencyPortName() : string {
if (this.props.symbolName) { return this.props.symbolName;}
return DEFAULT_FREQUENCY_PORT_NAME;
}
noteToPitchInfo(value: number) : PitchInfo
{
let aOffset = 69;
let tet = this.state.tet;
let refFrequency = this.state.refFrequency;
let names: string[];
let semitoneCents = 100;
let valid = false;
let hz = -1;
let midiNote = -1;
switch (tet)
{
case 12:
default:
aOffset = 69;
names = NOTES_12TET;
semitoneCents = 100;
break;
case 19:
aOffset = 4*19+13;
names = NOTES_19TET;
semitoneCents = 100*12.0/19;
break;
case 24:
aOffset = 4*24+18;
semitoneCents = 50;
names = NOTES_24TET;
break;
}
let name = "";
let fractionText = "";
let fraction = 0;
if (this.props.valueIsMidi)
{
midiNote = value;
} else {
hz = value;
if (hz < 65)
{
hz = -1;
midiNote = -1;
} else {
midiNote = Math.log2(hz/refFrequency)*tet + aOffset;
}
}
if (midiNote > 0)
{
let note = midiNote;
let noteNumber = Math.round(note);
let octave = Math.floor((noteNumber)/tet);
let nameIndex = noteNumber -octave* tet;
if (nameIndex < 0) nameIndex += tet;
name = names[ nameIndex ] + (octave-2);
valid = true;
fraction = note-noteNumber;
if (fraction >= 0) {
fractionText = "+" + fraction.toFixed(2).substr(1);
} else {
fractionText = "\u2212" + fraction.toFixed(2).substr(2);
}
}
return {
valid: valid,
name: name,
fraction: fraction,
fractionText: fractionText,
semitoneCents: semitoneCents
};
}
lastValue: number = -1;
onFrequencyUpdated(value: number): void
{
// suppress repeated zeros.
if (value === this.lastValue && value <= 0 && this.animationIdle) return;
this.lastValue = value;
let pitchInfo = this.noteToPitchInfo(value);
this.setState({
pitchInfo: pitchInfo
});
if (value <= 0)
{
this.startAnimationTimer();
} else {
this.cancelAnimationTimer();
}
}
animationTimer?: number = undefined;
cancelAnimationTimer(): void {
if (this.animationTimer)
{
clearTimeout(this.animationTimer);
this.animationTimer = undefined;
}
}
startAnimationTimer(): void {
if (!this.animationTimer)
{
this.animationTimer = setInterval(
() => {
this.setState({
animationTime: new Date().getTime()
});
},
1000/30
);
}
}
subscribedInstanceId: number = -1;
monitorHandle?: MonitorPortHandle;
addSubscription() {
this.subscribedInstanceId = this.props.instanceId;
this.monitorHandle = this.model.monitorPort(this.props.instanceId,this.frequencyPortName(),1.0/30,this.onFrequencyUpdated);
}
removeSubscription() {
this.subscribedInstanceId = -1;
if (this.monitorHandle) {
this.model.unmonitorPort(this.monitorHandle);
this.monitorHandle = undefined;
}
}
updateSubscription() {
if (this.subscribedInstanceId !== this.props.instanceId)
{
this.removeSubscription();
this.addSubscription();
}
}
onStateChanged(state: State)
{
// initial or reconnect
if (state === State.Ready)
{
this.removeSubscription();
this.updateSubscription();
}
}
componentDidMount()
{
this.model.state.addOnChangedHandler(this.onStateChanged);
this.addSubscription();
}
componentDidUpdate() {
this.updateSubscription();
}
componentWillUnmount()
{
this.model.state.removeOnChangedHandler(this.onStateChanged);
this.removeSubscription();
this.cancelAnimationTimer();
}
makeDialTick(pitchInfo: PitchInfo, cents: number): React.ReactNode {
let isZeroTick = Math.abs(cents) < 0.001;
let r0 = TICK_OUTER_RADIUS;
let r1 = (isZeroTick? TICK_INNER_ZERO_RADIUS: TICK_INNER_RADIUS);
let width = isZeroTick? 3: 2;
return this.makeTick(pitchInfo,cents,r0,r1,"#666", width);
}
nextKey() { return "key" + (this.keyCounter++)}
makeTick(pitchInfo: PitchInfo, cents: number, r0: number, r1: number, stroke: string,width: number): React.ReactNode
{
let range = pitchInfo.semitoneCents;
if (range > 50) {
range = 50;
}
r0 *= NEEDLE_CY;
r1 *= NEEDLE_CY;
let angle = DIAL_ANGLE_RADIANS * (cents*2/(range));
let sin_ = Math.sin(angle);
let cos_ = -Math.cos(angle);
let cx = DIAL_WIDTH/2;
let cy = NEEDLE_CY;
let path = new SvgPathBuilder().moveTo(r0*sin_+cx,r0*cos_+cy).lineTo(r1*sin_+cx,r1*cos_+cy).toString();
return (<path key={this.nextKey()} d={path} stroke={stroke} strokeWidth={width+""} />);
}
lastValidTime: number = 0;
lastValidCents: number = -100;
animationIdle: boolean = true;
makeNeedle(pitchInfo: PitchInfo)
{
let maxCents = pitchInfo.semitoneCents/2;
if (maxCents > 25) {
maxCents = 25;
}
let cents: number;
if (!pitchInfo.valid)
{
const NEEDLE_DECAY_RATE_PER_MS = -50*7/1000;
// animate to zero position.
let time = new Date().getTime();
let dt = time-this.lastValidTime;
cents = this.lastValidCents+(dt*NEEDLE_DECAY_RATE_PER_MS);
if (cents <= -maxCents)
{
cents = -maxCents;
this.animationIdle = true;
this.cancelAnimationTimer();
} else {
this.animationIdle = false;
}
} else {
cents = pitchInfo.semitoneCents*pitchInfo.fraction;
if (cents > maxCents) {
cents = maxCents;
}
if (cents < -maxCents)
{
cents = -maxCents;
}
this.lastValidCents = cents;
this.lastValidTime = new Date().getTime();
}
return this.makeTick(pitchInfo,cents,NEEDLE_OUTER_RADIUS,0.1,"#800",3);
}
renderDial(pitchInfo: PitchInfo)
{
let bounds = "0,0," + DIAL_WIDTH+ "," +DIAL_HEIGHT;
let content: React.ReactNode[] = [];
let maxCents = Math.floor(pitchInfo.semitoneCents/2 / CENTS_PER_MARK)*CENTS_PER_MARK;
for (let cents = -maxCents; cents <= maxCents+ 0.01; cents += CENTS_PER_MARK) {
content.push(this.makeDialTick(pitchInfo,cents));
}
content.push(
this.makeNeedle(pitchInfo)
);
return (
<svg viewBox={bounds} width={DIAL_WIDTH} height={DIAL_HEIGHT} style={{position: "absolute"}} >
{ content }
</svg>
)
}
keyCounter: number = 0;
render() {
this.keyCounter = 0;
let textColor = isDarkMode() ? "#999": "#444";
return (<div ref={this.refRoot} style={{width: DIAL_WIDTH, height: DIAL_HEIGHT, fontSize: "2em", fontWeight: 700, position: "relative",
boxShadow: isDarkMode() ?
"5px 5px 6px rgba(0,0,0,0.8) inset":
"1px 5px 6px #888 inset",
background: isDarkMode()? "rgba(255,255,255,0.07)" : "",
fontFamily: "arial,roboto,helvetica,sans"}}>
<div style={{position: "absolute", left: 0, bottom: 5, width: "50%",textAlign: "right"}}>
<span style={{ marginRight: 20, color: textColor, textAlign: "right" }}>{this.state.pitchInfo.name}</span>
</div>
<div style={{position: "absolute", right: 0, bottom: 5, width: "50%",textAlign: "left"}}>
<span style={{ marginLeft: 20, color: textColor, textAlign: "left" }}>{this.state.pitchInfo.fractionText}</span>
</div>
{ this.renderDial(this.state.pitchInfo) }
</div>);
}
},
styles
);
export default GxTunerControl;
+137
View File
@@ -0,0 +1,137 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import IControlViewFactory from './IControlViewFactory';
import { PiPedalModelFactory, PiPedalModel } from "./PiPedalModel";
import { PedalboardItem } from './Pedalboard';
import PluginControlView, { ControlGroup,ControlViewCustomization } from './PluginControlView';
import GxTunerControl from './GxTunerControl';
const GXTUNER_URI = "http://guitarix.sourceforge.net/plugins/gxtuner#tuner";
const TOOBTUNER_URI = "http://two-play.com/plugins/toob-tuner";
const styles = (theme: Theme) => createStyles({
});
interface GxTunerProps extends WithStyles<typeof styles> {
instanceId: number;
item: PedalboardItem;
isToobTuner: boolean;
}
interface GxTunerState {
}
const GxTunerView =
withStyles(
class extends React.Component<GxTunerProps, GxTunerState>
implements ControlViewCustomization
{
model: PiPedalModel;
customizationId: number = 1;
constructor(props: GxTunerProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
}
}
getControlIndex(key: string): number {
let item = this.props.item;
for (let i = 0; i < item.controlValues.length; ++i)
{
if (item.controlValues[i].key === key)
{
return i;
}
}
throw new Error("GxTuner: Control '" + key + "' not found.");
}
ModifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[]
{
let refFreqIndex = this.getControlIndex("REFFREQ");
let thresholdIndex = this.getControlIndex("THRESHOLD");
let muteIndex = -1;
if (this.props.isToobTuner)
{
muteIndex = this.getControlIndex("MUTE")
}
let result: (React.ReactNode | ControlGroup)[] = [];
let tunerControl = (<GxTunerControl instanceId={this.props.instanceId} valueIsMidi={false} />);
result.push(tunerControl);
result.push(controls[refFreqIndex]);
result.push(controls[thresholdIndex]);
if (muteIndex !== -1)
{
result.push(controls[muteIndex]);
}
return result;
}
render() {
return (<PluginControlView
instanceId={this.props.instanceId}
item={this.props.item}
customization={this}
customizationId={this.customizationId}
/>);
}
},
styles
);
export class GxTunerViewFactory implements IControlViewFactory {
uri: string = GXTUNER_URI;
Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode {
return (<GxTunerView isToobTuner={false} instanceId={pedalboardItem.instanceId} item={pedalboardItem} />);
}
}
// ToobTuner uses the same controls and control semantics.
export class ToobTunerViewFactory implements IControlViewFactory {
uri: string = TOOBTUNER_URI;
Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode {
return (<GxTunerView isToobTuner={true} instanceId={pedalboardItem.instanceId} item={pedalboardItem} />);
}
}
+32
View File
@@ -0,0 +1,32 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { PiPedalModel } from "./PiPedalModel";
import { PedalboardItem } from './Pedalboard';
interface IControlViewFactory {
uri: string;
Create(model: PiPedalModel,pedalboardItem: PedalboardItem): React.ReactNode;
}
export default IControlViewFactory;
+135
View File
@@ -0,0 +1,135 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import {AlsaMidiDeviceInfo} from './AlsaMidiDeviceInfo';
function getChannelNumber(channelId: string): number {
let pos = channelId.indexOf("_");
let i = Number(channelId.substring(pos+1));
return i;
}
export class JackChannelSelection {
deserialize(input: any): JackChannelSelection
{
this.inputAudioPorts = input.inputAudioPorts.slice();
this.outputAudioPorts = input.outputAudioPorts.slice();
this.inputMidiDevices = AlsaMidiDeviceInfo.deserializeArray(input.inputMidiDevices);
this.sampleRate = input.sampleRate;
this.bufferSize = input.bufferSize;
this.numberOfBuffers = input.numberOfBuffers;
return this;
}
clone() : JackChannelSelection {
return new JackChannelSelection().deserialize(this);
}
inputAudioPorts: string[] = [];
outputAudioPorts: string[] = [];
inputMidiDevices: AlsaMidiDeviceInfo[] = [];
sampleRate: number = 48000;
bufferSize: number = 64;
numberOfBuffers: number = 3;
static makeDefault(jackConfiguration: JackConfiguration): JackChannelSelection {
let result = new JackChannelSelection();
result.inputAudioPorts = jackConfiguration.inputAudioPorts.slice(0,2);
result.outputAudioPorts = jackConfiguration.inputAudioPorts.slice(0,2);
result.inputMidiDevices = [];
return result;
}
getChannelDisplayValue(selectedChannels: string[], availableChannels: string[],isConfigValid: boolean): string
{
if (!isConfigValid)
{
if (selectedChannels.length === 2) {
return "Stereo";
}
if (selectedChannels.length === 1) return "Mono";
return "\u00A0"; // nbsp
}
if (selectedChannels.length === 0) return "Invalid selection";
if (availableChannels.length === 1) return "Mono";
if (availableChannels.length === 2) {
if (selectedChannels.length === 2) return "Stereo";
if (selectedChannels[0] === availableChannels[0]) {
return "Mono (left channel only)";
} else if (selectedChannels[0] === availableChannels[1]) {
return "Mono (right channel only)";
} else {
return "\u00A0"; // nbsp
}
} else {
if (selectedChannels.length === 2)
{
let result: string = "Stereo (" + (getChannelNumber(selectedChannels[0])+1) + "," + (getChannelNumber(selectedChannels[1])+1) + ")";
return result;
}
if (selectedChannels.length === 1)
{
let result: string = "Mono (" + (getChannelNumber(selectedChannels[0])+1) +")";
return result;
}
return "\u00A0"; // nbsp
}
}
getAudioInputDisplayValue(jackConfiguration: JackConfiguration): string
{
return this.getChannelDisplayValue(this.inputAudioPorts,jackConfiguration.inputAudioPorts, jackConfiguration.isValid);
}
getAudioOutputDisplayValue(jackConfiguration: JackConfiguration): string
{
return this.getChannelDisplayValue(this.outputAudioPorts,jackConfiguration.outputAudioPorts, jackConfiguration.isValid);
}
};
export class JackConfiguration {
deserialize(input: any): JackConfiguration {
this.isValid = input.isValid;
this.isRestarting = input.isRestarting;
this.errorState = input.errorState;
this.sampleRate = input.sampleRate;
this.blockLength = input.blockLength;
this.midiBufferSize = input.midiBufferSize;
this.maxAllowedMidiDelta = input.maxAllowedMidiDelta;
this.inputAudioPorts = input.inputAudioPorts;
this.outputAudioPorts = input.outputAudioPorts;
this.inputMidiDevices = AlsaMidiDeviceInfo.deserializeArray(input.inputMidiDevices);
return this;
}
isValid: boolean = false;
isRestarting: boolean = false;
errorState: string = "Not loaded.";
sampleRate: number = 0;
blockLength: number = 0;
midiBufferSize: number = 0;
maxAllowedMidiDelta: number = 0;
inputAudioPorts: string[] = [];
outputAudioPorts: string[] = [];
inputMidiDevices: AlsaMidiDeviceInfo[] = [];
};
export default JackConfiguration;
+192
View File
@@ -0,0 +1,192 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import Typography from '@mui/material/Typography';
import { isDarkMode } from './DarkMode';
const RED_COLOR = isDarkMode() ? "#F88" : "#C00";
const GREEN_COLOR = isDarkMode() ? "rgba(255,255,255,0.7)" : "#666";
function tempDisplay(mC: number): string {
return (mC / 1000).toFixed(1) + "\u00B0C"; // degrees C.
}
function cpuDisplay(cpu: number): string {
return cpu.toFixed(1) + "%";
}
function fmtCpuFreq(freq: number): string {
if (freq >= 100000000) {
return (freq / 1000000).toFixed(1) + " GHz";
}
if (freq >= 10000000) {
return (freq / 1000000).toFixed(2) + " GHz";
}
if (freq >= 1000000) {
return (freq / 1000000).toFixed(3) + " GHz";
}
if (freq >= 1000) {
return (freq / 1000).toFixed(3) + " MHz";
}
return freq + " KHz";
}
function areSameFrequency(max: number, min: number): boolean {
return true;
// // x86 cpus can show incredibly minor frequency variations between CPUs
// if (min === 0) return false;
// let ratio = max/min;
// if (ratio < 1.2)
// {
// return true;
// }
// return false;
}
export default class JackHostStatus {
deserialize(input: any): JackHostStatus {
this.active = input.active;
this.restarting = input.restarting;
this.errorMessage = input.errorMessage;
this.underruns = input.underruns;
this.cpuUsage = input.cpuUsage;
this.msSinceLastUnderrun = input.msSinceLastUnderrun;
this.temperaturemC = input.temperaturemC;
this.cpuFreqMax = input.cpuFreqMax;
this.cpuFreqMin = input.cpuFreqMin;
this.hasCpuGovernor = input.hasCpuGovernor;
this.governor = input.governor;
return this;
}
hasTemperature(): boolean {
return this.temperaturemC >= -100000;
}
active: boolean = false;
errorMessage: string = "";
restarting: boolean = false;
underruns: number = 0;
cpuUsage: number = 0;
msSinceLastUnderrun: number = -5000 * 1000;
temperaturemC: number = -1000000;
cpuFreqMax: number = 0;
cpuFreqMin: number = 0;
hasCpuGovernor: boolean = false;
governor: string = "";
static getCpuInfo(label: string, status?: JackHostStatus): React.ReactNode {
if (!status) {
return (<div style={{ whiteSpace: "nowrap" }}>
<Typography variant="caption" color="inherit">{label}</Typography>
<Typography variant="caption">&nbsp;</Typography>
</div>);
}
return (<div style={{ whiteSpace: "nowrap" }}>
<Typography variant="caption" color="inherit">{label}</Typography>
{(status.cpuFreqMin !== 0 || status.cpuFreqMax !== 0) &&
(
<Typography variant="caption" color="inherit">
{
(areSameFrequency(status.cpuFreqMax,status.cpuFreqMin)) ?
(
<span> {status.governor} {fmtCpuFreq(status.cpuFreqMax)} </span>
)
: (
<span> {status.governor} {fmtCpuFreq(status.cpuFreqMin)}-{fmtCpuFreq(status.cpuFreqMax)} </span>
)
}
</Typography>
)}
</div>);
}
static getDisplayView(label: string, status?: JackHostStatus): React.ReactNode {
if (!status) {
return (<div style={{ whiteSpace: "nowrap" }}>
<Typography variant="caption" color="inherit">{label}</Typography>
<Typography variant="caption">&nbsp;</Typography>
</div>);
}
if (status.restarting) {
return (
<div style={{ whiteSpace: "nowrap" }}>
<Typography variant="caption" color="inherit">{label}</Typography>
<span style={{ color: RED_COLOR }}>
<Typography variant="caption" color="inherit">Restarting&nbsp;&nbsp;</Typography>
</span>
{
status.temperaturemC > -100000 &&
(
<span style={{ color: status.temperaturemC > 75000 ? RED_COLOR : GREEN_COLOR }}>
<Typography variant="caption" color="inherit">{tempDisplay(status.temperaturemC)}</Typography>
</span>
)
}
</div>
);
} else if (!status.active) {
return (
<div style={{ whiteSpace: "nowrap" }}>
<Typography variant="caption" color="inherit">{label}</Typography>
<span style={{ color: RED_COLOR }}>
<Typography variant="caption" color="inherit">{status.errorMessage === "" ? "Audio\u00A0Stopped" : status.errorMessage}&nbsp;&nbsp;</Typography>
</span>
{
status.temperaturemC > -100000 &&
(
<span style={{ color: status.temperaturemC > 75000 ? RED_COLOR : GREEN_COLOR }}>
<Typography variant="caption" color="inherit">{tempDisplay(status.temperaturemC)}</Typography>
</span>
)
}
</div>
);
} else {
let underrunError = status.msSinceLastUnderrun < 15 * 1000;
return (
<div style={{ whiteSpace: "nowrap" }}>
<Typography variant="caption" color="inherit">{label}</Typography>
<span style={{ color: underrunError ? RED_COLOR : GREEN_COLOR }}>
<Typography variant="caption" color="inherit">
XRuns:&nbsp;{status.underruns + ""}&nbsp;&nbsp;
</Typography>
</span>
<span style={{ color: underrunError ? RED_COLOR : GREEN_COLOR }}>
<Typography variant="caption" color="inherit">
CPU:&nbsp;{cpuDisplay(status.cpuUsage)}&nbsp;&nbsp;
</Typography>
</span>
<span style={{ color: GREEN_COLOR }}>
<Typography variant="caption" color="inherit">{tempDisplay(status.temperaturemC)}</Typography>
</span>
</div>
);
}
}
};
+66
View File
@@ -0,0 +1,66 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export default class JackServerSettings {
deserialize(input: any) : JackServerSettings{
this.valid = input.valid;
this.isOnboarding = input.isOnboarding;
this.isJackAudio = input.isJackAudio;
this.rebootRequired = input.rebootRequired;
this.alsaDevice = input.alsaDevice?? "";
this.sampleRate = input.sampleRate;
this.bufferSize = input.bufferSize;
this.numberOfBuffers = input.numberOfBuffers;
return this;
}
// constructor(alsaDevice: string, sampleRate?: number, bufferSize?: number, numberOfBuffers?: number)
// {
// if (sampleRate) this.sampleRate = sampleRate;
// if (bufferSize) this.bufferSize = bufferSize;
// if (numberOfBuffers) this.numberOfBuffers = numberOfBuffers;
// if (numberOfBuffers) {
// this.valid = true;
// }
// }
clone(): JackServerSettings
{
return new JackServerSettings().deserialize(this);
}
valid: boolean = false;
isOnboarding: boolean = true;
rebootRequired = false;
isJackAudio = false;
alsaDevice: string = "";
sampleRate = 48000;
bufferSize = 64;
numberOfBuffers = 3;
getSummaryText() {
if (this.valid) {
let device = this.alsaDevice;
if (device.startsWith("hw:")) device = device.substring(3);
return device + " - Rate: " + this.sampleRate + " BufferSize: " + this.bufferSize + " Buffers: " + this.numberOfBuffers;
} else {
return "Not configured";
}
}
}
@@ -0,0 +1,548 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { Component } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogEx from './DialogEx';
import JackServerSettings from './JackServerSettings';
import InputLabel from '@mui/material/InputLabel';
import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import DialogContent from '@mui/material/DialogContent';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import AlsaDeviceInfo from './AlsaDeviceInfo';
const MIN_BUFFER_SIZE = 16;
const MAX_BUFFER_SIZE = 2048;
interface BufferSetting {
bufferSize: number;
numberOfBuffers: number;
};
const INVALID_DEVICE_ID = "_invalid_";
interface JackServerSettingsDialogState {
latencyText: string;
jackServerSettings: JackServerSettings;
alsaDevices?: AlsaDeviceInfo[];
okEnabled: boolean;
}
const styles = (theme: Theme) =>
createStyles({
formControl: {
margin: theme.spacing(1),
minWidth: 120,
},
selectEmpty: {
marginTop: theme.spacing(2),
},
});
export interface JackServerSettingsDialogProps extends WithStyles<typeof styles> {
open: boolean;
jackServerSettings: JackServerSettings;
onClose: () => void;
onApply: (jackServerSettings: JackServerSettings) => void;
}
function getLatencyText(settings?: JackServerSettings ): string {
if (!settings)
{
return "\u00A0";
}
if (!settings.valid) return "\u00A0";
let ms = settings.bufferSize * settings.numberOfBuffers / settings.sampleRate * 1000;
return ms.toFixed(1) + "ms";
}
function getValidBufferCounts(bufferSize: number, alsaDeviceInfo?: AlsaDeviceInfo): number[]
{
if (!alsaDeviceInfo)
{
return [];
}
let result: number[] = [];
if (bufferSize * 2 >= alsaDeviceInfo.minBufferSize)
{
result = [2,3,4];
} else {
let minBuffers = Math.ceil(alsaDeviceInfo.minBufferSize/bufferSize/2-0.0001);
result = [minBuffers*2,minBuffers*3, minBuffers*4];
}
for (let i = 0; i < result.length; ++i)
{
let selectedSize = result[i]*bufferSize;
if (selectedSize < alsaDeviceInfo.minBufferSize || selectedSize > alsaDeviceInfo.maxBufferSize)
{
result.splice(i,1);
--i;
}
}
return result;
}
function getValidBufferSizes(alsaDeviceInfo? : AlsaDeviceInfo): number[]
{
if (!alsaDeviceInfo )
{
return [];
}
let result: number[] = [];
for (let i = MIN_BUFFER_SIZE; i <= MAX_BUFFER_SIZE; i *= 2)
{
if (getValidBufferCounts(i,alsaDeviceInfo).length !== 0)
{
result.push(i);
}
}
return result;
}
function getBestBuffers(
alsaDeviceInfo: AlsaDeviceInfo | undefined,
bufferSize: number,
numberOfBuffers: number,
bufferSizeChanging: boolean = false
) : BufferSetting {
if (!alsaDeviceInfo)
{
return { bufferSize:bufferSize, numberOfBuffers: numberOfBuffers};
}
// If the numberOfbuffers is fine as is, don't change the number of Buffers.
// set default values. Otherwise, choose the best buffer count from available buffer counts.
let validBuffercounts = getValidBufferCounts(bufferSize,alsaDeviceInfo);
let ix = validBuffercounts.indexOf(numberOfBuffers);
if (ix !== -1) {
if (bufferSize*numberOfBuffers <= alsaDeviceInfo.maxBufferSize
&& bufferSize*numberOfBuffers >= alsaDeviceInfo.minBufferSize)
{
return {bufferSize: bufferSize, numberOfBuffers: numberOfBuffers};
}
}
// if numberOfBuffers is not valid, and the user has just selected a new buffers size,
// then choose from available buffer counts.
if (bufferSizeChanging)
{
if (validBuffercounts.length !== 0)
{
// prefer the one thats divisible by 3.
for (let i = 0; i < validBuffercounts.length; ++i)
{
if (validBuffercounts[i] % 3 === 0)
{
numberOfBuffers = validBuffercounts[i];
return {bufferSize: bufferSize, numberOfBuffers:numberOfBuffers};
}
}
numberOfBuffers = validBuffercounts[0];
return {bufferSize: bufferSize, numberOfBuffers:numberOfBuffers};
}
}
// otherwise select a sensible starting value.
// favored default: 64x3.
if (64*3 >= alsaDeviceInfo.minBufferSize && 64*3 <= alsaDeviceInfo.maxBufferSize)
{
return { bufferSize: 64, numberOfBuffers: 3};
}
// if that isn't possible then minBufferSize/2 x 4.
bufferSize = alsaDeviceInfo.minBufferSize/2;
numberOfBuffers = 4;
// otherwise, minBufferSize/2 x 2.
if (bufferSize*numberOfBuffers > alsaDeviceInfo.maxBufferSize)
{
numberOfBuffers = 2;
}
return { bufferSize: bufferSize, numberOfBuffers: numberOfBuffers};
};
function isOkEnabled(jackServerSettings: JackServerSettings, alsaDevices?: AlsaDeviceInfo[])
{
if (!jackServerSettings.valid) return false;
if (!alsaDevices) return false;
let alsaDevice: AlsaDeviceInfo | undefined = undefined;
for (let i = 0; i < alsaDevices.length; ++i)
{
if (alsaDevices[i].id === jackServerSettings.alsaDevice)
{
alsaDevice = alsaDevices[i];
}
}
if (!alsaDevice)
{
return false;
}
let deviceBufferSize = jackServerSettings.bufferSize * jackServerSettings.numberOfBuffers;
if (deviceBufferSize < alsaDevice.minBufferSize || deviceBufferSize > alsaDevice.maxBufferSize)
{
return false;
}
let validBufferCounts = getValidBufferCounts(jackServerSettings.bufferSize, alsaDevice);
let ix = validBufferCounts.indexOf(jackServerSettings.numberOfBuffers);
if (ix === -1)
{
return false;
}
return true;
}
const JackServerSettingsDialog = withStyles(
class extends Component<JackServerSettingsDialogProps, JackServerSettingsDialogState> {
model: PiPedalModel;
constructor(props: JackServerSettingsDialogProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
latencyText: getLatencyText(props.jackServerSettings),
jackServerSettings: props.jackServerSettings.clone(), // invalid, but not nullish
alsaDevices: undefined,
okEnabled: false
};
}
mounted: boolean = false;
requestAlsaInfo() {
this.model.getAlsaDevices()
.then((devices) => {
if (this.mounted) {
if (this.props.open) {
let settings = this.applyAlsaDevices(this.props.jackServerSettings, devices);
this.setState({
alsaDevices: devices,
jackServerSettings: settings,
latencyText: getLatencyText(settings),
okEnabled: isOkEnabled(settings,devices)
});
} else {
this.setState({ alsaDevices: devices });
}
}
})
.catch((error) => {
});
}
applyAlsaDevices(jackServerSettings: JackServerSettings, alsaDevices?: AlsaDeviceInfo[]) {
let result = jackServerSettings.clone();
if (!alsaDevices) {
return result;
}
result.valid = false;
if (alsaDevices.length === 0) {
result.alsaDevice = INVALID_DEVICE_ID;
return result;
}
let selectedDevice: AlsaDeviceInfo | undefined = undefined;
for (let i = 0; i < alsaDevices.length; ++i) {
if (alsaDevices[i].id === result.alsaDevice) {
selectedDevice = alsaDevices[i]
break;
}
}
if (!selectedDevice) {
selectedDevice = alsaDevices[0];
result.alsaDevice = selectedDevice.id;
}
if (result.sampleRate === 0) result.sampleRate = 48000;
result.sampleRate = selectedDevice.closestSampleRate(result.sampleRate);
let bestBuffers = getBestBuffers(selectedDevice,result.bufferSize,result.numberOfBuffers);
result.bufferSize = bestBuffers.bufferSize;
result.numberOfBuffers = bestBuffers.numberOfBuffers;
result.valid = true;
return result;
}
componentDidMount() {
this.mounted = true;
if (this.props.open) {
this.requestAlsaInfo();
}
}
componentDidUpdate(oldProps: JackServerSettingsDialogProps) {
if ((this.props.open && !oldProps.open) && this.mounted) {
let settings = this.applyAlsaDevices(this.props.jackServerSettings.clone(), this.state.alsaDevices);
this.setState({
jackServerSettings: settings,
latencyText: getLatencyText(settings),
okEnabled: isOkEnabled(settings,this.state.alsaDevices)
});
if (!this.state.alsaDevices) {
this.requestAlsaInfo();
}
}
}
componentWillUnmount() {
this.mounted = false;
}
getSelectedDevice(deviceId: string): AlsaDeviceInfo | null {
if (!this.state.alsaDevices) return null;
for (let i = 0; i < this.state.alsaDevices.length; ++i) {
if (this.state.alsaDevices[i].id === deviceId) {
return this.state.alsaDevices[i];
}
}
return null;
}
handleDeviceChanged(e: any) {
let device = e.target.value as string;
let selectedDevice = this.getSelectedDevice(device);
if (!selectedDevice) return;
let settings = this.state.jackServerSettings.clone();
settings.alsaDevice = device;
settings = this.applyAlsaDevices(settings,this.state.alsaDevices);
this.setState({
jackServerSettings: settings,
latencyText: getLatencyText(settings),
okEnabled: isOkEnabled(settings,this.state.alsaDevices)
});
}
handleRateChanged(e: any) {
let rate = e.target.value as number;
console.log("New rate: " + rate);
let selectedDevice = this.getSelectedDevice(this.state.jackServerSettings.alsaDevice);
if (!selectedDevice) return;
let settings = this.state.jackServerSettings.clone();
settings.sampleRate = rate;
this.setState({
jackServerSettings: settings,
latencyText: getLatencyText(settings),
okEnabled: isOkEnabled(settings,this.state.alsaDevices)
});
}
handleSizeChanged(e: any) {
let size = e.target.value as number;
let selectedDevice = this.getSelectedDevice(this.state.jackServerSettings.alsaDevice);
if (!selectedDevice) return;
let settings = this.state.jackServerSettings.clone();
settings.bufferSize = size;
let bestBufferSetting = getBestBuffers(selectedDevice,settings.bufferSize,settings.numberOfBuffers,true);
settings.bufferSize = bestBufferSetting.bufferSize;
settings.numberOfBuffers = bestBufferSetting.numberOfBuffers;
this.setState({
jackServerSettings: settings,
latencyText: getLatencyText(settings),
okEnabled: isOkEnabled(settings,this.state.alsaDevices)
});
}
handleNumberOfBuffersChanged(e: any) {
let bufferCount = e.target.value as number;
let selectedDevice = this.getSelectedDevice(this.state.jackServerSettings.alsaDevice);
if (!selectedDevice) return;
let settings = this.state.jackServerSettings.clone();
settings.numberOfBuffers = bufferCount;
this.setState({
jackServerSettings: settings,
latencyText: getLatencyText(settings),
okEnabled: isOkEnabled(settings,this.state.alsaDevices)
});
}
handleApply() {
if (this.state.okEnabled)
{
this.props.onApply(this.state.jackServerSettings.clone());
}
};
render() {
const classes = withStyles.getClasses(this.props);
const { onClose, open } = this.props;
const handleClose = () => {
onClose();
};
let waitingForDevices = !this.state.alsaDevices
let noDevices = this.state.alsaDevices && this.state.alsaDevices.length === 0;
let selectedDevice: AlsaDeviceInfo | undefined = undefined;
if (this.state.alsaDevices) {
for (let device of this.state.alsaDevices) {
if (device.id === this.state.jackServerSettings.alsaDevice) {
selectedDevice = device;
break;
}
}
}
let bufferSizes: number[] = getValidBufferSizes(selectedDevice);
let bufferCounts = getValidBufferCounts(this.state.jackServerSettings.bufferSize,selectedDevice);
let bufferSizeDisabled = !selectedDevice;
let bufferCountDisabled = !selectedDevice;
return (
<DialogEx tag="jack" onClose={handleClose} aria-labelledby="select-channels-title" open={open}
onEnterKey={() => {
this.handleApply();
}}
>
<DialogContent>
<div>
<FormControl className={classes.formControl}>
<InputLabel htmlFor="jsd_device">Device</InputLabel>
<Select variant="standard" onChange={(e) => this.handleDeviceChanged(e)}
value={this.state.jackServerSettings.alsaDevice}
style={{width: 220}}
inputProps={{
name: "Device",
id: "jsd_device",
}}
>
{(noDevices && !waitingForDevices) &&
(
<MenuItem value={INVALID_DEVICE_ID}>No suitable devices.</MenuItem>
)
}
{((!noDevices) && !waitingForDevices) && (
this.state.alsaDevices!.map((device) =>
(
<MenuItem key={device.id} value={device.id}>{device.name}</MenuItem>
)
)
)}
</Select>
</FormControl>
</div><div>
<FormControl className={classes.formControl}>
<InputLabel htmlFor="jsd_sampleRate">Sample rate</InputLabel>
<Select variant="standard"
onChange={(e) => this.handleRateChanged(e)}
value={this.state.jackServerSettings.sampleRate}
disabled={!selectedDevice}
inputProps={{
id: 'jsd_sampleRate',
style: {
textAlign: "right"
}
}}
>
{selectedDevice &&
selectedDevice.sampleRates.map((sr) => {
return ( <MenuItem value={sr}>{sr}</MenuItem> );
})
}
</Select>
</FormControl>
<div style={{ display: "inline", whiteSpace: "nowrap" }}>
<FormControl className={classes.formControl}>
<InputLabel htmlFor="bufferSize">Buffer size</InputLabel>
<Select variant="standard"
onChange={(e) => this.handleSizeChanged(e)}
value={this.state.jackServerSettings.bufferSize}
disabled={bufferSizeDisabled}
inputProps={{
name: 'Buffer size',
id: 'jsd_bufferSize',
}}
>
{bufferSizes.map((buffSize) =>
(
<MenuItem key={"b"+buffSize} value={buffSize}>{buffSize}</MenuItem>
)
)}
</Select>
</FormControl>
<FormControl className={classes.formControl}>
<InputLabel htmlFor="numberofBuffers">Buffers</InputLabel>
<Select variant="standard"
onChange={(e) => this.handleNumberOfBuffersChanged(e)}
value={this.state.jackServerSettings.numberOfBuffers}
disabled={bufferCountDisabled}
inputProps={{
name: 'Number of buffers',
id: 'jsd_bufferCount',
}}
>
{
bufferCounts.map((bufferCount)=>
{
return (
<MenuItem key={bufferCount} value={bufferCount}>{bufferCount.toString()}</MenuItem>
);
})
}
</Select>
</FormControl>
</div>
</div>
<Typography display="block" variant="caption" style={{ textAlign: "left", marginTop: 8, marginLeft: 24 }}
color="textSecondary">
Latency: {this.state.latencyText}
</Typography>
</DialogContent>
<DialogActions>
<Button variant="dialogSecondary" onClick={handleClose} >
Cancel
</Button>
<Button variant="dialogPrimary" onClick={() => this.handleApply()}
disabled={!this.state.okEnabled}>
OK
</Button>
</DialogActions>
</DialogEx>
);
}
},
styles);
export default JackServerSettingsDialog;
+82
View File
@@ -0,0 +1,82 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { PiPedalModel, PiPedalModelFactory,State } from './PiPedalModel';
import JackHostStatus from './JackHostStatus';
interface JackStatusViewProps {
};
interface JackStatusViewState {
jackStatus?: JackHostStatus;
}
export default class JackStatusView extends React.Component<JackStatusViewProps, JackStatusViewState>
{
model: PiPedalModel;
constructor(props: JackStatusViewProps) {
super(props);
this.state = {
jackStatus: undefined
};
this.model = PiPedalModelFactory.getInstance();
this.tick = this.tick.bind(this);
}
tick() {
if (this.model.state.get() === State.Ready) {
this.model.getJackStatus()
.then(jackStatus => {
this.setState({jackStatus: jackStatus});
})
.catch(error => { /* ignore*/ });
}
}
timerHandle?: number;
componentDidMount() {
this.timerHandle = setInterval(this.tick, 1000);
}
componentWillUnmount() {
if (this.timerHandle) {
clearTimeout(this.timerHandle);
this.timerHandle = undefined;
}
}
render() {
return (
<div style={{
position: "absolute", right: 30, bottom: 0, height: 30, width: 200,
paddingRight: 20, paddingBottom: 6,
textAlign: "right", opacity: 0.7, whiteSpace: "nowrap", fontSize: 12, zIndex: 10, fontWeight: 900
}}>
{JackHostStatus.getDisplayView("",this.state.jackStatus) }
</div>
);
}
}
+137
View File
@@ -0,0 +1,137 @@
// Copyright (c) 2023 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// Utility class for constructing Json atoms.
class JsonAtom {
static Property(value: string): any
{
return {
"otype_": "Property",
"value": value
};
}
static Bool(value: boolean): any
{
return {
"otype_": "Bool",
"value": value
};
}
static Int(value: number): any
{
return {
"otype_": "Int",
"value": value
};
}
static Long(value: number): any
{
return {
"otype_": "Long",
"value": value
};
}
static Float(value: number): any
{
return value;
}
static Double(value: number): any
{
return {
"otype_": "Double",
"value": value
};
}
static String(value: string): any
{
return value;
}
static Path(value: string): any
{
return {
"otype_": "Path",
"value": value
};
}
static Uri(value: string): any
{
return {
"otype_": "URI",
"value": value
};
}
static Urid(value: string): any
{
return {
"otype_": "URID",
"value": value
};
}
static Object(type: string): any
{
return {
"otype_": type
};
}
static Tuple(values: any[]): any
{
return {
"otype_": "Tuple",
"value": values
};
}
static IntVector(values: []): any
{
return {
"otype_": "Vector",
"vtype_": "Int",
"value": values
};
}
static LongVector(values: []): any
{
return {
"otype_": "Vector",
"vtype_": "Long",
"value": values
};
}
static FloatVector(values: []): any
{
return {
"otype_": "Vector",
"vtype_": "Float",
"value": values
};
}
static DoubleVector(values: []): any
{
return {
"otype_": "Vector",
"vtype_": "Float",
"value": values
};
}
};
export default JsonAtom;
+918
View File
@@ -0,0 +1,918 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { ReactNode, SyntheticEvent, CSSProperties, Fragment, ReactElement } from 'react';
import { PiPedalModel, PiPedalModelFactory, FavoritesList } from './PiPedalModel';
import { UiPlugin, PluginType } from './Lv2Plugin';
import TextField from '@mui/material/TextField';
import ButtonBase from '@mui/material/ButtonBase';
import Button from '@mui/material/Button';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';
import PluginInfoDialog from './PluginInfoDialog'
import PluginIcon from './PluginIcon'
import DialogEx from './DialogEx';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import SelectHoverBackground from './SelectHoverBackground';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import IconButton from '@mui/material/IconButton';
import PluginClass from './PluginClass'
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import SearchControl from './SearchControl';
import SearchFilter from './SearchFilter';
import { FixedSizeGrid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import StarBorderIcon from '@mui/icons-material/StarBorder';
import StarIcon from '@mui/icons-material/Star';
import {createStyles} from './WithStyles';
import { Theme } from '@mui/material/styles';
import WithStyles, {withTheme} from './WithStyles';
import { withStyles } from "tss-react/mui";
import FilterListIcon from '@mui/icons-material/FilterList';
import FilterListOffIcon from '@mui/icons-material/FilterListOff';
import { css } from '@emotion/react';
export type CloseEventHandler = () => void;
export type OkEventHandler = (pluginUri: string) => void;
const NARROW_DISPLAY_THRESHOLD = 600;
const FILTER_STORAGE_KEY = "com.twoplay.pipedal.load_dlg.filter";
const pluginGridStyles = (theme: Theme) => createStyles({
frame: css({
position: "relative",
width: "100%",
height: "100%"
}),
top: css({
top: "0px",
right: "0px",
left: "0px",
bottom: "64px",
position: "relative",
flexGrow: 1,
overflowX: "hidden",
overflowY: "visible"
}),
bottom: css({
position: "relative",
bottom: "0px",
height: "64px",
width: "100%",
display: "flex",
flexDirection: "row",
flexWrap: "nowrap",
justifyContent: "space-between",
paddingLeft: "24px",
paddingRight: "48px",
}),
paper: css({
position: "relative",
overflow: "hidden",
height: "56px",
width: "100%",
background: theme.palette.background.paper
}),
buttonBase: css({
width: "100%",
height: "100%'"
}),
content: css({
marginTop: "4px",
marginBottom: "4px",
marginLeft: "8px",
marginRight: "8px",
width: "100%",
overflow: "hidden",
textAlign: "left",
display: "flex",
flexDirection: "row",
justifyContent: "flex-start",
height: 48,
alignItems: "start",
flexWrap: "nowrap"
}),
content2: css({
display: "flex", flexDirection: "column", flex: "1 1 auto", width: "100%",
paddingLeft: 12, whiteSpace: "nowrap", textOverflow: "ellipsis"
}),
favoriteDecoration: css({
flex: "0 0 auto"
}),
table: css({
borderCollapse: "collapse",
}),
icon: css({
width: "24px",
height: "24px",
margin: "0px",
opacity: "0.6"
}),
iconBorder: css({
flex: "0 0 auto",
paddingTop: "4px"
}),
label: css({
width: "100%"
}),
label2: css({
marginTop: 8,
color: theme.palette.text.secondary
}),
control: css({
padding: theme.spacing(1),
}),
tdText: css({
padding: "0px"
})
})
;
interface PluginGridProps extends WithStyles<typeof pluginGridStyles> {
onOk: OkEventHandler;
onCancel: CloseEventHandler;
uri?: string;
minimumItemWidth?: number;
theme: Theme;
open: boolean
};
type PluginGridState = {
selected_uri?: string,
search_string: string;
search_collapsed: boolean;
filterType: PluginType,
client_width: number,
client_height: number,
grid_cell_width: number,
grid_cell_columns: number,
minimumItemWidth: number,
favoritesList: FavoritesList,
uiPlugins: UiPlugin[]
//gridItems: UiPlugin[];
}
export const LoadPluginDialog =
withTheme(
withStyles(
class extends ResizeResponsiveComponent<PluginGridProps, PluginGridState> {
model: PiPedalModel;
searchInputRef: React.RefObject<HTMLInputElement|null>;
constructor(props: PluginGridProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.searchInputRef = React.createRef<HTMLInputElement>();
let filterType_ = PluginType.Plugin; // i.e. "Any".
let persistedFilter = window.localStorage.getItem(FILTER_STORAGE_KEY);
if (persistedFilter) {
filterType_ = persistedFilter as PluginType;
}
this.state = {
selected_uri: this.props.uri,
search_string: "",
search_collapsed: true,
filterType: filterType_,
client_width: window.innerWidth,
client_height: window.innerHeight,
grid_cell_width: this.getCellWidth(window.innerWidth),
grid_cell_columns: this.getCellColumns(window.innerWidth),
minimumItemWidth: props.minimumItemWidth ? props.minimumItemWidth : 220,
favoritesList: this.model.favorites.get(),
uiPlugins: this.model.ui_plugins.get()
};
this.updateWindowSize = this.updateWindowSize.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleOk = this.handleOk.bind(this);
this.handleSearchStringReady = this.handleSearchStringReady.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleFavoritesChanged = this.handleFavoritesChanged.bind(this);
this.handlePluginsChanged = this.handlePluginsChanged.bind(this);
this.requestScrollTo();
}
nominal_column_width: number = 350;
margin_reserve: number = 30;
getCellColumns(width: number) {
let gridWidth = width - this.margin_reserve;
let columns = Math.floor((gridWidth) / this.nominal_column_width);
if (columns < 1) columns = 1;
if (columns > 3) columns = 3;
return columns;
}
getCellWidth(width: number) {
return Math.floor(width / this.getCellColumns(width));
}
handleKeyPress(e: React.KeyboardEvent<HTMLDivElement>) {
let searchInput = this.searchInputRef.current;
if (searchInput && e.target !== searchInput) // if the search input doesn't have focus.
{
if (this.searchInputRef.current) // we do have one, right?
{
if (e.key.length === 1) {
if (/[a-zA-Z0-9]/.exec(e.key)) // if it's alpha-numeric.
{
if (this.state.search_collapsed) // and search is collapsed.
{
e.preventDefault();
let newValue = searchInput.value + e.key;
searchInput.value = newValue; // add the key. to the value.
this.handleSearchStringChanged(newValue);
searchInput.focus();
this.setState({
search_collapsed: false
})
}
}
}
}
}
}
updateWindowSize() {
this.setState({
client_width: window.innerWidth,
client_height: window.innerHeight,
grid_cell_width: this.getCellWidth(window.innerWidth),
grid_cell_columns: this.getCellColumns(window.innerWidth)
});
}
handleFavoritesChanged() {
let favorites = this.model.favorites.get();
this.requestScrollTo();
this.setState(
{
favoritesList: favorites,
});
}
handlePluginsChanged(plugins: UiPlugin[]) {
this.setState(
{
uiPlugins: plugins
}
);
}
mounted: boolean = false;
componentDidMount() {
super.componentDidMount();
this.mounted = true;
this.updateWindowSize();
window.addEventListener('resize', this.updateWindowSize);
this.model.favorites.addOnChangedHandler(this.handleFavoritesChanged);
this.model.ui_plugins.addOnChangedHandler(this.handlePluginsChanged);
this.setState({
favoritesList: this.model.favorites.get(),
uiPlugins: this.model.ui_plugins.get()
});
}
componentWillUnmount() {
this.cancelSearchTimeout();
this.model.ui_plugins.removeOnChangedHandler(this.handlePluginsChanged);
this.model.favorites.removeOnChangedHandler(this.handleFavoritesChanged);
window.removeEventListener('resize', this.updateWindowSize);
this.mounted = false;
super.componentWillUnmount();
}
componentDidUpdate(oldProps: PluginGridProps) {
if (oldProps.open !== this.props.open) {
if (this.props.open) {
// reset state now.
if (this.state.search_string !== "" || !this.state.search_collapsed) {
this.setState({
search_string: "",
search_collapsed: true,
});
}
this.requestScrollTo();
}
}
}
onWindowSizeChanged(width: number, height: number): void {
super.onWindowSizeChanged(width, height);
}
onFilterChange(e: any) {
let filterValue = e.target.value as PluginType;
window.localStorage.setItem(FILTER_STORAGE_KEY, filterValue as string);
this.requestScrollTo();
this.setState({
filterType: filterValue,
});
}
onClearFilter(): void {
let value = PluginType.Plugin;
window.localStorage.setItem(FILTER_STORAGE_KEY, value as string);
if (this.state.filterType !== value) {
this.requestScrollTo();
this.setState({
filterType: value
});
}
}
selectItem(item: number): void {
let uri: string = "";
let plugins = this.model.ui_plugins.get();
if (item >= 0 && item < plugins.length) {
uri = plugins[item].uri;
}
if (uri !== this.state.selected_uri) {
this.setState({ selected_uri: uri });
}
};
handleCancel(e: SyntheticEvent): void {
this.cancel();
}
handleOk(e?: SyntheticEvent): void {
let selectedPlugin: UiPlugin | undefined = undefined;
if (this.state.selected_uri) {
let t = this.model.getUiPlugin(this.state.selected_uri);
if (t) selectedPlugin = t;
}
if (!selectedPlugin)
{
return;
}
if (this.state.selected_uri) {
this.props.onOk(this.state.selected_uri);
}
}
onDoubleClick(e: SyntheticEvent, uri: string): void {
// handled with onClick e.detail which provides better behaviour on Chrome.
// this.props.onOk(uri);
}
cancel(): void {
this.props.onCancel();
}
fireSelected(item?: UiPlugin) {
if (item) {
this.setState({ selected_uri: item.uri });
}
}
onClick(e: React.MouseEvent<HTMLDivElement>, uri: string): void {
e.preventDefault();
this.setState({ selected_uri: uri });
// a better doubleclick: tracks how many recent clicks in the same area.
// regardless of whether we re-rendered in the interrim.
let isDoubleClick = e.detail === 2;
// we have to synthesize double clicks because
// DOM rewrites interfere with natural double click.
if (isDoubleClick) {
this.props.onOk(uri);
}
}
handleMouseEnter(e: SyntheticEvent, uri: string): void {
// this.setHoverUri(uri);
}
handleMouseLeave(e: SyntheticEvent, uri: string): void {
// this.setHoverUri("");
}
onInfoClicked(): void {
// let selectedUri = this.state.selected_uri;
}
vst3_indicator(uiPlugin?: UiPlugin) {
if (uiPlugin?.is_vst3) {
return (<img alt="vst" src="/img/vst.svg" style={{ marginLeft: 8, height: 22, opacity: 0.6, position: "relative", top: -1 }} />)
}
return null;
}
stereo_indicator(uiPlugin?: UiPlugin): string {
if (!uiPlugin) return "";
if (uiPlugin.audio_inputs === 2 || uiPlugin.audio_outputs === 2) {
return " (Stereo)";
}
return "";
}
info_string(uiPlugin?: UiPlugin): ReactElement {
if (!uiPlugin) {
return (<Fragment />);
} else {
let stereoIndicator = "\u00A0" + this.stereo_indicator(uiPlugin)
if (uiPlugin.author_name !== "") {
if (uiPlugin.author_homepage !== "") {
return (<Fragment>
<div style={{ flex: "0 1 auto" }}>
<Typography variant='body2' color="textSecondary" noWrap>{uiPlugin.name + ",\u00A0"}</Typography>
</div>
<div style={{ flex: "0 1 auto" }}>
<a href={uiPlugin.author_homepage} target="_blank" rel="noopener noreferrer" >
<Typography variant='body2' color="textSecondary" noWrap>{uiPlugin.author_name}</Typography>
</a>
</div>
<div style={{ flex: "0 0 auto" }}>
<Typography variant='body2' color="textSecondary" noWrap>{stereoIndicator}</Typography>
</div>
</Fragment>);
} else {
return (<Fragment>
<Typography variant='body2' color="textSecondary" noWrap>{uiPlugin.name + ", " + uiPlugin.author_name + stereoIndicator}</Typography>
</Fragment>);
}
}
return (
<Fragment>
<Typography variant='body2' color="textSecondary" noWrap>{uiPlugin?.name ?? "" + stereoIndicator}</Typography>
</Fragment>);
}
}
createFilterChildren(result: ReactNode[], classNode: PluginClass, level: number): void {
for (let i = 0; i < classNode.children.length; ++i) {
let child = classNode.children[i];
let name = "\u00A0".repeat(level * 3 + 1) + child.display_name;
result.push((<MenuItem key={child.plugin_type} value={child.plugin_type}>{name}</MenuItem>));
if (child.children.length !== 0) {
this.createFilterChildren(result, child, level + 1);
}
}
}
createFilterOptions(): ReactNode[] {
let classes = this.model.plugin_classes.get();
let result: ReactNode[] = [];
result.push((<MenuItem key={PluginType.Plugin} value={PluginType.Plugin}>&nbsp;All</MenuItem>));
this.createFilterChildren(result, classes, 1);
return result;
}
getFilteredPlugins(plugins: UiPlugin[], searchString: string | null, filterType: PluginType | null, favoritesList: FavoritesList | null): UiPlugin[] {
try {
if (searchString === null) {
searchString = this.state.search_string;
}
if (filterType === null) {
filterType = this.state.filterType;
}
if (favoritesList === null) {
favoritesList = this.state.favoritesList;
}
let results: { score: number; plugin: UiPlugin }[] = [];
let searchFilter = new SearchFilter(searchString);
let rootClass = this.model.plugin_classes.get();
for (let i = 0; i < plugins.length; ++i) {
let plugin = plugins[i];
try {
if (filterType === PluginType.Plugin || rootClass.is_type_of(filterType, plugin.plugin_type)) {
let score: number = 0;
if (plugin.is_vst3) {
score = searchFilter.score(plugin.name, plugin.plugin_display_type, plugin.author_name, "vst3");
} else {
score = searchFilter.score(plugin.name, plugin.plugin_display_type, plugin.author_name);
}
if (score !== 0) {
if (favoritesList[plugin.uri]) {
score += 32768;
}
results.push({ score: score, plugin: plugin });
}
}
} catch (e: any) {
alert("Bad plugin:" + (plugin?.name ?? "null") + " plugin type: " + (plugin?.plugin_type ?? "null"
+ " " + e.toString()));
}
}
results.sort((left: { score: number; plugin: UiPlugin }, right: { score: number; plugin: UiPlugin }) => {
if (right.score < left.score) return -1;
if (right.score > left.score) return 1;
return left.plugin.name.localeCompare(right.plugin.name);
});
let t: UiPlugin[] = [];
for (let i = 0; i < results.length; ++i) {
t.push(results[i].plugin);
}
return t;
} catch (e: any) {
alert("getFilteredPlugins: " + e.toString());
throw e;
}
}
changedSearchString?: string = undefined;
hSearchTimeout?: number;
private scrollToRequested = false;
requestScrollTo() {
this.scrollToRequested = true;
}
private cachedGridItems: UiPlugin[] = []; // retained for the scrollTo callback.
handleScrollToCallback(element: FixedSizeGrid): void {
if (element) {
if (this.scrollToRequested) {
this.scrollToRequested = false;
let position = -1;
let gridItems = this.cachedGridItems;
for (let i = 0; i < gridItems.length; ++i) {
if (this.state.selected_uri === gridItems[i].uri) {
position = i;
break;
}
}
if (position !== -1) {
element.scrollToItem({ rowIndex: Math.floor(position / this.state.grid_cell_columns) });
}
}
}
}
handleSearchStringReady() {
if (!this.mounted)
{
return;
}
if (this.changedSearchString !== undefined) {
this.requestScrollTo();
this.setState({
search_string: this.changedSearchString
});
this.changedSearchString = undefined;
}
this.hSearchTimeout = undefined;
}
handleApplyFilter()
{
this.handleSearchStringReady();
}
cancelSearchTimeout()
{
if (this.hSearchTimeout) {
clearTimeout(this.hSearchTimeout);
this.hSearchTimeout = undefined;
}
}
handleSearchStringChanged(text: string): void {
this.cancelSearchTimeout();
this.changedSearchString = text;
this.hSearchTimeout = setTimeout(
this.handleSearchStringReady,
2000);
}
renderItem(gridItems: UiPlugin[], row: number, column: number): React.ReactNode {
let item: number = (row) * this.gridColumnCount + (column);
if (item >= gridItems.length) {
return (<div />);
}
let value = gridItems[item];
const classes = withStyles.getClasses(this.props);
let isFavorite: boolean = this.state.favoritesList[value.uri] ?? false;
return (
<div key={value.uri}
onDoubleClick={(e) => { this.onDoubleClick(e, value.uri) }}
onClick={(e) => { this.onClick(e, value.uri) }}
onMouseEnter={(e) => { this.handleMouseEnter(e, value.uri) }}
onMouseLeave={(e) => { this.handleMouseLeave(e, value.uri) }}
>
<ButtonBase className={classes.buttonBase} >
<SelectHoverBackground selected={value.uri === this.state.selected_uri} showHover={true} />
<div className={classes.content}>
<div className={classes.iconBorder} >
<PluginIcon pluginType={value.plugin_type} size={24} opacity={0.6} />
</div>
<div className={classes.content2}>
<div className={classes.label} style={{ display: "flex", flexFlow: "row nowrap", alignItems: "center" }} >
<Typography color="textPrimary" noWrap sx={{ display: "block", flex: "0 1 auto", }} >
{value.name}
</Typography>
{
isFavorite && (
<div style={{ flex: "0 0 auto" }}>
<StarIcon sx={{ color: "#C80", fontSize: 16, marginRight: "2px" }} />
</div>
)
}
</div>
<Typography color="textSecondary" noWrap>
{value.plugin_display_type} {this.stereo_indicator(value)}
</Typography>
</div>
<div className={classes.favoriteDecoration}>
</div>
</div>
</ButtonBase>
</div>
);
}
setFavorite(pluginUri: string | undefined, isFavorite: boolean): void {
if (!pluginUri) return;
this.model.setFavorite(pluginUri, isFavorite);
}
gridColumnCount: number = 1;
render() {
const classes= withStyles.getClasses(this.props);
let selectedPlugin: UiPlugin | undefined = undefined;
if (this.state.selected_uri) {
let t = this.model.getUiPlugin(this.state.selected_uri);
if (t) selectedPlugin = t;
}
let showSearchIcon = true;
if (this.state.client_width < 500) {
showSearchIcon = this.state.search_collapsed
}
let gridColumnCount = this.state.grid_cell_columns;
this.gridColumnCount = gridColumnCount;
let isFavorite = this.state.favoritesList[this.state.selected_uri ?? ""];
return (
<React.Fragment>
<DialogEx tag="plugins"
onEnterKey={()=>{ this.handleOk(); }}
onKeyPress={(e) => { this.handleKeyPress(e); }}
fullScreen={true}
TransitionComponent={undefined}
maxWidth={false}
open={this.props.open}
scroll="body"
onClose={this.handleCancel}
style={{ overflowX: "hidden", overflowY: "hidden", display: "flex", flexDirection: "column", flexWrap: "nowrap" }}
aria-labelledby="select-plugin-dialog-title">
<div style={{ display: "flex", flexDirection: "column", flexWrap: "nowrap", height: "100%" }}>
<DialogTitle id="select-plugin-dialog-title" style={{ flex: "0 0 auto", padding: "0px", height: 54 }}>
<div style={{ display: "flex", flexDirection: "row", paddingTop: 3, paddingBottom: 3, flexWrap: "nowrap", width: "100%", alignItems: "center" }}>
<IconButton onClick={() => { this.cancel(); }} style={{ flex: "0 0 auto" }} >
<ArrowBackIcon />
</IconButton>
<Typography display="inline" noWrap variant="h6" style={{
flex: "0 0 auto", height: "auto", verticalAlign: "center",
visibility: (this.state.search_collapsed ? "visible" : "collapse"),
width: (this.state.search_collapsed ? undefined : "0px")
}}>
{this.state.client_width > 520 ? "Select Plugin" : ""}
</Typography>
<div style={{ flex: "1 1 auto" }} >
<SearchControl collapsed={this.state.search_collapsed} searchText={this.state.search_string}
inputRef={this.searchInputRef}
showSearchIcon={showSearchIcon}
onTextChanged={(text) => {
this.handleSearchStringChanged(text);
}}
onApplyFilter={() => {
this.handleApplyFilter();
}}
onClearFilterClick={() => {
if (this.state.search_string !== "") {
this.requestScrollTo();
this.setState({
search_collapsed: true,
search_string: ""
});
} else {
this.setState({ search_collapsed: true });
}
}}
onClick={() => {
if (this.state.search_collapsed) {
this.setState({ search_collapsed: false });
} else {
if (this.state.search_string !== "") {
this.requestScrollTo();
this.setState({
search_collapsed: true,
search_string: ""
});
} else {
this.setState({ search_collapsed: true });
}
}
}}
/>
</div>
<div style={{ flex: "0 0 auto" }} >
<IconButton onClick={() => { this.onClearFilter(); }}>
{this.state.filterType === PluginType.Plugin ? (
<FilterListIcon fontSize='small' style={{ opacity: 0.75 }} />
) : (
<FilterListOffIcon fontSize='small' style={{ opacity: 0.75 }} />
)}
</IconButton>
</div>
<div style={{ flex: "0 0 160px", marginRight: 24 }} >
<TextField select variant="standard"
defaultValue={this.state.filterType}
key={this.state.filterType}
onChange={(e) => { this.onFilterChange(e); }}
sx={{ minWidth: 160 }}
>
{this.createFilterOptions()}
</TextField>
</div>
</div>
</DialogTitle>
<DialogContent dividers style={{
height: "100px",
padding: 8,
flex: "1 1 100px",
}} >
<AutoSizer>
{(arg: any) => {
if ((!arg.width) || (!arg.height)) {
return (<div></div>);
}
let width = arg.width ?? 1;
let height = arg.height ?? 1;
let gridItems = this.getFilteredPlugins(
this.state.uiPlugins, this.state.search_string, this.state.filterType, this.state.favoritesList);
this.cachedGridItems = gridItems;
let scrollRef = (grid: FixedSizeGrid) => { this.handleScrollToCallback(grid); }
return (
<FixedSizeGrid
ref={scrollRef}
width={width}
columnCount={this.gridColumnCount}
columnWidth={(width - 40) / this.gridColumnCount}
height={height}
rowHeight={64}
overscanRowCount={10}
rowCount={Math.ceil(gridItems.length / this.gridColumnCount)}
itemKey={(args: { columnIndex: number, data: any, rowIndex: number }) => {
let index = args.columnIndex + this.gridColumnCount * args.rowIndex;
if (index >= gridItems.length) {
return "blank-" + args.columnIndex + "-" + args.rowIndex;
}
let plugin = gridItems[index];
return plugin.uri + "-" + args.rowIndex + "-" + args.columnIndex;
}}
>
{(arg: { columnIndex: number, rowIndex: number, style: CSSProperties }) => (
<div style={arg.style} >
{
this.renderItem(gridItems, arg.rowIndex, arg.columnIndex)
}
</div>
)}
</FixedSizeGrid>
);
}
}
</AutoSizer>
</DialogContent>
{(this.state.client_width >= NARROW_DISPLAY_THRESHOLD) ? (
<DialogActions style={{ flex: "0 0 auto" }} >
<div className={classes.bottom}>
<div style={{ display: "flex", textOverflow: "ellipsis", justifyContent: "start", alignItems: "center", flex: "1 1 auto", height: "100%", overflow: "hidden" }} >
<PluginInfoDialog plugin_uri={this.state.selected_uri ?? ""} />
{this.info_string(selectedPlugin)}
{this.vst3_indicator(selectedPlugin)}
<div style={{
color: isFavorite ? "#F80" : "#CCC", display: (this.state.selected_uri !== "" ? "block" : "none"),
position: "relative", top: -2
}}>
<IconButton color="inherit" aria-label="Set as favorite"
onClick={() => { this.setFavorite(this.state.selected_uri, !isFavorite); }}
>
<StarBorderIcon />
</IconButton>
</div>
</div>
<div style={{ position: "relative", float: "right", flex: "0 1 200px", width: 200, display: "flex", alignItems: "center" }}>
<Button variant="dialogSecondary" onClick={this.handleCancel} style={{ width: 120 }} >Cancel</Button>
<Button variant="dialogPrimary" onClick={this.handleOk} disabled={selectedPlugin === null} style={{ width: 180 }} >SELECT</Button>
</div>
</div>
</DialogActions>
) : (
<DialogActions style={{ flex: "0 0 auto", display: "block" }} >
<div style={{ display: "flex", justifyContent: "start", alignItems: "center", flex: "1 1 auto", overflow: "hidden" }} >
<PluginInfoDialog plugin_uri={this.state.selected_uri ?? ""} />
<div style={{ width: 1, height: 48 }} />
{this.info_string(selectedPlugin)}
<div style={{
color: isFavorite ? "#F80" : "#CCC", display: (this.state.selected_uri ? "block" : "block"),
position: "relative"
}}>
{
this.state.selected_uri !== "uri://two-play/pipedal/pedalboard#Empty" && (
<IconButton color="inherit" aria-label="Set as favorite"
onClick={() => { this.setFavorite(this.state.selected_uri, !isFavorite); }}
>
<StarBorderIcon />
</IconButton>
)
}
</div>
</div>
<div className={classes.bottom} style={{ height: 64 }}>
<div style={{ flex: "1 1 1px" }} />
<div style={{ position: "relative", flex: "0 1 auto", display: "flex", alignItems: "center" }}>
<Button variant="dialogSecondary" onClick={this.handleCancel} style={{ height: 48 }} >Cancel</Button>
<Button variant="dialogPrimary" onClick={this.handleOk} disabled={selectedPlugin === null} style={{ height: 48 }} >SELECT</Button>
</div>
</div>
</DialogActions>
)}
</div>
</DialogEx>
</React.Fragment >
);
}
}
,
pluginGridStyles
));
export default LoadPluginDialog;
File diff suppressed because it is too large Load Diff
+647
View File
@@ -0,0 +1,647 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { SyntheticEvent } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles, {withTheme} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import {
Pedalboard, PedalboardItem, PedalboardSplitItem, SplitType
} from './Pedalboard';
import Button from '@mui/material/Button';
import InputIcon from '@mui/icons-material/Input';
import LoadPluginDialog from './LoadPluginDialog';
import Switch from '@mui/material/Switch';
import Typography from '@mui/material/Typography';
import PedalboardView from './PedalboardView';
import { PiPedalStateError } from './PiPedalError';
import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Fade from '@mui/material/Fade';
import Divider from '@mui/material/Divider';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import PluginInfoDialog from './PluginInfoDialog';
import { GetControlView } from './ControlViewFactory';
import MidiBindingsDialog from './MidiBindingsDialog';
import PluginPresetSelector from './PluginPresetSelector';
import OldDeleteIcon from "./svg/old_delete_outline_24dp.svg?react";
import MidiIcon from "./svg/ic_midi.svg?react";
import { isDarkMode } from './DarkMode';
import Snapshot0Icon from "./svg/snapshot_0.svg?react";
import Snapshot1Icon from "./svg/snapshot_1.svg?react";
import Snapshot2Icon from "./svg/snapshot_2.svg?react";
import Snapshot3Icon from "./svg/snapshot_3.svg?react";
import Snapshot4Icon from "./svg/snapshot_4.svg?react";
import Snapshot5Icon from "./svg/snapshot_5.svg?react";
import Snapshot6Icon from "./svg/snapshot_6.svg?react";
import SnapshotDialog from './SnapshotDialog';
import { css } from '@emotion/react';
const SPLIT_CONTROLBAR_THRESHHOLD = 750;
const DISPLAY_AUTHOR_THRESHHOLD = 750;
const DISPLAY_AUTHOR_SPLIT_THRESHOLD = 500;
const HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK = 500;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// const HORIZONTAL_LAYOUT_MQ = "@media (max-height: " + HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK + "px)";
const styles = ({ palette }: Theme) => { return {
frame: css({
position: "absolute", display: "flex", flexDirection: "column", flexWrap: "nowrap",
justifyContent: "flex-start", left: "0px", top: "0px", bottom: "0px", right: "0px", overflow: "hidden"
}),
pedalboardScroll: css({
position: "relative", width: "100%",
flex: "0 0 auto", overflow: "auto", maxHeight: 220
}),
pedalboardScrollSmall: css({
position: "relative", width: "100%",
flex: "1 1 1px", overflow: "auto"
}),
separator: css({
width: "100%", height: "1px", background: "#888", opacity: "0.5",
flex: "0 0 1px"
}),
controlToolBar: css({
flex: "0 0 auto", width: "100%", height: 48
}),
splitControlBar: css({
flex: "0 0 64px", width: "100%", paddingLeft: 24, paddingRight: 16, paddingBottom: 16
}),
controlContent: css({
flex: "1 1 auto", width: "100%", overflowY: "auto", minHeight: 240
}),
controlContentSmall: css({
flex: "0 0 162px", width: "100%", height: 162, overflowY: "hidden",
}),
title: css({ fontSize: "1.1em", fontWeight: 700, marginRight: 8, textOverflow: "ellipsis", whiteSpace: "nowrap", opacity: 0.75 }),
author: css({ fontWeight: 500, marginRight: 8, textOverflow: "ellipsis", whiteSpace: "nowrap", opacity: 0.75 })
}
};
interface MainProps extends WithStyles<typeof styles> {
hasTinyToolBar: boolean;
theme: Theme;
enableStructureEditing: boolean;
}
interface MainState {
selectedPedal: number;
selectedSnapshot: number;
loadDialogOpen: boolean;
snapshotDialogOpen: boolean;
pedalboard: Pedalboard;
addMenuAnchorEl: HTMLElement | null;
splitControlBar: boolean;
displayAuthor: boolean;
horizontalScrollLayout: boolean;
showMidiBindingsDialog: boolean;
screenHeight: number;
}
export const MainPage =
withTheme(
withStyles(
class extends ResizeResponsiveComponent<MainProps, MainState> {
model: PiPedalModel;
getSplitToolbar() {
return this.windowSize.width < SPLIT_CONTROLBAR_THRESHHOLD;
}
getDisplayAuthor()
{
if (this.getSplitToolbar())
{
return this.windowSize.width >= DISPLAY_AUTHOR_SPLIT_THRESHOLD;
} else {
return this.windowSize.width >= DISPLAY_AUTHOR_THRESHHOLD;
}
}
constructor(props: MainProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
let pedalboard = this.model.pedalboard.get();
let selectedPedal = pedalboard.getFirstSelectableItem();
this.state = {
selectedPedal: selectedPedal,
selectedSnapshot: -1,
loadDialogOpen: false,
snapshotDialogOpen: false,
pedalboard: pedalboard,
addMenuAnchorEl: null,
splitControlBar: this.getSplitToolbar(),
displayAuthor: this.getDisplayAuthor(),
horizontalScrollLayout: this.windowSize.height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK,
showMidiBindingsDialog: false,
screenHeight: this.windowSize.height
};
this.onSelectionChanged = this.onSelectionChanged.bind(this);
this.onPedalDoubleClick = this.onPedalDoubleClick.bind(this);
this.onLoadClick = this.onLoadClick.bind(this);
this.onLoadOk = this.onLoadOk.bind(this);
this.onLoadCancel = this.onLoadCancel.bind(this);
this.onPedalboardChanged = this.onPedalboardChanged.bind(this);
this.onSelectedSnapshotChanged = this.onSelectedSnapshotChanged.bind(this);
this.handleEnableCurrentItemChanged = this.handleEnableCurrentItemChanged.bind(this);
}
onInsertPedal(instanceId: number) {
this.setAddMenuAnchorEl(null);
let newId = this.model.addPedalboardItem(instanceId, false);
this.setSelection(newId);
}
onAppendPedal(instanceId: number) {
this.setAddMenuAnchorEl(null);
let newId = this.model.addPedalboardItem(instanceId, true);
this.setSelection(newId);
}
onInsertSplit(instanceId: number) {
this.setAddMenuAnchorEl(null);
let newId = this.model.addPedalboardSplitItem(instanceId, false);
this.setSelection(newId);
}
onAppendSplit(instanceId: number) {
this.setAddMenuAnchorEl(null);
let newId = this.model.addPedalboardSplitItem(instanceId, true);
this.setSelection(newId);
}
setAddMenuAnchorEl(value: HTMLElement | null) {
this.setState({ addMenuAnchorEl: value });
}
onAddClick(e: SyntheticEvent) {
this.setAddMenuAnchorEl(e.currentTarget as HTMLElement);
}
handleMidiBindingsDialogClose() {
this.setState({ showMidiBindingsDialog: false })
}
handleAddClose(): void {
this.setAddMenuAnchorEl(null);
}
handleMidiConfiguration(instanceId: number): void {
this.setState({ showMidiBindingsDialog: true });
}
handleEnableCurrentItemChanged(event: any): void {
let newValue = event.target.checked;
let item = this.getSelectedPedalboardItem();
if (item != null) {
this.model.setPedalboardItemEnabled(item.getInstanceId(), newValue);
}
}
handleSelectPluginPreset(instanceId: number, presetInstanceId: number) {
this.model.loadPluginPreset(instanceId, presetInstanceId);
}
onPedalboardChanged(value: Pedalboard) {
let selectedItem = -1;
if (this.state.selectedPedal === Pedalboard.START_CONTROL || this.state.selectedPedal === Pedalboard.END_CONTROL) {
selectedItem = this.state.selectedPedal;
} else if (value.hasItem(this.state.selectedPedal)) {
selectedItem = this.state.selectedPedal;
} else {
selectedItem = value.getFirstSelectableItem();
}
this.setState({
pedalboard: value,
selectedPedal: selectedItem,
selectedSnapshot: value.selectedSnapshot
});
}
onSelectedSnapshotChanged(selectedSnapshot: number) {
this.setState({
selectedSnapshot: selectedSnapshot
});
}
onDeletePedal(instanceId: number): void {
let result = this.model.deletePedalboardPedal(instanceId);
if (result != null) {
this.setState({ selectedPedal: result });
}
}
componentDidMount() {
super.componentDidMount();
this.model.pedalboard.addOnChangedHandler(this.onPedalboardChanged);
this.model.selectedSnapshot.addOnChangedHandler(this.onSelectedSnapshotChanged);
}
componentWillUnmount() {
this.model.selectedSnapshot.removeOnChangedHandler(this.onSelectedSnapshotChanged);
this.model.pedalboard.removeOnChangedHandler(this.onPedalboardChanged);
super.componentWillUnmount();
}
updateResponsive() {
this.setState({
splitControlBar: this.getSplitToolbar(),
displayAuthor: this.getDisplayAuthor(),
horizontalScrollLayout: this.windowSize.height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK,
screenHeight: this.windowSize.height
});
}
onWindowSizeChanged(width: number, height: number): void {
super.onWindowSizeChanged(width, height);
this.updateResponsive();
}
setSelection(selectedId_: number): void {
this.setState({ selectedPedal: selectedId_ });
}
onSelectionChanged(selectedId: number): void {
this.setSelection(selectedId);
}
onPedalDoubleClick(selectedId: number): void {
this.setSelection(selectedId);
let item = this.getPedalboardItem(selectedId);
if (item != null) {
if (item.isStart() || item.isEnd())
{
// do nothing.
} else if (item.isSplit()) {
let split = item as PedalboardSplitItem;
if (split.getSplitType() === SplitType.Ab) {
let cv = split.getToggleAbControlValue();
if (split.instanceId === undefined) throw new PiPedalStateError("Split without valid id.");
this.model.setPedalboardControl(split.instanceId, cv.key, cv.value);
}
} else {
this.setState({ loadDialogOpen: true });
}
}
}
onLoadCancel(): void {
this.setState({ loadDialogOpen: false });
}
onLoadOk(selectedUri: string): void {
this.setState({ loadDialogOpen: false });
let itemId = this.state.selectedPedal;
let newSelectedItem = this.model.loadPedalboardPlugin(itemId, selectedUri);
this.setState({ selectedPedal: newSelectedItem });
}
onSnapshotDialogOk(): void {
this.setState({ snapshotDialogOpen: false });
}
onLoadClick(e: SyntheticEvent) {
this.setState({ loadDialogOpen: true });
e.preventDefault();
e.stopPropagation();
}
onSnapshotClick() {
this.setState({ snapshotDialogOpen: true });
}
getPedalboardItem(selectedId?: number): PedalboardItem | null {
if (selectedId === undefined) return null;
let pedalboard = this.model.pedalboard.get();
if (!pedalboard) return null;
if (selectedId === Pedalboard.START_CONTROL) // synthetic input volume item.
{
return pedalboard.makeStartItem();
} else if (selectedId === Pedalboard.END_CONTROL) // synthetic output volume.
{
return pedalboard.makeEndItem();
}
let it = pedalboard.itemsGenerator();
if (!selectedId) return null;
while (true) {
let v = it.next();
if (v.done) break;
let item = v.value;
if (item.instanceId === selectedId) {
return item;
}
}
return null;
}
onPedalboardPropertyChanged(instanceId: number, key: string, value: number) {
this.model.setPedalboardControl(instanceId, key, value);
}
getSelectedPedalboardItem(): PedalboardItem | null {
return this.getPedalboardItem(this.state.selectedPedal);
}
getSelectedUri(): string {
let pedalboardItem = this.getSelectedPedalboardItem();
if (pedalboardItem === null) return "";
return pedalboardItem.uri;
}
titleBar(pedalboardItem: PedalboardItem | null): React.ReactNode {
let title = "";
let author = "";
let infoPluginUri = "";
let presetsUri = "";
let missing = false;
if (pedalboardItem) {
if (pedalboardItem.isEmpty()) {
title = "";
} else if (pedalboardItem.isSplit()) {
title = "Split";
} else if (pedalboardItem.isSyntheticItem()) {
title = pedalboardItem.pluginName ?? "#error";
author = "";
presetsUri = "";
infoPluginUri = "";
}
else {
let uiPlugin = this.model.getUiPlugin(pedalboardItem.uri);
if (!uiPlugin) {
missing = true;
title = pedalboardItem?.pluginName ?? "Missing plugin";
} else {
title = uiPlugin.name;
author = uiPlugin.author_name;
presetsUri = uiPlugin.uri;
// if (uiPlugin.description.length > 20) {
// }
infoPluginUri = uiPlugin.uri;
}
}
}
const classes = withStyles.getClasses(this.props);
if (missing) {
return (
<div style={{
flex: "1 0 auto", overflow: "hidden", marginRight: 8, minWidth: 0,
display: "flex", flexDirection: "row", height: 48, flexWrap: "nowrap",
alignItems: "center"
}}>
<div style={{ flex: "0 1 auto", minWidth: 0 }}>
<span style={{ color: isDarkMode() ? "#F02020" : "#800000" }}>
<span className={classes.title}>{title}</span>
</span>
</div>
</div>
);
} else {
return (
<div style={{
flex: "1 1 auto", minWidth: 0, overflow: "hidden", marginRight: 8,
display: "flex", flexDirection: "row", height: 48, flexWrap: "nowrap",
alignItems: "center"
}}>
<div style={{ flex: "0 1 auto", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }}>
<span className={classes.title}>{title}</span>
{this.state.displayAuthor&&(
<span className={classes.author}>{author}</span>
)}
</div>
<div style={{ flex: "0 0 auto", verticalAlign: "center" }}>
<PluginInfoDialog plugin_uri={infoPluginUri} />
</div>
<div style={{ flex: "0 0 auto" }}>
<PluginPresetSelector pluginUri={presetsUri} instanceId={pedalboardItem?.instanceId ?? 0}
/>
</div>
</div>
);
}
}
snapshotIcon(theme: Theme, snapshotNumber: number) {
switch (snapshotNumber + 1) {
case 0:
default:
return (<Snapshot0Icon style={{ height: 24, width: 24, fill: theme.palette.text.primary, opacity: 0.6 }} />);
case 1:
return (<Snapshot1Icon style={{ height: 24, width: 24, fill: theme.palette.text.primary, opacity: 0.6 }} />);
case 2:
return (<Snapshot2Icon style={{ height: 24, width: 24, fill: theme.palette.text.primary, opacity: 0.6 }} />);
case 3:
return (<Snapshot3Icon style={{ height: 24, width: 24, fill: theme.palette.text.primary, opacity: 0.6 }} />);
case 4:
return (<Snapshot4Icon style={{ height: 24, width: 24, fill: theme.palette.text.primary, opacity: 0.6 }} />);
case 5:
return (<Snapshot5Icon style={{ height: 24, width: 24, fill: theme.palette.text.primary, opacity: 0.6 }} />);
case 6:
return (<Snapshot6Icon style={{ height: 24, width: 24, fill: theme.palette.text.primary, opacity: 0.6 }} />);
}
}
render() {
const classes = withStyles.getClasses(this.props);
let pedalboard = this.model.pedalboard.get();
let pedalboardItem = this.getSelectedPedalboardItem();
let uiPlugin = null;
let bypassVisible = false;
let bypassChecked = false;
let canDelete = false;
let canInsert = false;
let canAppend = false;
let canLoad = true;
let instanceId = -1;
let missing = false;
let pluginUri = "#error";
if (pedalboardItem) {
canDelete = pedalboard.canDeleteItem(pedalboardItem.instanceId);
instanceId = pedalboardItem.instanceId;
if (pedalboardItem.isEmpty()) {
canInsert = true;
canAppend = true;
} else if (pedalboardItem.isStart()) {
canAppend = true;
canDelete = false;
canLoad = false;
} else if (pedalboardItem.isEnd()) {
canInsert = true;
canDelete = false;
canLoad = false;
} else if (pedalboardItem.isSplit()) {
canInsert = true;
canAppend = true;
canLoad = false;
} else {
pluginUri = pedalboardItem.uri;
uiPlugin = this.model.getUiPlugin(pluginUri);
canInsert = true;
canAppend = true;
if (uiPlugin) {
bypassVisible = true;
bypassChecked = pedalboardItem.isEnabled;
} else {
missing = true;
}
}
}
let horizontalScrollLayout = this.state.horizontalScrollLayout;
return (
<div className={classes.frame}>
<div id="pedalboardScroll" className={horizontalScrollLayout ? classes.pedalboardScrollSmall : classes.pedalboardScroll}
style={{ maxHeight: horizontalScrollLayout ? undefined : this.state.screenHeight / 2 }}>
<PedalboardView key={pluginUri} selectedId={this.state.selectedPedal}
enableStructureEditing={this.props.enableStructureEditing}
onSelectionChanged={this.onSelectionChanged}
onDoubleClick={this.onPedalDoubleClick}
hasTinyToolBar={this.props.hasTinyToolBar}
/>
</div>
<div className={classes.separator} />
<div className={classes.controlToolBar}>
<div style={{
display: "flex", flexFlow: "row nowrap", alignItems: "center", justifyContent: "center", minWidth: 0,
width: "100%", height: 48, paddingLeft: 16, paddingRight: 16
}} >
<div style={{ flex: "0 0 auto", width: this.state.splitControlBar? undefined: 80 }} >
<div style={{ display: bypassVisible ? "block" : "none", width: this.state.splitControlBar? undefined: 80 }} >
<Switch color="secondary" checked={bypassChecked} onChange={this.handleEnableCurrentItemChanged} />
</div>
</div>
{
(!this.state.splitControlBar || !this.props.enableStructureEditing) && this.titleBar(pedalboardItem)
}
<div style={{ flex: "1 1 1px" }}>
</div>
{this.props.enableStructureEditing && (
<div style={{ flex: "0 0 auto", display: "flex", flexFlow: "row nowrap",alignItems: "center" }}>
<div style={{ flex: "0 0 auto", display: (canInsert || canAppend) ? "block" : "none", paddingRight: 8 }}>
<IconButton onClick={(e) => { this.onAddClick(e) }} size="large">
<AddIcon style={{ height: 24, width: 24, fill: this.props.theme.palette.text.primary, opacity: 0.6 }} />
</IconButton>
<Menu
id="add-menu"
anchorEl={this.state.addMenuAnchorEl}
keepMounted
open={Boolean(this.state.addMenuAnchorEl)}
onClose={() => this.handleAddClose()}
TransitionComponent={Fade}
>
{canInsert && (<MenuItem onClick={() => this.onInsertPedal(instanceId)}>Insert pedal</MenuItem>)}
{canAppend && (<MenuItem onClick={() => this.onAppendPedal(instanceId)}>Append pedal</MenuItem>)}
<Divider />
{canInsert && (<MenuItem onClick={() => this.onInsertSplit(instanceId)}>Insert split</MenuItem>)}
{canAppend && (<MenuItem onClick={() => this.onAppendSplit(instanceId)}>Append split</MenuItem>)}
</Menu>
</div>
<div style={{ flex: "0 0 auto", display: canDelete ? "block" : "none", paddingRight: 8 }}>
<IconButton
onClick={() => { this.onDeletePedal(pedalboardItem?.instanceId ?? -1) }}
size="large">
<OldDeleteIcon style={{ height: 24, width: 24, fill: this.props.theme.palette.text.primary, opacity: 0.6 }} />
</IconButton>
</div>
<div style={{ flex: "0 0 auto" }}>
<Button
variant="contained"
color="primary"
size="small"
onClick={this.onLoadClick}
disabled={this.state.selectedPedal === -1 || (!canLoad) || (this.getSelectedPedalboardItem()?.isSplit() ?? true)}
startIcon={<InputIcon />}
style={{
textTransform: "none",
background: (isDarkMode() ? "#6750A4" : undefined)
}}
>
Load
</Button>
</div>
<div style={{ flex: "0 0 auto" }}>
<IconButton
onClick={(e) => { this.handleMidiConfiguration(instanceId); }}
size="large">
<MidiIcon style={{ height: 24, width: 24, fill: this.props.theme.palette.text.primary, opacity: 0.6 }} />
</IconButton>
</div>
<div style={{ flex: "0 0 auto" }}>
<IconButton
onClick={(e) => { this.setState({ snapshotDialogOpen: true }); }}
size="large">
{this.snapshotIcon(this.props.theme, this.state.selectedSnapshot)}
</IconButton>
</div>
</div>
)}
</div>
</div>
{
this.state.splitControlBar && this.props.enableStructureEditing && (
<div className={classes.splitControlBar}>
{
this.titleBar(pedalboardItem)
}
</div>
)
}
<div className={horizontalScrollLayout ? classes.controlContentSmall : classes.controlContent}>
{
missing ? (
<div style={{ marginLeft: 40, marginTop: 20 }}>
<Typography variant="body1" paragraph={true}>Error: Plugin is not installed.</Typography>
<Typography variant="body2" paragraph={true}>{pluginUri}</Typography>
</div>
) :
(
GetControlView(pedalboardItem)
)
}
</div>
<MidiBindingsDialog open={this.state.showMidiBindingsDialog}
onClose={() => this.setState({ showMidiBindingsDialog: false })}
/>
{
(this.state.loadDialogOpen) && (
<LoadPluginDialog open={this.state.loadDialogOpen} uri={this.getSelectedUri()}
onOk={this.onLoadOk} onCancel={this.onLoadCancel}
/>
)
}
{(this.state.snapshotDialogOpen) && (
<SnapshotDialog open={this.state.snapshotDialogOpen} onOk={() => this.onSnapshotDialogOk()} />
)}
</div>
);
}
},
styles
));
export default MainPage
+356
View File
@@ -0,0 +1,356 @@
import { isDarkMode } from "./DarkMode";
export const materialColors: { [Name: string]: { [Name: number]: string } } = {
"grey": {
50: '#FAFAFA',
100: '#F5F5F5',
200: '#EEEEEE',
300: '#E0E0E0',
400: '#BDBDBD',
500: '#9E9E9E',
600: '#757575',
700: '#616161',
800: '#424242',
900: '#212121'
},
"blueGrey": {
50: '#ECEFF1',
100: '#CFD8DC',
200: '#B0BEC5',
300: '#90A4AE',
400: '#78909C',
500: '#607D8B',
600: '#546E7A',
700: '#455A64',
800: '#37474F',
900: '#263238'
},
"red": {
50: '#FFEBEE',
100: '#FFCDD2',
200: '#EF9A9A',
300: '#E57373',
400: '#EF5350',
500: '#F44336',
600: '#E53935',
700: '#D32F2F',
800: '#C62828',
900: '#B71C1C',
// A100: '#FF8A80',
// A200: '#FF5252',
// A400: '#FF1744',
// A700: '#D50000'
},
"pink": {
50: '#FCE4EC',
100: '#F8BBD0',
200: '#F48FB1',
300: '#F06292',
400: '#EC407A',
500: '#E91E63',
600: '#D81B60',
700: '#C2185B',
800: '#AD1457',
900: '#880E4F',
// A100: '#FF80AB',
// A200: '#FF4081',
// A400: '#F50057',
// A700: '#C51162'
},
"purple": {
50: '#F3E5F5',
100: '#E1BEE7',
200: '#CE93D8',
300: '#BA68C8',
400: '#AB47BC',
500: '#9C27B0',
600: '#8E24AA',
700: '#7B1FA2',
800: '#6A1B9A',
900: '#4A148C',
// A100: '#EA80FC',
// A200: '#E040FB',
// A400: '#D500F9',
// A700: '#AA00FF'
},
"deepPurple": {
50: '#EDE7F6',
100: '#D1C4E9',
200: '#B39DDB',
300: '#9575CD',
400: '#7E57C2',
500: '#673AB7',
600: '#5E35B1',
700: '#512DA8',
800: '#4527A0',
900: '#311B92',
// A100: '#B388FF',
// A200: '#7C4DFF',
// A400: '#651FFF',
// A700: '#6200EA'
},
"indigo": {
50: '#E8EAF6',
100: '#C5CAE9',
200: '#9FA8DA',
300: '#7986CB',
400: '#5C6BC0',
500: '#3F51B5',
600: '#3949AB',
700: '#303F9F',
800: '#283593',
900: '#1A237E',
// A100: '#8C9EFF',
// A200: '#536DFE',
// A400: '#3D5AFE',
// A700: '#304FFE'
},
"blue": {
50: '#E3F2FD',
100: '#BBDEFB',
200: '#90CAF9',
300: '#64B5F6',
400: '#42A5F5',
500: '#2196F3',
600: '#1E88E5',
700: '#1976D2',
800: '#1565C0',
900: '#0D47A1',
// A100: '#82B1FF',
// A200: '#448AFF',
// A400: '#2979FF',
// A700: '#2962FF'
},
"lightBlue": {
50: '#E1F5FE',
100: '#B3E5FC',
200: '#81D4FA',
300: '#4FC3F7',
400: '#29B6F6',
500: '#03A9F4',
600: '#039BE5',
700: '#0288D1',
800: '#0277BD',
900: '#01579B',
// A100: '#80D8FF',
// A200: '#40C4FF',
// A400: '#00B0FF',
// A700: '#0091EA'
},
"cyan": {
50: '#E0F7FA',
100: '#B2EBF2',
200: '#80DEEA',
300: '#4DD0E1',
400: '#26C6DA',
500: '#00BCD4',
600: '#00ACC1',
700: '#0097A7',
800: '#00838F',
900: '#006064',
// A100: '#84FFFF',
// A200: '#18FFFF',
// A400: '#00E5FF',
// A700: '#00B8D4'
},
"teal": {
50: '#E0F2F1',
100: '#B2DFDB',
200: '#80CBC4',
300: '#4DB6AC',
400: '#26A69A',
500: '#009688',
600: '#00897B',
700: '#00796B',
800: '#00695C',
900: '#004D40',
// A100: '#A7FFEB',
// A200: '#64FFDA',
// A400: '#1DE9B6',
// A700: '#00BFA5'
},
"green": {
50: '#E8F5E9',
100: '#C8E6C9',
200: '#A5D6A7',
300: '#81C784',
400: '#66BB6A',
500: '#4CAF50',
600: '#43A047',
700: '#388E3C',
800: '#2E7D32',
900: '#1B5E20',
// A100: '#B9F6CA',
// A200: '#69F0AE',
// A400: '#00E676',
// A700: '#00C853'
},
"lightGreen": {
50: '#F1F8E9',
100: '#DCEDC8',
200: '#C5E1A5',
300: '#AED581',
400: '#9CCC65',
500: '#8BC34A',
600: '#7CB342',
700: '#689F38',
800: '#558B2F',
900: '#33691E',
// A100: '#CCFF90',
// A200: '#B2FF59',
// A400: '#76FF03',
// A700: '#64DD17'
},
"lime": {
50: '#F9FBE7',
100: '#F0F4C3',
200: '#E6EE9C',
300: '#DCE775',
400: '#D4E157',
500: '#CDDC39',
600: '#C0CA33',
700: '#AFB42B',
800: '#9E9D24',
900: '#827717',
// A100: '#F4FF81',
// A200: '#EEFF41',
// A400: '#C6FF00',
// A700: '#AEEA00'
},
"yellow": {
50: '#FFFDE7',
100: '#FFF9C4',
200: '#FFF59D',
300: '#FFF176',
400: '#FFEE58',
500: '#FFEB3B',
600: '#FDD835',
700: '#FBC02D',
800: '#F9A825',
900: '#F57F17',
// A100: '#FFFF8D',
// A200: '#FFFF00',
// A400: '#FFEA00',
// A700: '#FFD600'
},
"amber": {
50: '#FFF8E1',
100: '#FFECB3',
200: '#FFE082',
300: '#FFD54F',
400: '#FFCA28',
500: '#FFC107',
600: '#FFB300',
700: '#FFA000',
800: '#FF8F00',
900: '#FF6F00',
// A100: '#FFE57F',
// A200: '#FFD740',
// A400: '#FFC400',
// A700: '#FFAB00'
},
"orange": {
50: '#FFF3E0',
100: '#FFE0B2',
200: '#FFCC80',
300: '#FFB74D',
400: '#FFA726',
500: '#FF9800',
600: '#FB8C00',
700: '#F57C00',
800: '#EF6C00',
900: '#E65100',
// A100: '#FFD180',
// A200: '#FFAB40',
// A400: '#FF9100',
// A700: '#FF6D00'
},
"deepOrange": {
50: '#FBE9E7',
100: '#FFCCBC',
200: '#FFAB91',
300: '#FF8A65',
400: '#FF7043',
500: '#FF5722',
600: '#F4511E',
700: '#E64A19',
800: '#D84315',
900: '#BF360C',
// A100: '#FF9E80',
// A200: '#FF6E40',
// A400: '#FF3D00',
// A700: '#DD2C00'
},
"brown": {
50: '#EFEBE9',
100: '#D7CCC8',
200: '#BCAAA4',
300: '#A1887F',
400: '#8D6E63',
500: '#795548',
600: '#6D4C41',
700: '#5D4037',
800: '#4E342E',
900: '#3E2723'
}
};
function makeLabels() {
let result: string[] = [];
for (let [key] of Object.entries(materialColors)) {
result.push(key.toString());
}
return result;
}
export const colorKeys: string[] = makeLabels();
const darkMode: boolean = isDarkMode();
export function getBackgroundColor(colorKey: string): string {
let list = materialColors[colorKey];
if (!list) {
list = materialColors["grey"];
}
if (darkMode) {
if (colorKey === "grey" || colorKey === "yellow")
{
return list[900];
}
else {
return list[800];
}
} else {
if (colorKey === "grey")
{
return list[200];
}
else {
return list[100];
}
}
}
export function getBorderColor(colorKey: string): string {
let list = materialColors[colorKey];
if (!list)
{
list = materialColors["grey"];
}
if (darkMode) {
if (colorKey === "grey" || colorKey === "yellow")
{
return list[300];
}
return list[200];
} else {
if (colorKey === "grey")
{
return list[500];
}
return list[400];
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export default class MidiBinding {
deserialize(input: any) : MidiBinding {
this.symbol = input.symbol;
this.bindingType = input.bindingType;
this.note = input.note;
this.control = input.control;
this.minValue = input.minValue;
this.maxValue = input.maxValue;
this.linearControlType = input.linearControlType;
this.switchControlType = input.switchControlType;
return this;
}
static systemBinding(symbol: string): MidiBinding {
let result = new MidiBinding();
result.symbol = symbol;
return result;
}
static deserialize_array(input: any): MidiBinding[] {
let result: MidiBinding[] = [];
for (let i = 0; i < input.length; ++i)
{
result.push(new MidiBinding().deserialize(input[i]));
}
return result;
}
clone(): MidiBinding { return new MidiBinding().deserialize(this);}
equals(other: MidiBinding) : boolean
{
return (this.symbol === other.symbol)
&& (this.bindingType === other.bindingType)
&& (this.note === other.note)
&& (this.control === other.control)
&& (this.minValue === other.minValue)
&& (this.maxValue === other.maxValue)
&& (this.linearControlType === other.linearControlType)
&& (this.switchControlType === other.switchControlType)
}
static BINDING_TYPE_NONE: number = 0;
static BINDING_TYPE_NOTE: number = 1;
static BINDING_TYPE_CONTROL: number = 2;
setBindingType(bindingType:number)
{
this.bindingType = bindingType;
}
symbol: string = "";
bindingType: number = MidiBinding.BINDING_TYPE_NONE;
note: number = 12*4+24; // C4.
control: number = 1;
minValue: number = 0;
maxValue: number = 1;
rotaryScale: number = 1;
static LINEAR_CONTROL_TYPE:number = 0;
static CIRCULAR_CONTROL_TYPE: number = 1;
linearControlType: number = MidiBinding.LINEAR_CONTROL_TYPE;
static TRIGGER_ON_RISING_EDGE: number = 0;
static TOGGLE_ON_RISING_EDGE: number = 0;
static MOMENTARY_CONTROL_TYPE: number = 1;
static TOGGLE_ON_VALUE: number = 1;
static TOGGLE_ON_ANY: number = 2;
static TRIGGER_ON_ANY: number = 2;
switchControlType: number = MidiBinding.TRIGGER_ON_RISING_EDGE;
};
+407
View File
@@ -0,0 +1,407 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { Component } from 'react';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import {createStyles} from './WithStyles';
import { UiPlugin } from './Lv2Plugin';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import MidiBinding from './MidiBinding';
import Utility from './Utility';
import Typography from '@mui/material/Typography';
import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined';
import MicOutlinedIcon from '@mui/icons-material/MicOutlined';
import IconButton from '@mui/material/IconButton';
import NumericInput from './NumericInput';
const styles = (theme: Theme) => createStyles({
controlDiv: { flex: "0 0 auto", marginRight: 12, verticalAlign: "center", height: 48, paddingTop: 8, paddingBottom: 8 },
controlDiv2: {
flex: "0 0 auto", marginRight: 12, verticalAlign: "center",
height: 48, paddingTop: 0, paddingBottom: 0, whiteSpace: "nowrap"
}
});
enum MidiControlType {
None,
Toggle,
Trigger,
Dial,
Select,
}
interface MidiBindingViewProps extends WithStyles<typeof styles> {
instanceId: number;
listen: boolean;
midiBinding: MidiBinding;
uiPlugin: UiPlugin;
onChange: (instanceId: number, newBinding: MidiBinding) => void;
onListen: (instanceId: number, key: string, listenForControl: boolean) => void;
}
interface MidiBindingViewState {
midiControlType: MidiControlType;
}
const MidiBindingView =
withStyles(
class extends Component<MidiBindingViewProps, MidiBindingViewState> {
model: PiPedalModel;
constructor(props: MidiBindingViewProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
midiControlType: this.getControlType()
};
}
handleBindingTypeChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.setBindingType(newValue);
this.validateSwitchControlType(newBinding);
this.props.onChange(this.props.instanceId, newBinding);
}
handleNoteChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.note = newValue;
this.validateSwitchControlType(newBinding);
this.props.onChange(this.props.instanceId, newBinding);
}
handleControlChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.control = newValue;
this.validateSwitchControlType(newBinding);
this.props.onChange(this.props.instanceId, newBinding);
}
handleLatchControlTypeChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.switchControlType = newValue;
this.validateSwitchControlType(newBinding);
this.props.onChange(this.props.instanceId, newBinding);
}
handleTriggerControlTypeChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.switchControlType = newValue;
this.validateSwitchControlType(newBinding);
this.props.onChange(this.props.instanceId, newBinding);
}
handleLinearControlTypeChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.linearControlType = newValue;
this.validateSwitchControlType(newBinding);
this.props.onChange(this.props.instanceId, newBinding);
}
handleMinChange(value: number): void {
let newBinding = this.props.midiBinding.clone();
newBinding.minValue = value;
this.props.onChange(this.props.instanceId, newBinding);
}
handleMaxChange(value: number): void {
let newBinding = this.props.midiBinding.clone();
newBinding.maxValue = value;
this.props.onChange(this.props.instanceId, newBinding);
}
handleScaleChange(value: number): void {
let newBinding = this.props.midiBinding.clone();
newBinding.rotaryScale = value;
this.props.onChange(this.props.instanceId, newBinding);
}
generateMidiNoteSelects(): React.ReactNode[] {
let result: React.ReactNode[] = [];
for (let i = 0; i < 127; ++i) {
result.push(
<MenuItem value={i}>{Utility.midiNoteName(i)}</MenuItem>
)
}
return result;
}
validateSwitchControlType(midiBinding: MidiBinding) {
// :-(
let controlType = this.state.midiControlType;
if (controlType === MidiControlType.Toggle
&& midiBinding.bindingType === MidiBinding.BINDING_TYPE_NOTE) {
if (midiBinding.switchControlType !== MidiBinding.TRIGGER_ON_RISING_EDGE // Toggle on Note On.
&& midiBinding.switchControlType !== MidiBinding.MOMENTARY_CONTROL_TYPE // Note on/Note off.
) {
midiBinding.switchControlType = MidiBinding.TRIGGER_ON_RISING_EDGE;
}
}
}
generateControlSelects(): React.ReactNode[] {
return Utility.validMidiControllers.map((control) => (
<MenuItem key={control.value} value={control.value}>{control.displayName}</MenuItem>
)
);
}
getControlType(): MidiControlType {
if (this.props.midiBinding.symbol === "__bypass") {
return MidiControlType.Toggle;
}
if (!this.props.uiPlugin) return MidiControlType.None;
let port = this.props.uiPlugin.getControl(this.props.midiBinding.symbol);
if (!port) return MidiControlType.None;
if (port.trigger_property) {
return MidiControlType.Trigger;
}
if (port.isAbToggle() || port.isOnOffSwitch()) {
return MidiControlType.Toggle;
}
if (port.isSelect()) {
return MidiControlType.Select;
}
return MidiControlType.Dial;
}
render() {
const classes = withStyles.getClasses(this.props);
let midiBinding = this.props.midiBinding;
let uiPlugin = this.props.uiPlugin;
if (!uiPlugin) {
return (<div />);
}
let controlType = this.state.midiControlType;
let showLinearRange =
controlType === MidiControlType.Dial &&
midiBinding.bindingType === MidiBinding.BINDING_TYPE_CONTROL
&& midiBinding.linearControlType === MidiBinding.LINEAR_CONTROL_TYPE;
let canRotaryScale: boolean =
controlType === MidiControlType.Dial &&
midiBinding.bindingType === MidiBinding.BINDING_TYPE_CONTROL
&& midiBinding.linearControlType === MidiBinding.CIRCULAR_CONTROL_TYPE;
return (
<div style={{ display: "flex", flexDirection: "row", flexWrap: "wrap", justifyContent: "left", alignItems: "center", minHeight: 48 }}>
<div className={classes.controlDiv} >
<Select variant="standard"
style={{ width: 80, }}
onChange={(e, extra) => this.handleBindingTypeChange(e, extra)}
value={midiBinding.bindingType}
>
<MenuItem value={0}>None</MenuItem>
{(controlType === MidiControlType.Toggle || controlType === MidiControlType.Trigger) && (
<MenuItem value={1}>Note</MenuItem>
)}
<MenuItem value={2}>Control</MenuItem>
</Select>
</div>
{
(midiBinding.bindingType === MidiBinding.BINDING_TYPE_NOTE) &&
(
<div className={classes.controlDiv2} >
<Select variant="standard"
style={{ width: 80 }}
onChange={(e, extra) => this.handleNoteChange(e, extra)}
value={midiBinding.note}
>
{
this.generateMidiNoteSelects()
}
</Select>
<IconButton
onClick={() => {
if (this.props.listen) {
this.props.onListen(-2, "", false)
} else {
this.props.onListen(this.props.instanceId, this.props.midiBinding.symbol, false)
}
}}
size="large">
{this.props.listen ? (
<MicOutlinedIcon />
) : (
<MicNoneOutlinedIcon />
)}
</IconButton>
</div>
)
}
{
(midiBinding.bindingType === MidiBinding.BINDING_TYPE_CONTROL) &&
(
<div className={classes.controlDiv2} >
<Select variant="standard"
style={{ width: 80 }}
onChange={(e, extra) => this.handleControlChange(e, extra)}
value={midiBinding.control}
renderValue={(value) => { return "CC-" + value }}
>
{
this.generateControlSelects()
}
</Select>
<IconButton
onClick={() => {
if (this.props.listen) {
this.props.onListen(-2, "", false)
} else {
this.props.onListen(this.props.instanceId, this.props.midiBinding.symbol, true)
}
}}
size="large">
{this.props.listen ? (
<MicOutlinedIcon />
) : (
<MicNoneOutlinedIcon />
)}
</IconButton>
</div>
)
}
{
((controlType === MidiControlType.Toggle && midiBinding.bindingType === MidiBinding.BINDING_TYPE_NOTE) &&
(
<div className={classes.controlDiv} >
<Select variant="standard"
style={{}}
onChange={(e, extra) => this.handleLatchControlTypeChange(e, extra)}
value={midiBinding.switchControlType}
>
<MenuItem value={MidiBinding.TRIGGER_ON_RISING_EDGE}>Toggle on Note On</MenuItem>
<MenuItem value={MidiBinding.MOMENTARY_CONTROL_TYPE}>Note On/Note off</MenuItem>
</Select>
</div>
))
}
{
((controlType === MidiControlType.Toggle && midiBinding.bindingType === MidiBinding.BINDING_TYPE_CONTROL) &&
(
<div className={classes.controlDiv} >
<Select variant="standard"
style={{}}
onChange={(e, extra) => this.handleLatchControlTypeChange(e, extra)}
value={midiBinding.switchControlType}
>
<MenuItem value={MidiBinding.TOGGLE_ON_RISING_EDGE}>Toggle on rising edge</MenuItem>
<MenuItem value={MidiBinding.TOGGLE_ON_ANY}>Toggle on any value</MenuItem>
<MenuItem value={MidiBinding.TOGGLE_ON_VALUE}>Use control value</MenuItem>
</Select>
</div>
))
}
{
(controlType === MidiControlType.Trigger && midiBinding.bindingType === MidiBinding.BINDING_TYPE_CONTROL) &&
(
<div className={classes.controlDiv} >
<Select variant="standard"
style={{}}
onChange={(e, extra) => this.handleTriggerControlTypeChange(e, extra)}
value={midiBinding.switchControlType}
>
<MenuItem value={MidiBinding.TRIGGER_ON_RISING_EDGE}>Trigger on rising edge</MenuItem>
<MenuItem value={MidiBinding.TRIGGER_ON_ANY}>
Trigger on any value
</MenuItem>
</Select>
</div>
)
}
{
(controlType === MidiControlType.Dial && midiBinding.bindingType === MidiBinding.BINDING_TYPE_CONTROL) &&
(
<div className={classes.controlDiv} >
<Select variant="standard"
style={{ width: 120 }}
onChange={(e, extra) => this.handleLinearControlTypeChange(e, extra)}
value={midiBinding.linearControlType}
>
<MenuItem value={MidiBinding.LINEAR_CONTROL_TYPE}>Linear</MenuItem>
<MenuItem value={MidiBinding.CIRCULAR_CONTROL_TYPE}>Rotary</MenuItem>
</Select>
</div>
)
}
{
showLinearRange && (
<div className={classes.controlDiv}>
<Typography display="inline">Min:&nbsp;</Typography>
<NumericInput defaultValue={midiBinding.minValue} ariaLabel='min'
min={0} max={1} onChange={(value) => { this.handleMinChange(value); }}
/>
</div>
)
}
{
showLinearRange && (
<div className={classes.controlDiv}>
<Typography display="inline">Max:&nbsp;</Typography>
<NumericInput defaultValue={midiBinding.maxValue} ariaLabel='max'
min={0} max={1} onChange={(value) => { this.handleMaxChange(value); }} />
</div>
)
}
{
canRotaryScale && (
<div className={classes.controlDiv}>
<Typography display="inline">Scale:&nbsp;</Typography>
<NumericInput defaultValue={midiBinding.maxValue} ariaLabel='scale'
min={-100} max={100} onChange={(value) => { this.handleScaleChange(value); }} />
</div>
)
}
</div>
);
}
},
styles);
export default MidiBindingView;
+384
View File
@@ -0,0 +1,384 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { SyntheticEvent } from 'react';
import DialogEx from './DialogEx';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { PiPedalModel, PiPedalModelFactory, ListenHandle } from './PiPedalModel';
import Typography from '@mui/material/Typography';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import IconButton from '@mui/material/IconButton';
import MidiBinding from './MidiBinding';
import MidiBindingView from './MidiBindingView';
import Snackbar from '@mui/material/Snackbar';
import { UiPlugin,makeSplitUiPlugin } from './Lv2Plugin';
import { css } from '@emotion/react';
const styles = (theme: Theme) => createStyles({
dialogAppBar: css({
position: 'relative',
top: 0, left: 0
}),
dialogTitle: css({
marginLeft: theme.spacing(2),
flex: "1 1 auto",
}),
pluginTable: css({
border: "collapse",
width: "100%",
}),
pluginHead: css({
borderTop: "1pt #DDD solid", paddingLeft: 22, paddingRight: 22
}),
bindingTd: css({
verticalAlign: "top",
paddingLeft: 12, paddingBottom: 8
}),
nameTd: css({
paddingLeft: 48,
verticalAlign: "top",
paddingTop: 12
}),
});
function not_null<T>(value: T | null) {
if (!value) throw Error("Unexpected null value");
return value;
}
export interface MidiBindingDialogProps extends WithStyles<typeof styles> {
open: boolean,
onClose: () => void
}
export interface MidiBindingDialogState {
listenInstanceId: number;
listenSymbol: string;
listenSnackbarOpen: boolean;
}
export const MidiBindingDialog =
withStyles(
class extends ResizeResponsiveComponent<MidiBindingDialogProps, MidiBindingDialogState> {
model: PiPedalModel;
constructor(props: MidiBindingDialogProps) {
super(props);
this.state = {
listenInstanceId: -2,
listenSymbol: "",
listenSnackbarOpen: false
};
this.model = PiPedalModelFactory.getInstance();
this.handleClose = this.handleClose.bind(this);
}
mounted: boolean = false;
hasHooks: boolean = false;
handleClose() {
this.cancelListenForControl();
this.props.onClose();
}
listenTimeoutHandle?: number;
listenHandle?: ListenHandle;
cancelListenForControl() {
if (this.listenTimeoutHandle) {
clearTimeout(this.listenTimeoutHandle);
this.listenTimeoutHandle = undefined;
}
if (this.listenHandle)
{
this.model.cancelListenForMidiEvent(this.listenHandle)
this.listenHandle = undefined;
}
this.setState({ listenInstanceId: -2, listenSymbol: "" });
}
handleListenSucceeded(instanceId: number, symbol: string, isNote: boolean, noteOrControl: number)
{
this.cancelListenForControl();
let pedalboard = this.model.pedalboard.get();
let item = pedalboard.getItem(instanceId);
if (!item) return;
let binding = item.getMidiBinding(symbol);
let newBinding = binding.clone();
if (isNote) {
newBinding.bindingType = MidiBinding.BINDING_TYPE_NOTE;
newBinding.note = noteOrControl;
} else {
newBinding.bindingType = MidiBinding.BINDING_TYPE_CONTROL;
newBinding.control = noteOrControl;
}
this.model.setMidiBinding(instanceId,newBinding);
}
handleListenForControl(instanceId: number, symbol: string, listenForControl: boolean): void {
this.cancelListenForControl();
this.setState({ listenInstanceId: instanceId, listenSymbol: symbol, listenSnackbarOpen: true });
this.listenTimeoutHandle = setTimeout(() => {
this.cancelListenForControl();
}, 8000);
this.listenHandle = this.model.listenForMidiEvent(listenForControl,
(isNote: boolean, noteOrControl: number) => {
this.handleListenSucceeded(instanceId,symbol,isNote, noteOrControl);
});
}
onWindowSizeChanged(width: number, height: number): void {
}
componentDidMount() {
super.componentDidMount();
this.mounted = true;
}
componentWillUnmount() {
super.componentWillUnmount();
this.mounted = false;
}
componentDidUpdate() {
}
handleItemChanged(instanceId: number, newBinding: MidiBinding) {
this.model.setMidiBinding(instanceId, newBinding);
}
generateTable(): React.ReactNode[] {
const classes = withStyles.getClasses(this.props);
let result: React.ReactNode[] = [];
let pedalboard = this.model.pedalboard.get();
let iter = pedalboard.itemsGenerator();
while (true) {
let v = iter.next();
if (v.done) break;
let item = v.value;
let isSplit = item.uri === "uri://two-play/pipedal/pedalboard#Split";
let plugin : UiPlugin | null = this.model.getUiPlugin(item.uri);
if (plugin === null && isSplit)
{
plugin = makeSplitUiPlugin();
let splitType = item.getControlValue("splitType");
not_null(plugin.getControl("splitType")).not_on_gui = true;
switch (splitType)
{
case 0: // A/B
not_null(plugin.getControl("select")).not_on_gui = false;
not_null(plugin.getControl("mix")).not_on_gui = true;
not_null(plugin.getControl("volL")).not_on_gui = true;
not_null(plugin.getControl("panL")).not_on_gui = true;
not_null(plugin.getControl("volR")).not_on_gui = true;
not_null(plugin.getControl("panR")).not_on_gui = true;
break;
case 1: //mixer
not_null(plugin.getControl("select")).not_on_gui = true;
not_null(plugin.getControl("mix")).not_on_gui = false;
not_null(plugin.getControl("volL")).not_on_gui = true;
not_null(plugin.getControl("panL")).not_on_gui = true;
not_null(plugin.getControl("volR")).not_on_gui = true;
not_null(plugin.getControl("panR")).not_on_gui = true;
break;
case 2: // L/R
not_null(plugin.getControl("select")).not_on_gui = true;
not_null(plugin.getControl("mix")).not_on_gui = true;
not_null(plugin.getControl("volL")).not_on_gui = false;
not_null(plugin.getControl("panL")).not_on_gui = false;
not_null(plugin.getControl("volR")).not_on_gui = false;
not_null(plugin.getControl("panR")).not_on_gui = false;
break;
}
// xxx
}
if (plugin) {
result.push(
<tr>
<td colSpan={2} className={classes.pluginHead}>
<Typography variant="caption" color="textSecondary" noWrap>
{plugin.name}
</Typography>
</td>
</tr>
);
if (!isSplit)
{
result.push(
<tr>
<td className={classes.nameTd}>
<Typography noWrap style={{ verticalAlign: "center", height: 48 }}>
Bypass
</Typography>
</td>
<td className={classes.bindingTd}>
<MidiBindingView instanceId={item.instanceId} midiBinding={item.getMidiBinding("__bypass")}
uiPlugin={plugin}
onListen={(instanceId: number, symbol: string, listenForControl: boolean) => {
if (instanceId === -2)
{
this.cancelListenForControl();
} else {
this.handleListenForControl(instanceId, symbol, listenForControl);
}
}}
listen={item.instanceId === this.state.listenInstanceId && this.state.listenSymbol === "__bypass"}
onChange={(instanceId: number, newItem: MidiBinding) => this.handleItemChanged(instanceId, newItem)}
/>
</td>
</tr>
);
}
for (let i = 0; i < plugin.controls.length; ++i) {
let control = plugin.controls[i];
if (control.isHidden())
{
continue;
}
if (!control.is_input)
{
continue;
}
let symbol = control.symbol;
result.push(
<tr>
<td className={classes.nameTd}>
<Typography noWrap style={{ verticalAlign: "middle", maxWidth: 120 }} >
{control.name}
</Typography>
</td>
<td className={classes.bindingTd}>
<MidiBindingView instanceId={item.instanceId}
uiPlugin={plugin}
midiBinding={item.getMidiBinding(symbol)}
listen={item.instanceId === this.state.listenInstanceId && this.state.listenSymbol === symbol}
onListen={(instanceId: number, symbol: string, listenForControl: boolean) => {
this.handleListenForControl(instanceId, symbol, listenForControl);
}}
onChange={(instanceId: number, newItem: MidiBinding) => this.handleItemChanged(instanceId, newItem)}
/>
</td>
</tr>
);
}
}
}
return result;
}
supressDefault(e: SyntheticEvent) {
//e.preventDefault();
//e.stopPropagation();
}
render() {
let props = this.props;
let { open} = props;
const classes = withStyles.getClasses(props);
if (!open) {
return (<div />);
}
return (
<DialogEx tag="midiBindings" open={open} fullWidth onClose={this.handleClose} aria-labelledby="Rename-dialog-title"
fullScreen={true}
style={{userSelect: "none"}}
onEnterKey={()=>{}}
>
<div style={{ display: "flex", flexDirection: "column", flexWrap: "nowrap", width: "100%", height: "100%", overflow: "hidden" }}>
<div style={{ flex: "0 0 auto" }}>
<AppBar className={classes.dialogAppBar} >
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={this.handleClose}
aria-label="back"
size="large">
<ArrowBackIcon />
</IconButton>
<Typography noWrap variant="h6" className={classes.dialogTitle}>
Preset MIDI Bindings
</Typography>
</Toolbar>
</AppBar>
</div>
<div style={{ overflow: "auto", flex: "1 1 auto", width: "100%" }}>
<table className={classes.pluginTable} >
<colgroup>
<col style={{ width: "auto" }} />
<col style={{ width: "100%" }} />
</colgroup>
<tbody>
{this.generateTable()}
</tbody>
</table>
</div>
</div>
<Snackbar
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
open={this.state.listenSnackbarOpen}
autoHideDuration={1500}
onClose={() => this.setState({ listenSnackbarOpen: false })}
message="Listening for MIDI input"
/>
</DialogEx >
);
}
},
styles);
export default MidiBindingDialog;
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2024 Robin E. R. Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export enum MidiDeviceSelection {
DeviceAny = 0,
DeviceNone = 1,
DeviceList = 2
}
export default class MidiChannelBinding {
deserialize(input: any) : MidiChannelBinding {
this.deviceSelection = input.deviceSelection;
this.midiDevices = input.midiDevices;
this.channel = input.channel;
this.acceptProgramChanges = input.acceptProgramChanges;
this.acceptCommonMessages = input.acceptCommonMessages;
return this;
}
clone() { return new MidiChannelBinding().deserialize(this);}
deviceSelection: number = MidiDeviceSelection.DeviceAny as number;
midiDevices: string[] = [];
channel: number = -1;
acceptProgramChanges: boolean = true;
acceptCommonMessages: boolean = true;
static CreateMissingValue() : MidiChannelBinding {
let result = new MidiChannelBinding();
return result;
}
}
@@ -0,0 +1,141 @@
// Copyright (c) 2024 Robin E. R. Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { Component } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles, {withTheme} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { pluginControlStyles } from './PluginControl';
import MidiChannelBinding from './MidiChannelBinding';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
import ButtonBase from '@mui/material/ButtonBase';
import MidiIcon from './svg/ic_midi.svg?react';
import MidiChannelBindingDialog from './MidiChannelBindingDialog';
export const StandardItemSize = { width: 80, height: 140 }
export interface MidiChannelBindingControlProps extends WithStyles<typeof pluginControlStyles> {
midiChannelBinding: MidiChannelBinding
theme: Theme;
onChange: (result: MidiChannelBinding) => void;
}
type MidiChannelBindingControlState = {
dialogOpen: boolean;
};
const MidiChannelBindingControl =
withTheme(
withStyles(
class extends Component<MidiChannelBindingControlProps, MidiChannelBindingControlState> {
model: PiPedalModel;
constructor(props: MidiChannelBindingControlProps) {
super(props);
this.state = {
dialogOpen: false
};
this.model = PiPedalModelFactory.getInstance();
}
render() {
const classes = withStyles.getClasses(this.props);
let item_width = 80;
return (
<div
className={classes.controlFrame}
style={{ width: item_width }}
>
{/* TITLE SECTION */}
<div className={classes.titleSection}
style={
{
alignSelf: "stretch"
}}>
<Tooltip title={(
<React.Fragment>
<Typography variant="caption">title</Typography>
<Divider />
<Typography variant="caption">Controls which MIDI messages get forward to the instrument.</Typography>
</React.Fragment>
)}
placement="top-start" arrow enterDelay={1500} enterNextDelay={1500}
>
<Typography variant="caption" display="block" noWrap style={{
width: "100%",
textAlign: "center"
}}> MIDI</Typography>
</Tooltip>
</div>
{/* CONTROL SECTION */}
<div className={classes.midSection}>
<ButtonBase style={{ width: 48, height: 48, borderRadius: 9999 }}
onClick={()=> {
this.setState({dialogOpen: true});
}}
sx={{
':hover': {
bgcolor: 'action.hover', // theme.palette.primary.main
color: 'white',
},
}}
>
<MidiIcon fill={this.props.theme.palette.text.secondary} style={{ width: 36, height: 36 }} />
</ButtonBase>
</div>
<div className={classes.editSection}
style={{ display: "flex", flexFlow: "column nowrap", justifyContent: "center" }}
>
<Typography variant="caption" display="block" noWrap
style={{ width: "100%", textAlign: "center" }}
>OMNI</Typography>
</div>
{this.state.dialogOpen&&(
<MidiChannelBindingDialog
open={this.state.dialogOpen}
midiChannelBinding={this.props.midiChannelBinding}
onClose={() =>{ this.setState({dialogOpen: false});}}
onChanged={(midiChannelBinding: MidiChannelBinding)=> {}}
midiDevices={[]}
/>
)}
</div >
);
}
},
pluginControlStyles
));
export default MidiChannelBindingControl;
@@ -0,0 +1,201 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// Copyright (c) 2024 Robin E. R. Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { useState } from 'react';
import Button from '@mui/material/Button';
import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import FormControlLabel from '@mui/material/FormControlLabel';
import ChannelBindingHelpDialog from './ChannelBindingsHelpDialog';
import IconButton from '@mui/material/IconButton';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import Checkbox from '@mui/material/Checkbox';
import { AlsaMidiDeviceInfo } from './AlsaMidiDeviceInfo';
import DialogEx from './DialogEx';
import MidiChannelBinding, { MidiDeviceSelection } from './MidiChannelBinding';
import DialogContent from '@mui/material/DialogContent';
import Typography from '@mui/material/Typography';
import Select, { SelectChangeEvent } from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
export interface MidiChannelBindingDialogProps {
open: boolean;
midiChannelBinding: MidiChannelBinding;
onClose: () => void;
onChanged: (midiChannelBinding: MidiChannelBinding) => void;
midiDevices: AlsaMidiDeviceInfo[];
}
function MidiChannelBindingDialog(props: MidiChannelBindingDialogProps) {
const { onClose, midiChannelBinding, open } = props;
const [currentChannel, setCurrentChannel] = useState(midiChannelBinding.channel);
const [allowProgramChanges, setAllowProgramChanges] = useState(midiChannelBinding.acceptCommonMessages);
const [helpDialog, setHelpDialog] = useState(false);
const model: PiPedalModel = PiPedalModelFactory.getInstance();
const handleClose = (): void => {
onClose();
};
const handleOk = (): void => {
onClose();
};
const generateDeviceSelect = () => {
let result: React.ReactElement[] = [
(<MenuItem key={0} value={0}>Any</MenuItem>),
(<MenuItem key={1} value={1}>None</MenuItem>)
];
let i = 2;
for (let midiDevice of model.jackConfiguration.get().inputMidiDevices) {
result.push((<MenuItem key={i} value={i}>{midiDevice.description}</MenuItem>));
++i;
}
return result;
};
const getDefaultDeviceSelect = (): number => {
if (midiChannelBinding.deviceSelection === MidiDeviceSelection.DeviceAny as number) {
return 0;
}
if (midiChannelBinding.deviceSelection === MidiDeviceSelection.DeviceNone as number) {
return 1;
}
if (midiChannelBinding.midiDevices.length === 0) {
return 0;
}
let selectedDevice = midiChannelBinding.midiDevices[0];
let ix = 2;
for (let midiDevice of model.jackConfiguration.get().inputMidiDevices) {
if (midiDevice.name === selectedDevice) {
return ix;
}
++ix;
}
return 0; // any.
}
const [currentDeviceSelect, setCurrentDeviceSelect] = useState(getDefaultDeviceSelect())
const handleDeviceSelecChange = (event: SelectChangeEvent) => {
setCurrentDeviceSelect(parseInt(event.target.value));
};
const handleChannelChange = (event: SelectChangeEvent) => {
setCurrentChannel(parseInt(event.target.value));
};
return (
<DialogEx tag="midiChannelBinding" onClose={handleClose} aria-labelledby="select-channels-title" open={open}
onEnterKey={()=>{}} fullWidth maxWidth="xs"
>
<DialogTitle id="simple-dialog-title">MIDI Channel Filters</DialogTitle>
<DialogContent>
<div style={{
display: "grid", alignItems: "center", columnGap: 24, rowGap: 24,
gridTemplateColumns: "1fr"
}}>
<div >
<Typography display="block" variant="caption">MIDI Devices</Typography>
<Select variant='standard' value={currentDeviceSelect.toString()} fullWidth
onChange={handleDeviceSelecChange} >
{
generateDeviceSelect()
}
</Select>
</div>
<div>
<Typography display="block" variant="caption">Channels</Typography>
<Select variant='standard' label="Channels" value={currentChannel.toString()} fullWidth
onChange={handleChannelChange}>
<MenuItem value={-1}>Omni</MenuItem>
<MenuItem value={0}>Channel 1</MenuItem>
<MenuItem value={1}>Channel 2</MenuItem>
<MenuItem value={2}>Channel 3</MenuItem>
<MenuItem value={3}>Channel 4</MenuItem>
<MenuItem value={4}>Channel 5</MenuItem>
<MenuItem value={5}>Channel 6</MenuItem>
<MenuItem value={6}>Channel 7</MenuItem>
<MenuItem value={7}>Channel 8</MenuItem>
<MenuItem value={8}>Channel 9</MenuItem>
<MenuItem value={9}>Channel 10</MenuItem>
<MenuItem value={10}>Channel 11</MenuItem>
<MenuItem value={11}>Channel 12</MenuItem>
<MenuItem value={12}>Channel 13</MenuItem>
<MenuItem value={13}>Channel 14</MenuItem>
<MenuItem value={14}>Channel 15</MenuItem>
<MenuItem value={15}>Channel 16</MenuItem>
</Select>
</div>
<div style={{ display: "flex", flexFlow: "row nowrap", justifyContent: "center" }}>
<FormControlLabel control={
<Checkbox checked={allowProgramChanges}
onChange={(event) => {
setAllowProgramChanges(event.target.checked);
}}
/>
} label="Allow Program Changes"
/>
{false&&( // wait until the implementation stabilizes before exposing the help dialog.
<IconButton
onClick={() => { setHelpDialog(true); }}
size="large">
<InfoOutlinedIcon color='inherit'
style={{
fill: "var(--mui-palette.text.primary)",
opacity: 0.6
}}
/>
</IconButton>
)}
</div>
</div>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} variant="dialogSecondary" >
Cancel
</Button>
<Button onClick={handleOk} variant="dialogPrimary" >
OK
</Button>
</DialogActions>
<ChannelBindingHelpDialog open={helpDialog} onClose={()=>setHelpDialog(false)}
/>
</DialogEx>
);
}
export default MidiChannelBindingDialog;
+60
View File
@@ -0,0 +1,60 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export interface ModDirectory {
modType: string;
pipedalPath: string;
displayName: string;
defaultFileExtensions: string[];
};
export const modDirectories: ModDirectory[] = [
{modType: "audioloop", pipedalPath: "shared/audio/Loops",displayName: "Loops", defaultFileExtensions: ["audio/*"]}, // Audio Loops, meant to be used for looper-style plugins
//"audiorecording","shared/audio/Audio Recordings", defaultFileExtensions: ["audio/*"]}, : Audio Recordings, triggered by plugins and stored in the unit
{modType: "audiosample", pipedalPath: "shared/audio/Samples", displayName: "Samples", defaultFileExtensions: ["audio/*"]}, // One-shot Audio Samples, meant to be used for sampler-style plugins
{modType: "audiotrack", pipedalPath: "shared/audio/Tracks",displayName: "Tracks", defaultFileExtensions: ["audio/*"]}, // Audio Tracks, meant to be used as full-performance/song or backtrack
{modType: "cabsim", pipedalPath: "CabIR", displayName: "Cab IRs", defaultFileExtensions: ["audio/*"]}, // Speaker Cabinets, meant as small IR audio files
/// - h2drumkit: Hydrogen Drumkits, must use h2drumkit file extension
{modType: "ir", pipedalPath: "ReverbImpulseFiles", displayName: "Impulse Responses", defaultFileExtensions: ["audio/*"]}, // Impulse Responses
{modType: "midiclip", pipedalPath: "shared/midiClips",displayName: "MIDI Clips", defaultFileExtensions: [".mid", ".midi"]}, // MIDI Clips, to be used in sync with host tempo, must have mid or midi file extension
{modType: "midisong", pipedalPath: "shared/midiSongs", displayName: "MIDI Songs", defaultFileExtensions: [".mid", ".midi"]}, // MIDI Songs, meant to be used as full-performance/song or backtrack
{modType: "sf2", pipedalPath: "shared/sf2",displayName: "Sound Fonts", defaultFileExtensions: [".sf2", ".sf3"]}, // SF2 Instruments, must have sf2 or sf3 file extension
{modType: "sfz", pipedalPath: "shared/sfz",displayName: "Sfz Files", defaultFileExtensions: [".sfz"]}, // SFZ Instruments, must have sfz file extension
// extensions observed in the field.
{modType: "audio", pipedalPath: "shared/audio", displayName: "Audio", defaultFileExtensions: ["audio/*"]}, // all audio files (Ratatoille)
{modType: "nammodel", pipedalPath: "NeuralAmpModels", displayName: "Neural Amp Models", defaultFileExtensions: [".nam"]}, // Ratatoille, Mike's NAM.
{modType: "aidadspmodel", pipedalPath: "shared/aidaaix", displayName: "AIDA IAX Models", defaultFileExtensions: [".json", ".aidaiax"]}, // Ratatoille
{modType: "mlmodel", pipedalPath: "ToobMlModels", displayName: "ML Models", defaultFileExtensions: [".json"]}, //
];
export function getModDirectory(modFileType: string): ModDirectory | undefined {
for (let i = 0; i < modDirectories.length; ++i)
{
if (modDirectories[i].modType === modFileType)
{
return modDirectories[i];
}
}
return undefined;
}
+140
View File
@@ -0,0 +1,140 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import InputAdornment from '@mui/material/InputAdornment';
import IconButton from '@mui/material/IconButton';
import FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import Input from '@mui/material/Input';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
interface NoChangePasswordProps {
hasPassword: boolean;
label: string;
defaultValue: string;
onPasswordChange: (text: string) => void;
disabled?: boolean;
helperText?: string;
error?: boolean;
inputRef?: React.RefObject<any>
};
interface NoChangePasswordState {
focused: boolean;
passwordText: string;
showPassword: boolean;
};
class NoChangePassword extends React.Component<NoChangePasswordProps, NoChangePasswordState> {
refText: React.Ref<HTMLInputElement | HTMLTextAreaElement>;
constructor(props: NoChangePasswordProps) {
super(props);
this.refText = React.createRef();
this.state = {
focused: false,
showPassword: false,
passwordText: this.props.defaultValue as string
};
}
handleShowPassword() {
this.setState({
showPassword: !this.state.showPassword
});
}
handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
this.textChanged = true;
this.props.onPasswordChange(e.target.value);
}
textChanged: boolean = false;
handleFocus(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
console.log("onFocus");
if (!this.state.focused) {
this.textChanged = false;
e.target.value = this.state.passwordText;
}
e.currentTarget.removeAttribute("readonly");
this.setState({ focused: true });
}
handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
console.log("onBlur");
let text = e.target.value as string;
this.setState({ focused: false, passwordText: text });
this.props.onPasswordChange(e.target.value);
this.textChanged = false;
if (text.length === 0 && this.props.hasPassword)
{
e.target.value = "(Unchanged)";
}
e.currentTarget.setAttribute("readonly","true");
}
render() {
console.log("Render focus: " + this.state.focused)
let showUnchanged = (this.state.passwordText.length === 0) && this.props.hasPassword && (!this.state.focused);
let thisDefaultValue = showUnchanged ? "(Unchanged)" : this.props.defaultValue;
let isText = showUnchanged || this.state.showPassword;
return (
<FormControl disabled={this.props.disabled} fullWidth error={this.props.error} >
<InputLabel htmlFor="standard-adornment-password">{this.props.label}</InputLabel>
<Input
fullWidth
color="primary"
spellCheck="false"
autoComplete="off"
inputRef={this.props.inputRef}
readOnly
id="standard-adornment-password"
onChange={(e) => this.handleChange(e)}
value={thisDefaultValue}
onFocus={(e) => this.handleFocus(e)}
onBlur={(e) => this.handleBlur(e)}
type={isText ? 'text' : 'password'}
style={ { color: showUnchanged? '#888': '#000'} }
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={(e) => this.handleShowPassword()}
onMouseDown={(e) => e.preventDefault()}
size="large">
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
}
/>
<FormHelperText >{ this.props.helperText}</FormHelperText>
</FormControl>
);
}
};
export default NoChangePassword;
+155
View File
@@ -0,0 +1,155 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { Component } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import Input from '@mui/material/Input';
const styles = ({ palette }: Theme) => createStyles({
});
interface NumericInputProps extends WithStyles<typeof styles> {
ariaLabel: string;
defaultValue: number;
min: number;
max: number;
onChange: (value: number) => void;
}
interface NumericInputState {
error: boolean;
}
export const NumericInput =
withStyles(
class extends Component<NumericInputProps, NumericInputState>
{
constructor(props: NumericInputProps) {
super(props);
this.state = {
error: false
};
}
changed: boolean = false;
handleChange(event: any): void {
this.changed = true;
let strValue = event.target.value;
try {
let value = Number(strValue);
if (isNaN(value))
{
this.setState({ error: true });
} else if (value >= this.props.min && value <= this.props.max) {
this.setState({ error: false });
} else {
this.setState({ error: true });
}
} catch (error) {
this.setState({ error: true });
}
}
toDisplayValue(value: number): string {
if (value <= -1000 || value >= 1000)
{
return value.toFixed(0);
}
if (value <= -100 || value >= 100)
{
return value.toFixed(1);
}
if (value <= -10 || value >= 10)
{
return value.toFixed(2);
} else {
return value.toFixed(3);
}
}
apply(input: HTMLInputElement) {
if (this.changed) {
let strValue = input.value;
let value = Number(strValue);
if (!isNaN(value))
{
if (value < this.props.min) value = this.props.min;
if (value > this.props.max) value = this.props.max;
input.value = this.toDisplayValue(value);
this.props.onChange(value);
} else {
input.value = this.toDisplayValue(this.props.defaultValue);
}
this.changed = false;
this.setState({ error: false });
}
}
handleLostFocus(e: any) {
this.apply(e.currentTarget as HTMLInputElement);
}
handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter")
{
this.apply(e.currentTarget as HTMLInputElement);
} else if (e.key === "Escape")
{
e.currentTarget.value = this.toDisplayValue(this.props.defaultValue);
this.changed = false;
}
}
render() {
//const classes = withStyles.getClasses(this.props);
return (<Input style={{ width: 50 }}
inputProps={{
'aria-label': this.props.ariaLabel,
style: { textAlign: 'right' },
"onBlur": (e: any) => this.handleLostFocus(e),
"onKeyDown": (e: any) => this.handleKeyDown(e)
}}
onChange={(event) => this.handleChange(event)}
defaultValue={this.toDisplayValue(this.props.defaultValue)}
error={this.state.error}
/>
);
}
},
styles
);
export default NumericInput;
+54
View File
@@ -0,0 +1,54 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO `EVENT` SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export type ObservableEventHandler<VALUE_TYPE> = (newValue: VALUE_TYPE) => void;
export default class ObservableEvent<VALUE_TYPE> {
private _on_event_handlers: ObservableEventHandler<VALUE_TYPE>[] = [];
addEventHandler(handler: ObservableEventHandler<VALUE_TYPE> ) : void
{
this._on_event_handlers.push(handler);
}
removeEventHandler(handler: ObservableEventHandler<VALUE_TYPE> ) : void
{
let newArray: ObservableEventHandler<VALUE_TYPE>[] = [];
for (let myHandler of this._on_event_handlers)
{
if (myHandler !== handler)
{
newArray.push(myHandler);
}
}
this._on_event_handlers = newArray;
}
fire(value: VALUE_TYPE)
{
for (let handler of this._on_event_handlers)
{
handler(value);
}
}
};
+69
View File
@@ -0,0 +1,69 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO `EVENT` SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export type OnChangedHandler<VALUE_TYPE> = (newValue: VALUE_TYPE) => void;
export class ObservableProperty<VALUE_TYPE> {
_value_type: VALUE_TYPE;
_on_changed_handlers: OnChangedHandler<VALUE_TYPE>[] = [];
constructor(default_value: VALUE_TYPE)
{
this._value_type = default_value;
}
addOnChangedHandler(handler: OnChangedHandler<VALUE_TYPE> ) : void
{
this._on_changed_handlers.push(handler);
handler(this._value_type);
}
removeOnChangedHandler(handler: OnChangedHandler<VALUE_TYPE> ) : void
{
let oldArray = this._on_changed_handlers;
let newArray: OnChangedHandler<VALUE_TYPE>[] = [];
let outIx = 0;
for (let i = 0; i < oldArray.length; ++i)
{
if (oldArray[i] !== handler)
{
newArray[outIx++] = oldArray[i];
}
}
this._on_changed_handlers = newArray;
}
get() : VALUE_TYPE {
return this._value_type;
}
set(value: VALUE_TYPE) : void
{
this._value_type = value;
let t = this._on_changed_handlers; // take an copy in case removes happen while iteratiing.
t.forEach(c => c(value));
}
};
+84
View File
@@ -0,0 +1,84 @@
/*
* MIT License
*
* Copyright (c) 2022 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from 'react';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import DialogEx from './DialogEx';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
export interface OkCancelDialogProps {
open: boolean,
text: string,
okButtonText: string,
onOk: () => void,
onClose: () => void
};
export interface OkCancelDialogState {
};
export default class OkCancelDialog extends React.Component<OkCancelDialogProps, OkCancelDialogState> {
constructor(props: OkCancelDialogProps) {
super(props);
this.state = {
};
}
render() {
let props = this.props;
let { open,okButtonText,text, onClose, onOk } = props;
const handleClose = () => {
onClose();
};
const handleOk = () => {
onOk();
}
return (
<DialogEx tag="okCancel" open={open} onClose={handleClose}
onEnterKey={handleOk}
style={{userSelect: "none"}}
>
<DialogContent>
<Typography
>{text}</Typography>
</DialogContent>
<DialogActions>
<Button variant="dialogSecondary" onClick={handleClose} >
Cancel
</Button>
<Button variant="dialogPrimary" onClick={handleOk} >
{okButtonText}
</Button>
</DialogActions>
</DialogEx>
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { createSvgIcon } from '@mui/material/utils';
const OldDeleteIcon = createSvgIcon(
<path d="M20,4h-4.5l-1-1h-5l-1,1H4v2h1v13c0,1.1,0.9,2,2,2h10c1.1,0,2-0.9,2-2V6h1V4z M17,19H7V6h10V19z M9 8h2v8h-2z M13,8h2v8h-2z"/>,
"Delete",
);
export default OldDeleteIcon;
+102
View File
@@ -0,0 +1,102 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { useState } from 'react';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import DialogTitle from '@mui/material/DialogTitle';
import DialogEx from './DialogEx';
import DialogContent from '@mui/material/DialogContent';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
export interface OptionItem {
key: string | number;
text: string;
};
export interface OptionsDialogProps {
open: boolean;
title?: string;
options: OptionItem[];
value: string | number;
onOk: (result: OptionItem) => void;
minWidth?: number | string;
onClose: () => void;
}
function OptionsDialog(props: OptionsDialogProps) {
//const classes = useStyles();
const { options, value, onOk, onClose, open,title,minWidth } = props;
let initialSelection : OptionItem | undefined = undefined;
for (let option of options)
{
if (option.key === value)
{
initialSelection = option;
break;
}
}
const [currentSelection, setCurrentSelection] = useState(initialSelection)
const handleListItemClick = (item: OptionItem) => {
setCurrentSelection(item);
onOk(item);
};
const handleCancel = () => {
onClose();
};
return (
<DialogEx tag="options" onClose={handleCancel} aria-labelledby="select-option" open={open} onEnterKey={()=>{}} >
{title && (
<DialogTitle style={{paddingLeft: 8}}>
<Typography variant="body2" color="textSecondary">
{title}
</Typography>
</DialogTitle>
)}
{title && (
<Divider />
)}
<DialogContent style={{padding: 0, margin: 0}}>
<List style={{ marginLeft: 3, marginRight: 3 ,minWidth: minWidth }}>
{options.map(
(item: OptionItem,index) => {
return (
<ListItemButton onClick={() => handleListItemClick(item)} key={item.key} selected={item === currentSelection} >
<ListItemText primary={item.text} />
</ListItemButton>
);
}
)}
</List>
</DialogContent>
</DialogEx>
);
}
export default OptionsDialog;
+54
View File
@@ -0,0 +1,54 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { Component, ReactNode } from 'react';
import { Lv2Plugin } from './Lv2Plugin'
interface PedalProps {
plugin: Lv2Plugin;
children?: React.ReactNode;
}
type PedalState = {
};
export const TemporaryDrawer =
class extends Component<PedalProps, PedalState>
{
state: PedalState;
constructor(props: PedalProps) {
super(props);
this.state = {
};
}
render() : ReactNode {
return (
<div>
</div>
);
}
};
+772
View File
@@ -0,0 +1,772 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { PiPedalArgumentError } from './PiPedalError';
import MidiBinding from './MidiBinding';
import MidiChannelBinding from './MidiChannelBinding';
const SPLIT_PEDALBOARD_ITEM_URI = "uri://two-play/pipedal/pedalboard#Split";
const EMPTY_PEDALBOARD_ITEM_URI = "uri://two-play/pipedal/pedalboard#Empty";
interface Deserializable<T> {
deserialize(input: any): T;
}
export class ControlValue implements Deserializable<ControlValue> {
constructor(key?: string, value?: number)
{
this.key = key??"";
this.value = value?? 0;
}
deserialize(input: any): ControlValue {
this.key = input.key;
this.value = input.value;
return this;
}
static EmptyArray: ControlValue[] = [];
static deserializeArray(input: any[]): ControlValue[] {
let result: ControlValue[] = [];
for (let i = 0; i < input.length; ++i) {
result[i] = new ControlValue().deserialize(input[i]);
}
return result;
}
setValue(value: number) {
this.value = value;
}
key: string;
value: number;
}
export class PedalboardItem implements Deserializable<PedalboardItem> {
deserializePedalboardItem(input: any): PedalboardItem {
this.instanceId = input.instanceId ?? -1;
this.uri = input.uri;
this.pluginName = input.pluginName;
this.isEnabled = input.isEnabled;
this.midiBindings = MidiBinding.deserialize_array(input.midiBindings);
if (input.midiChannelBinding)
{
this.midiChannelBinding = input.midiChannelBinding;
} else {
this.midiChannelBinding = null;
}
this.controlValues = ControlValue.deserializeArray(input.controlValues);
this.vstState = input.vstState ?? "";
this.stateUpdateCount = input.stateUpdateCount;
this.lv2State = input.lv2State;
this.lilvPresetUri = input.lilvPresetUri;
this.pathProperties = input.pathProperties;
return this;
}
deserialize(input: any): PedalboardItem {
return this.deserializePedalboardItem(input);
}
static deserializeArray(input: any): PedalboardItem[] {
let result: PedalboardItem[] = [];
for (let i = 0; i < input.length; ++i) {
let inputItem: any = input[i];
let uri: string = inputItem.uri as string;
let outputItem: PedalboardItem;
if (uri === SPLIT_PEDALBOARD_ITEM_URI) {
outputItem = new PedalboardSplitItem().deserialize(inputItem);
} else {
outputItem = new PedalboardItem().deserialize(inputItem);
}
result[i] = outputItem;
}
return result;
}
isSyntheticItem(): boolean {
return this.instanceId === Pedalboard.START_CONTROL
|| this.instanceId === Pedalboard.END_CONTROL;
}
getInstanceId() : number {
if (this.instanceId === undefined)
{
throw new PiPedalArgumentError("Item does not have an id.");
}
return this.instanceId;
}
isEmpty(): boolean {
return this.uri === EMPTY_PEDALBOARD_ITEM_URI;
}
isSplit(): boolean {
return this.uri === SPLIT_PEDALBOARD_ITEM_URI;
}
isStart(): boolean {
return this.uri === Pedalboard.START_PEDALBOARD_ITEM_URI;
}
isEnd(): boolean {
return this.uri === Pedalboard.END_PEDALBOARD_ITEM_URI;
}
getControl(key: string): ControlValue {
for (let i = 0; i < this.controlValues.length; ++i) {
let v = this.controlValues[i];
if (v.key === key) {
return v;
}
}
throw new PiPedalArgumentError("Invalid key.");
}
getControlValue(key: string): number {
for (let i = 0; i < this.controlValues.length; ++i) {
let v = this.controlValues[i];
if (v.key === key) {
return v.value;
}
}
return 0;
}
setControlValue(key: string, value: number): boolean {
for (let i = 0; i < this.controlValues.length; ++i) {
let v = this.controlValues[i];
if (v.key === key) {
if (v.value === value) return false;
v.setValue(value);
return true;
}
}
return false;
}
setMidiBinding(midiBinding: MidiBinding): boolean {
if (this.midiBindings)
{
for (let i = 0; i < this.midiBindings.length; ++i)
{
let binding = this.midiBindings[i];
if (midiBinding.symbol === binding.symbol)
{
if (binding.equals(midiBinding))
{
return false;
}
this.midiBindings.splice(i,1,midiBinding);
return true;
}
}
this.midiBindings.push(midiBinding);
return true;
}
this.midiBindings = [ midiBinding];
return true;
}
getMidiBinding(symbol: string): MidiBinding {
if (this.midiBindings)
{
for (let i = 0; i < this.midiBindings.length; ++i)
{
let midiBinding = this.midiBindings[i];
if (midiBinding.symbol === symbol)
{
return midiBinding;
}
}
}
let result = new MidiBinding();
result.symbol = symbol;
return result;
}
static EmptyArray: PedalboardItem[] = [];
instanceId: number = -1;
isEnabled: boolean = false;
uri: string = "";
pluginName?: string;
controlValues: ControlValue[] = ControlValue.EmptyArray;
midiBindings: MidiBinding[] = [];
midiChannelBinding: MidiChannelBinding | null = null;
vstState: string = "";
stateUpdateCount: number = 0;
lv2State: [boolean,any] = [false,{}];
lilvPresetUri: string = "";
pathProperties: {[Name: string]: string} = {};
};
export class SnapshotValue {
deserialize(input: any): SnapshotValue {
this.isEnabled = input.isEnabled;
this.instanceId = input.instanceId;
this.controlValues = ControlValue.deserializeArray(input.controlValues);
this.lv2State = input.lv2state;
this.pathProperties = input.pathProperties;
return this;
}
static deserializeArray(input: any): SnapshotValue[] {
let result: SnapshotValue[] = [];
for (let i = 0; i < input.length; ++i) {
let inputItem: any = input[i];
let outputItem = new SnapshotValue().deserialize(inputItem);
result[i] = outputItem;
}
return result;
}
static createFromPedalboardItem(item: PedalboardItem)
{
let result = new SnapshotValue();
result.instanceId = item.instanceId;
result.isEnabled = item.isEnabled;
result.controlValues = ControlValue.deserializeArray(item.controlValues);
result.lv2State = item.lv2State; // we can do this, because lv2State is immutable.
result.pathProperties = {...item.pathProperties}; // clone the dictionary.
return result;
}
instanceId: number = -1;
isEnabled: boolean = true;
controlValues: ControlValue[] = ControlValue.EmptyArray;
lv2State: [boolean,any] = [false,{}];
pathProperties: {[Name: string]: string} = {};
}
export class Snapshot {
deserialize(input: any): Snapshot {
this.values = SnapshotValue.deserializeArray(input.values);
this.isModified = input.isModified;
this.name = input.name;
this.color = input.color;
return this;
}
static deserializeArray(input: any): (Snapshot| null)[] {
let result: (Snapshot|null)[] = [];
for (let i = 0; i < input.length; ++i) {
let inputItem: any = input[i];
let outputItem: (Snapshot|null) = null;
if (inputItem !== null)
{
outputItem = new Snapshot().deserialize(inputItem);
}
result[i] = outputItem;
}
return result;
}
static readonly MAX_SNAPSHOTS: number = 6;
static cloneSnapshots(snapshots: (Snapshot|null)[]): (Snapshot|null)[]
{
let result: (Snapshot|null)[] = [];
for (let i = 0; i < Snapshot.MAX_SNAPSHOTS; ++i)
{
if (i >= snapshots.length)
{
result.push(null);
} else {
result.push(snapshots[i]);
}
}
return result;
}
name: string = "";
isModified: boolean = false;
color: string = "";
values: SnapshotValue[] = [];
};
export enum SplitType {
Ab = 0,
Mix = 1,
Lr = 2
}
export class PedalboardSplitItem extends PedalboardItem {
static PANL_KEY: string = "panL";
static PANR_KEY: string = "panR";
static VOLL_KEY: string = "volL";
static VOLR_KEY: string = "volR";
static MIX_KEY: string = "mix";
static TYPE_KEY: string = "splitType";
static SELECT_KEY: string = "select";
deserialize(input: any): PedalboardSplitItem {
this.deserializePedalboardItem(input);
this.topChain = PedalboardItem.deserializeArray(input.topChain);
this.bottomChain = PedalboardItem.deserializeArray(input.bottomChain);
return this;
}
getSplitType(): SplitType {
let rawValue = this.getControlValue(PedalboardSplitItem.TYPE_KEY);
if (rawValue < 1) return SplitType.Ab;
if (rawValue < 2) return SplitType.Mix;
return SplitType.Lr;
}
getToggleAbControlValue(): ControlValue {
let cv = new ControlValue();
cv.key = "select";
cv.value = this.isASelected() ? 1 : 0;
return cv;
}
getMixControl(): ControlValue {
return this.getControl(PedalboardSplitItem.MIX_KEY);
}
getMix(): number {
return this.getControlValue(PedalboardSplitItem.MIX_KEY);
}
isASelected(): boolean {
return this.getSplitType() !== SplitType.Ab || this.getControlValue(PedalboardSplitItem.SELECT_KEY) === 0;
}
isBSelected(): boolean {
return this.getSplitType() !== SplitType.Ab || this.getControlValue(PedalboardSplitItem.SELECT_KEY) !== 0;
}
topChain: PedalboardItem[] = PedalboardItem.EmptyArray;
bottomChain: PedalboardItem[] = PedalboardItem.EmptyArray;
}
export class Pedalboard implements Deserializable<Pedalboard> {
static readonly START_CONTROL = -2; // synthetic PedalboardItem for input volume.
static readonly END_CONTROL = -3; // synthetic PedalboardItem for output volume.
static readonly START_PEDALBOARD_ITEM_URI = "uri://two-play/pipedal/pedalboard#Start";
static readonly END_PEDALBOARD_ITEM_URI = "uri://two-play/pipedal/pedalboard#End";
deserialize(input: any): Pedalboard {
this.name = input.name;
this.input_volume_db = input.input_volume_db;
this.output_volume_db = input.output_volume_db;
this.items = PedalboardItem.deserializeArray(input.items);
this.nextInstanceId = input.nextInstanceId ?? -1;
this.snapshots = input.snapshots ? Snapshot.deserializeArray(input.snapshots): [];
this.selectedSnapshot = input.selectedSnapshot;
this.pathProperties = input.pathProperties;
return this;
}
clone(): Pedalboard {
return new Pedalboard().deserialize(this);
}
name: string = "";
input_volume_db: number = 0;
output_volume_db: number = 0;
items: PedalboardItem[] = [];
nextInstanceId: number = -1;
snapshots: (Snapshot | null)[] = [];
selectedSnapshot: number = -1;
pathProperties: {[Name: string]: string} = {};
*itemsGenerator(): Generator<PedalboardItem, void, undefined> {
let it = itemGenerator_(this.items);
while (true)
{
let v = it.next();
if (v.done) break;
yield v.value;
}
}
makeSnapshot(): Snapshot {
let result = new Snapshot();
let it = this.itemsGenerator();
while (true)
{
let v = it.next();
if (v.done) break;
let pedalboardItem = v.value;
let snapshotValue = SnapshotValue.createFromPedalboardItem(pedalboardItem);
result.values.push(snapshotValue);
}
return result;
}
hasItem(instanceId: number): boolean
{
let it = this.itemsGenerator();
while (true)
{
let v = it.next();
if (v.done) break;
if (v.value.instanceId === instanceId)
{
return true;
}
}
return false;
}
getFirstSelectableItem(): number
{
if (this.items.length !== 0)
{
return this.items[0].instanceId;
}
return -1;
}
maybeGetItem(instanceId: number): PedalboardItem | null{
let it = this.itemsGenerator();
while (true)
{
let v = it.next();
if (v.done) break;
if (v.value.instanceId === instanceId)
{
return v.value;
}
}
return null;
}
makeStartItem(): PedalboardItem {
let result = new PedalboardItem();
result.pluginName = "Input";
result.instanceId = Pedalboard.START_CONTROL;
result.uri = Pedalboard.START_PEDALBOARD_ITEM_URI;
result.isEnabled = true;
result.controlValues = [new ControlValue("volume_db",this.input_volume_db)];
return result;
}
makeEndItem(): PedalboardItem {
let result = new PedalboardItem();
result.pluginName = "Output";
result.instanceId = Pedalboard.END_CONTROL;
result.uri = Pedalboard.END_PEDALBOARD_ITEM_URI;
result.isEnabled = true;
result.controlValues = [new ControlValue("volume_db",this.output_volume_db)];
return result;
}
getItem(instanceId: number): PedalboardItem {
let it = this.itemsGenerator();
while (true)
{
let v = it.next();
if (v.done) break;
if (v.value.instanceId === instanceId)
{
return v.value;
}
}
throw new PiPedalArgumentError("Item not found.");
}
deleteItem_(instanceId: number,items: PedalboardItem[]): number | null
{
for (let i = 0; i < items.length; ++i)
{
let item = items[i];
if (item.instanceId === instanceId)
{
if (items.length > 1) {
items.splice(i,1);
let nextSelectedItem = i;
if (i >= items.length) --nextSelectedItem;
return items[nextSelectedItem].instanceId;
} else {
// replace with an empty item.
let newItem = this.createEmptyItem();
items[i] = newItem;
return newItem.instanceId;
}
} else {
if (item.isSplit())
{
let splitItem = item as PedalboardSplitItem;
let t = this.deleteItem_(instanceId,splitItem.topChain);
if (t != null) return t;
t = this.deleteItem_(instanceId,splitItem.bottomChain);
if (t != null) return t;
}
}
}
return null;
}
canDeleteItem_(instanceId: number,items: PedalboardItem[]): boolean
{
for (let i = 0; i < items.length; ++i)
{
let item = items[i];
if (item.instanceId === instanceId)
{
if (items.length > 1) return true;
return !item.isEmpty(); // can delete if there's one non-empty item.
}
if (item.isSplit())
{
let splitItem = item as PedalboardSplitItem;
if (this.canDeleteItem_(instanceId,splitItem.topChain))
{
return true;
}
if (this.canDeleteItem_(instanceId,splitItem.bottomChain))
{
return true;
}
}
}
return false;
}
canDeleteItem(instanceId: number): boolean
{
return this.canDeleteItem_(instanceId,this.items);
}
// Returns the next selected instanceId, or null if no deletion occurred.
deleteItem(instanceId: number): number | null {
return this.deleteItem_(instanceId,this.items);
}
setMidiBinding(instanceId: number, midiBinding: MidiBinding): boolean
{
let item = this.getItem(instanceId);
if (!item) return false;
return item.setMidiBinding(midiBinding);
}
addToStart(item: PedalboardItem)
{
this.items.splice(0,0,item);
}
addToEnd(item: PedalboardItem)
{
this.items.splice(this.items.length,0,item);
}
static _addRelative(items: PedalboardItem[],newItem: PedalboardItem, instanceId: number, addBefore: boolean): boolean
{
for (let i = 0; i < items.length; ++i)
{
let item = items[i];
if (item.instanceId === instanceId)
{
if (addBefore)
{
items.splice(i,0,newItem);
} else {
items.splice(i+1,0,newItem);
}
return true;
}
if (item.isSplit())
{
let split = item as PedalboardSplitItem;
if (this._addRelative(split.topChain,newItem,instanceId,addBefore))
{
return true;
}
if (this._addRelative(split.bottomChain,newItem,instanceId,addBefore))
{
return true;
}
}
}
return false;
}
addBefore(item: PedalboardItem, instanceId: number)
{
if (item.instanceId === instanceId) return;
let result = Pedalboard._addRelative(this.items,item, instanceId, true);
if (!result) {
throw new PiPedalArgumentError("instanceId not found.");
}
}
addAfter(item: PedalboardItem, instanceId: number)
{
if (item.instanceId === instanceId) return;
let result = Pedalboard._addRelative(this.items,item, instanceId, false);
if (!result) {
throw new PiPedalArgumentError("instanceId not found.");
}
}
ensurePedalboardIds() {
if (this.nextInstanceId === -1) {
let minId = 1;
let it = this.itemsGenerator();
while (true) {
let v = it.next();
if (v.done) break;
if (v.value.instanceId) {
let t = v.value.instanceId;
if (t+1 > minId) minId = t+1;
}
}
this.nextInstanceId = minId;
}
let it = this.itemsGenerator();
while (true) {
let v = it.next();
if (v.done) break;
if (v.value.instanceId === -1) {
v.value.instanceId = ++this.nextInstanceId;
}
}
}
createEmptySplit(): PedalboardSplitItem {
let result: PedalboardSplitItem = new PedalboardSplitItem();
result.uri = SPLIT_PEDALBOARD_ITEM_URI;
result.instanceId = ++this.nextInstanceId;
result.pluginName = "";
result.isEnabled = true;
result.topChain = [ this.createEmptyItem()];
result.bottomChain = [ this.createEmptyItem()];
result.controlValues = [
new ControlValue(PedalboardSplitItem.TYPE_KEY, 0),
new ControlValue(PedalboardSplitItem.SELECT_KEY,0),
new ControlValue(PedalboardSplitItem.MIX_KEY,0),
new ControlValue(PedalboardSplitItem.PANL_KEY,0),
new ControlValue(PedalboardSplitItem.VOLL_KEY,-3),
new ControlValue(PedalboardSplitItem.PANR_KEY,0),
new ControlValue(PedalboardSplitItem.VOLR_KEY,-3)
];
return result;
}
createEmptyItem(): PedalboardItem {
let result: PedalboardItem = new PedalboardItem();
result.uri = EMPTY_PEDALBOARD_ITEM_URI;
result.instanceId = ++this.nextInstanceId;
result.pluginName = "";
result.isEnabled = true;
return result;
}
setItemEmpty(item: PedalboardItem)
{
item.uri = EMPTY_PEDALBOARD_ITEM_URI;
item.instanceId = ++this.nextInstanceId;
item.pluginName = "";
item.isEnabled = true;
item.controlValues = [];
item.vstState = "";
item.lv2State = [false,{}];
}
_replaceItem(items: PedalboardItem[], instanceId: number, newItem: PedalboardItem): boolean {
for (let i = 0; i < items.length; ++i)
{
let item = items[i];
if (item.instanceId === instanceId)
{
items[i] = newItem;
return true;
}
if (items[i].isSplit())
{
let splitItem = item as PedalboardSplitItem;
if (this._replaceItem(splitItem.topChain,instanceId,newItem))
return true;
if (this._replaceItem(splitItem.bottomChain,instanceId,newItem))
{
return true;
}
}
}
return false;
}
replaceItem(instanceId: number, newItem: PedalboardItem)
{
let result = this._replaceItem(this.items,instanceId,newItem);
if (!result)
{
throw new PiPedalArgumentError("instanceId not found.");
}
}
private _addItem(items: PedalboardItem[], newItem: PedalboardItem, instanceId: number, append: boolean)
{
for (let i = 0; i < items.length; ++i)
{
let item = items[i];
if (item.instanceId === instanceId)
{
if (append)
{
items.splice(i+1,0,newItem);
return true;
} else {
items.splice(i,0,newItem);
return true;
}
}
if (item.isSplit())
{
let splitItem = item as PedalboardSplitItem;
if (this._addItem(splitItem.topChain,newItem,instanceId,append)) return true;
if (this._addItem(splitItem.bottomChain,newItem,instanceId,append)) return true;
}
}
}
addItem(newItem: PedalboardItem, instanceId: number, append: boolean): void
{
this._addItem(this.items,newItem,instanceId,append);
}
}
function* itemGenerator_(items: PedalboardItem[]): Generator<PedalboardItem, void, undefined> {
for (let i = 0; i < items.length; ++i) {
let item = items[i];
yield item;
if (item.uri === SPLIT_PEDALBOARD_ITEM_URI) {
let splitItem = item as PedalboardSplitItem;
let it = itemGenerator_(splitItem.topChain);
while (true) {
let v = it.next();
if (v.done) break;
yield v.value;
}
it = itemGenerator_(splitItem.bottomChain);
while (true) {
let v = it.next();
if (v.done) break;
yield v.value;
}
}
}
}
File diff suppressed because it is too large Load Diff
+417
View File
@@ -0,0 +1,417 @@
// Copyright (c) 2024 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { Theme } from '@mui/material/styles';
import SaveIconOutline from '@mui/icons-material/Save';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { PiPedalModel, PiPedalModelFactory, PresetIndex } from './PiPedalModel';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import IconButton from '@mui/material/IconButton';
import AppBar from '@mui/material/AppBar';
import SnapshotPanel from './SnapshotPanel';
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
//import ArrowLeftOutlined from '@mui/icons-material/ArrowLeft';
//import ArrowRightOutlined from '@mui/icons-material/ArrowRight';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import { isDarkMode } from './DarkMode';
import { BankIndex } from './Banks';
import SnapshotEditor from './SnapshotEditor';
import { Snapshot } from './Pedalboard';
import JackStatusView from './JackStatusView';
import {IDialogStackable, popDialogStack, pushDialogStack} from './DialogStack';
import { css } from '@emotion/react';
const selectColor = isDarkMode() ? "#888" : "#FFFFFF";
const styles = (theme: Theme) => createStyles({
frame: css({
position: "absolute", display: "flex", flexDirection: "column", flexWrap: "nowrap",
justifyContent: "flex-start", left: "0px", top: "0px", bottom: "0px", right: "0px", overflow: "hidden"
}),
select: css({ // fu fu fu.Overrides for white selector on dark background.
'&:before': {
borderColor: selectColor,
},
'&:after': {
borderColor: selectColor,
},
'&:hover:not(.Mui-disabled):before': {
borderColor: selectColor,
}
}),
select_icon: css({
fill: selectColor,
}),
});
interface PerformanceViewProps extends WithStyles<typeof styles> {
open: boolean,
onClose: () => void,
}
interface PerformanceViewState {
wrapSelects: boolean
presets: PresetIndex;
banks: BankIndex;
showSnapshotEditor: boolean;
snapshotEditorIndex: number;
presetModified: boolean;
}
export const PerformanceView =
withStyles(
class extends ResizeResponsiveComponent<PerformanceViewProps, PerformanceViewState> implements IDialogStackable {
model: PiPedalModel;
constructor(props: PerformanceViewProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
// let pedalboard = this.model.pedalboard.get();
// let selectedPedal = pedalboard.getFirstSelectableItem();
this.state = {
presets: this.model.presets.get(),
banks: this.model.banks.get(),
wrapSelects: false,
showSnapshotEditor: false,
snapshotEditorIndex: 0,
presetModified: this.model.presetChanged.get()
};
this.onPresetsChanged = this.onPresetsChanged.bind(this);
this.onBanksChanged = this.onBanksChanged.bind(this);
this.onPresetChangedChanged = this.onPresetChangedChanged.bind(this);
}
getTag() { return "performView"}
isOpen() { return this.props.open }
onDialogStackClose() {
this.props.onClose();
}
private hasHooks: boolean = false;
updateHooks() : void {
let wantHooks = this.mounted && this.props.open;
if (wantHooks !== this.hasHooks)
{
this.hasHooks = wantHooks;
if (this.hasHooks)
{
pushDialogStack(this);
} else {
popDialogStack(this);
}
}
}
onWindowSizeChanged(width: number, height: number): void {
this.setState({ wrapSelects: width < 700 });
}
onPresetsChanged(newValue: PresetIndex) {
this.setState({ presets: this.model.presets.get() })
}
onBanksChanged(newValue: BankIndex) {
this.setState({ banks: this.model.banks.get() })
}
onPresetChangedChanged(newValue: boolean) {
this.setState({ presetModified:newValue});
}
private mounted: boolean = false;
componentDidMount(): void {
super.componentDidMount();
this.mounted = true;
this.model.presets.addOnChangedHandler(this.onPresetsChanged);
this.model.banks.addOnChangedHandler(this.onBanksChanged);
this.model.presetChanged.addOnChangedHandler(this.onPresetChangedChanged)
this.setState({
presets: this.model.presets.get(),
banks: this.model.banks.get(),
presetModified: this.model.presetChanged.get()
});
this.updateHooks();
}
componentWillUnmount(): void {
this.model.presetChanged.removeOnChangedHandler(this.onPresetChangedChanged)
this.model.presets.removeOnChangedHandler(this.onPresetsChanged);
this.model.banks.removeOnChangedHandler(this.onBanksChanged);
this.mounted = false;
this.updateHooks();
super.componentWillUnmount();
}
handlePresetSelectClose(event: any): void {
let value = event.currentTarget.getAttribute("data-value");
if (value && value.length > 0) {
this.model.loadPreset(parseInt(value));
}
}
handleBankSelectClose(event: any): void {
let value = event.currentTarget.getAttribute("data-value");
if (value && value.length > 0) {
this.model.openBank(parseInt(value));
}
}
handleOnEdit(index: number): boolean {
// load it so we can edit it.
this.model.selectSnapshot(index);
this.setState({
showSnapshotEditor: true,
snapshotEditorIndex: index
});
return true;
}
handleSnapshotEditOk(index: number, name: string, color: string,newSnapshots: (Snapshot|null)[])
{
// results have been sent to the server. we would perform a short-cut commit to our local state, to avoid laggy display updates.
// but we don't actually have that in our state.
}
handlePreviousBank()
{
this.model.previousBank();
}
handleNextBank()
{
this.model.nextBank();
}
handlePreviousPreset()
{
this.model.previousPreset();
}
handleNextPreset()
{
this.model.nextPreset();
}
render() {
const classes = withStyles.getClasses(this.props);
let wrapSelects = this.state.wrapSelects;
let presets = this.state.presets;
let banks = this.state.banks;
return (
<div className={classes.frame} style={{ overflow: "clip", height: "100%" }} >
<div className={classes.frame} style={{ overflow: "clip", height: "100%" }} >
<AppBar id="select-plugin-dialog-title"
style={{
position: "static", flex: "0 0 auto", height: wrapSelects ? 108 : 58,
display: this.state.showSnapshotEditor ? "none" : undefined
}}
>
<div style={{
display: "flex", flexFlow: "row nowrap", alignContent: "center",
paddingTop: 3, paddingBottom: 3, paddingRight: 8, paddingLeft: 8,
alignItems: "start"
}}>
<IconButton aria-label="menu" color="inherit"
onClick={() => { this.props.onClose(); }}
style={{
position: "relative",top: 3,
flex: "0 0 auto" }} >
<ArrowBackIcon />
</IconButton>
<div style={{
flex: "1 1 1px", display: "flex",
flexFlow: wrapSelects ? "column nowrap" : "row nowrap",
alignItems: wrapSelects ? "stretch" : undefined,
justifyContent: wrapSelects ? undefined : "space-evenly",
marginLeft: 8,
gap: 8
}}>
{/********* BANKS *******************/}
<div style={{ flex: "1 1 1px", display: "flex", flexFlow: "row nowrap", maxWidth: 500 }}>
<IconButton
aria-label="previous-bank"
onClick={() => { this.handlePreviousBank(); }}
size="medium"
color="inherit"
style={{
borderTopRightRadius: "3px", borderBottomRightRadius: "3px", marginRight: 4
}}
>
<ArrowBackIosIcon style={{ opacity: 0.75 }} />
</IconButton>
<Select variant="standard"
className={classes.select}
style={{ flex: "1 1 1px", width: "100%", position: "relative", top: 0, color: "#FFFFFF" }} disabled={false}
displayEmpty
onClose={(e) => this.handleBankSelectClose(e)}
value={banks.selectedBank === 0 ? undefined : banks.selectedBank}
inputProps={{
classes: { icon: classes.select_icon },
'aria-label': "Select preset"
}}
>
{
banks.entries.map((entry) => {
return (
<MenuItem key={entry.instanceId} value={entry.instanceId} >
{entry.name}
</MenuItem>
);
})
}
</Select>
<IconButton
aria-label="next-bank"
onClick={() => { this.handleNextBank(); }}
color="inherit"
style={{ borderTopLeftRadius: "3px", borderBottomLeftRadius: "3px", marginLeft: 4 }}
>
<ArrowForwardIosIcon style={{ opacity: 0.75 }} />
</IconButton>
{/** spacer */}
<IconButton
aria-label="next-bank"
onClick={() => { this.handleNextBank(); }}
size="medium"
color="inherit"
style={{visibility: "hidden"}}
>
<ArrowForwardIosIcon style={{ opacity: 0.75 }} />
</IconButton>
</div>
{/********* PRESETS *******************/}
<div style={{ flex: "1 1 1px", display: "flex", flexFlow: "row nowrap", maxWidth: 500 }}>
<IconButton
aria-label="previous-presest"
onClick={() => { this.handlePreviousPreset(); }}
color="inherit"
style={{ borderTopRightRadius: "3px", borderBottomRightRadius: "3px", marginRight: 4 }}
>
<ArrowBackIosIcon style={{ opacity: 0.75 }} />
</IconButton>
<Select variant="standard"
className={classes.select}
style={{
flex: "1 1 1px", width: "100%", color: "#FFFFFF"
}} disabled={false}
displayEmpty
onClose={(e) => this.handlePresetSelectClose(e)}
value={presets.selectedInstanceId === 0 ? '' : presets.selectedInstanceId}
inputProps={{
classes: { icon: classes.select_icon },
'aria-label': "Select preset"
}
}
>
{
presets.presets.map((preset) => {
let name = preset.name;
if (this.state.presets.selectedInstanceId === preset.instanceId && this.state.presetModified)
{
name += "*";
}
return (
<MenuItem key={preset.instanceId} value={preset.instanceId} >
{name}
</MenuItem>
);
})
}
</Select>
<IconButton
aria-label="next-preset"
onClick={() => { this.handleNextPreset(); }}
color="inherit"
style={{ borderTopLeftRadius: "3px", borderBottomLeftRadius: "3px", marginLeft: 4 }}
>
<ArrowForwardIosIcon style={{ opacity: 0.75 }} />
</IconButton>
<IconButton
aria-label="save-preset"
onClick={() => { this.model.saveCurrentPreset(); }}
size="medium"
color="inherit"
style={{flexShrink: 0,visibility: this.state.presetModified? "visible": "hidden"}}
>
<SaveIconOutline style={{ opacity: 0.75 }} color="inherit" />
</IconButton>
</div>
</div>
</div>
</AppBar >
<div style={{ flex: "1 0 auto", display: "flex", marginTop: 16,marginBottom: 20 }}>
<SnapshotPanel onEdit={(index) => { return this.handleOnEdit(index); }} />
</div>
{!this.state.showSnapshotEditor && (
<JackStatusView />)
}
</div>
{this.state.showSnapshotEditor && (
<SnapshotEditor snapshotIndex={this.state.snapshotEditorIndex}
onClose={() => {
this.setState({ showSnapshotEditor: false });
}}
onOk={(index,name,color,newSnapshots)=>{
this.setState({ showSnapshotEditor: false});
this.handleSnapshotEditOk(index,name,color,newSnapshots);
}}
/>
)}
</div >
)
}
},
styles
);
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export class PiPedalError extends Error {
constructor(message: string)
{
super(message);
this.name = "PiPedalError";
}
}
export class PiPedalArgumentError extends PiPedalError {
constructor(message: string)
{
super(message);
this.name = "PiPedalArgumentError";
}
}
export class PiPedalStateError extends PiPedalError {
constructor(message: string)
{
super(message);
this.name = "PiPedalStateError";
}
}
export default PiPedalError;
File diff suppressed because it is too large Load Diff
+313
View File
@@ -0,0 +1,313 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { PiPedalStateError } from './PiPedalError';
import Utility from './Utility';
export type MessageHandler = (header: PiPedalMessageHeader, body: any | null) => void;
export type ErrorHandler = (message: string, exception?: Error) => void;
export type ReconnectHandler = () => void;
export type ReconnectingHandler = (retry: number, maxRetries: number) => void;
const MAX_RETRIES = 6;
const MAX_RETRY_TIME = 90 * 1000;
export type PiPedalMessageHeader = {
replyTo?: number;
reply?: number;
message: string;
}
type ReplyHandler = (header: PiPedalMessageHeader, body: any | null) => void;
export interface PiPedalSocketListener {
onMessageReceived: (header: PiPedalMessageHeader, body: any | null) => void;
onError: (message: string, exception?: Error) => void;
onConnectionLost: () => void;
onReconnect: () => void;
onReconnecting: (retry: number, maxRetries: number) => boolean;
};
class PiPedalSocket {
listener: PiPedalSocketListener;
socket?: WebSocket;
nextResponseCode: number = 0;
url: string;
retrying: boolean = false;
retryCount: number = 0;
retryDelay: number = 0;
totalRetryDelay: number = 0;
constructor(
url: string,
listener: PiPedalSocketListener
) {
this.url = url;
this.listener = listener;
}
handleOpen(event: Event): any {
}
sendInternal_(json: string) {
if (!this.retrying) {
this.socket?.send(json);
}
}
send(message: string, jsonObject?: any) {
let msg: any;
if (jsonObject === undefined) {
msg = [{ message: message }];
} else {
msg = [{ message: message }, jsonObject];
}
let json = JSON.stringify(msg);
this.sendInternal_(json);
}
reply(replyTo: number, message: string, jsonObject?: any) {
if (replyTo !== -1) {
let msg: any;
if (jsonObject === undefined) {
msg = [{ reply: replyTo, message: message }];
} else {
msg = [{ reply: replyTo, message: message }, jsonObject];
}
let json = JSON.stringify(msg);
this.sendInternal_(json);
}
}
_nextResponseCode(): number {
return ++this.nextResponseCode;
}
_replyMap: Map<number, ReplyHandler> = new Map<number, ReplyHandler>();
_discardReplyReservations() {
// it's ok. All pending reservations disappear into the GC.
this._replyMap = new Map<number, ReplyHandler>();
}
_addReservation(replyCode: number, handler: ReplyHandler): void {
this._replyMap.set(replyCode, handler);
}
request<Type = any>(message_: string, requestArgs?: any): Promise<Type> {
let responseCode = this._nextResponseCode();
return new Promise<Type>((resolve, reject) => {
try {
this._addReservation(responseCode, (header: PiPedalMessageHeader, jsonObject?: any) => {
if (header.message === "error") {
reject(jsonObject + "");
} else {
resolve(jsonObject as Type);
}
});
let msg: any;
if (requestArgs !== undefined) {
msg = [{ message: message_, replyTo: responseCode }, requestArgs];
} else {
msg = [{ message: message_, replyTo: responseCode }];
}
let jsonMessage = JSON.stringify(msg);
this.sendInternal_(jsonMessage);
} catch (err) {
reject(err);
}
});
}
handleMessage(event: MessageEvent<string>): any {
try {
let message: any = JSON.parse(event.data);
if (!Array.isArray(message)) {
throw new PiPedalStateError("Invalid message received from server.");
}
let header = message[0] as PiPedalMessageHeader;
let body = undefined;
if (message.length === 2) {
body = message[1];
}
if (header.reply !== undefined) {
let handler = this._replyMap.get(header.reply);
if (handler) {
handler(header, body);
}
return;
} else {
if (header.message === "error") {
throw new PiPedalStateError("Server error: " + body);
}
this.listener.onMessageReceived(header, body);
}
} catch (error) {
if (this.listener) {
this.listener.onError("Invalid server response. " + error, error as Error);
} else {
throw new PiPedalStateError("Invalid server response.");
}
}
}
handleError(_event: Event): any {
if (this.listener) {
this.listener.onError("Server connection lost.");
} else {
throw new PiPedalStateError("Server connection lost.");
}
}
canReconnect: boolean = false;
handleClose(_event: any): any {
if (this.retrying) {
this.close();
// treat this as a fatal error.
if (this.listener) {
this.listener.onError("Server connection lost.");
} else {
throw new PiPedalStateError("Server connection closed.");
}
return;
}
if (this.canReconnect) {
this.listener.onConnectionLost();
this._reconnect();
}
}
_reconnect() {
this._discardReplyReservations();
this.retrying = true;
this.retryCount = 0;
this.socket = undefined;
this.retryDelay = 10000;
this.totalRetryDelay = 0;
this.reconnect();
}
isBackground: boolean = false;
enterBackgroundState() {
this.isBackground = true;
this.close();
}
exitBackgroundState() {
this.isBackground = false;
this._reconnect();
}
reconnect() {
if (this.socket) {
this.close();
}
if (!this.listener.onReconnecting(this.retryCount, MAX_RETRIES)) {
return;
}
++this.retryCount;
this.connectInternal_()
.then((socket) => {
this.socket = socket;
this.retrying = false;
this.listener.onReconnect();
})
.catch(error => {
if (this.totalRetryDelay >= MAX_RETRY_TIME) {
this.listener.onError("Server connection lost.");
return;
} else {
this.totalRetryDelay += this.retryDelay;
Utility.delay(this.retryDelay).then(() => this.reconnect());
this.retryDelay *= 2;
if (this.retryDelay > 3000) this.retryDelay = 3000;
}
});
}
close(): void {
try {
if (this.socket) {
this.socket.onclose = null;
this.socket.onerror = null;
this.socket.onmessage = null;
this.socket.onopen = null;
this.socket.close();
this.socket = undefined;
}
} catch (ignored) {
}
this.socket = undefined;
}
connectInternal_(): Promise<WebSocket> {
return new Promise<WebSocket>((resolve, reject) => {
try {
let ws = new WebSocket(this.url);
let self = this;
ws.onmessage = this.handleMessage.bind(this);
ws.onclose = (event: Event) => {
ws.onclose = null;
ws.onerror = null;
reject("Connection closed unexpectedly.");
};
ws.onerror = (evt: Event) => {
ws.onclose = null;
ws.onerror = null;
reject("Connection not accepted.");
};
ws.onopen = (event: Event) => {
ws.onerror = self.handleError.bind(self);
ws.onclose = self.handleClose.bind(self);
ws.onopen = null;
resolve(ws);
};
} catch (e: any){
reject("Failed to connect: " + e.toString());
};
});
}
connect(): Promise < void> {
return new Promise<void>((resolve, reject) => {
this.connectInternal_()
.then((socket) => {
this.socket = socket;
resolve();
})
.catch((reason) => {
reject(reason);
});
});
}
}
export default PiPedalSocket;
+82
View File
@@ -0,0 +1,82 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import {PluginType} from './Lv2Plugin';
class PluginClass {
deserialize(input: any) : PluginClass {
this.uri = input.uri;
this.display_name = input.display_name;
this.parent_uri = input.parent_uri;
this.plugin_type = input.plugin_type as PluginType;
this.children = PluginClass.deserialize_array(input.children);
return this;
}
static deserialize_array(input: any): PluginClass[] {
let result: PluginClass[] = [];
for (let i = 0; i < input.length; ++i)
{
result[i] = new PluginClass().deserialize(input[i]);
}
return result;
}
findChild(classUri: string): PluginClass | null {
if (this.uri === classUri) return this;
for (let i = 0; i < this.children.length; ++i)
{
let t = this.children[i].findChild(classUri);
if (t != null) return t;
}
return null;
}
findChildType(pluginType: PluginType): PluginClass | null {
if (this.plugin_type === pluginType) return this;
for (let i = 0; i < this.children.length; ++i)
{
let t = this.children[i].findChildType(pluginType);
if (t != null) return t;
}
return null;
}
is_a(parentClassUri: string,childClassUri: string): boolean {
let parent = this.findChild(parentClassUri);
if (parent === null) return false;
let child = parent.findChild(childClassUri);
return child != null;
}
is_type_of(parentType: PluginType,childType: PluginType): boolean {
let parent = this.findChildType(parentType);
if (parent === null) return false;
let child = parent.findChildType(childType);
return child != null;
}
uri: string = "";
display_name: string = "";
parent_uri: string = "";
plugin_type: PluginType = PluginType.Plugin;
children: PluginClass[] = [];
}
export default PluginClass;
+918
View File
@@ -0,0 +1,918 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { TouchEvent, PointerEvent, ReactNode, Component, SyntheticEvent } from 'react';
import { css } from '@emotion/react';
import Button from '@mui/material/Button';
import { Theme } from '@mui/material/styles';
import WithStyles, {withTheme} from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { UiControl, ScalePoint } from './Lv2Plugin';
import Typography from '@mui/material/Typography';
import Input from '@mui/material/Input';
import Select from '@mui/material/Select';
import Switch from '@mui/material/Switch';
import Utility, { nullCast } from './Utility';
import MenuItem from '@mui/material/MenuItem';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import DialIcon from './svg/fx_dial.svg?react';
import { isDarkMode } from './DarkMode';
import ControlTooltip from './ControlTooltip';
const MIN_ANGLE = -135;
const MAX_ANGLE = 135;
const FONT_SIZE = "0.8em";
const SELECTED_OPACITY = 0.8;
const DEFAULT_OPACITY = 0.6;
const RANGE_SCALE = 120; // 120 pixels to move from 0 to 1.
const FINE_RANGE_SCALE = RANGE_SCALE * 10; // 1200 pixels to move from 0 to 1.
const ULTRA_FINE_RANGE_SCALE = RANGE_SCALE * 50; // 12000 pixels to move from 0 to 1.
export const StandardItemSize = { width: 80, height: 140 }
export const pluginControlStyles = (theme: Theme) => createStyles({
frame: css({
position: "relative",
margin: "12px"
}),
switchTrack: css({
height: '100%',
width: '100%',
borderRadius: 14 / 2,
zIndex: -1,
transition: theme.transitions.create(['opacity', 'background-color'], {
duration: theme.transitions.duration.shortest
}),
backgroundColor: theme.palette.primary.main,
opacity: theme.palette.mode === 'light' ? 0.38 : 0.3
}),
controlFrame: css({
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "space-between", height: 116
}),
titleSection: css({
flex: "0 0 auto", alignSelf: "stretch", marginBottom: 8, marginLeft: 0, marginRight: 0
}),
displayValue: css({
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 4,
textAlign: "center",
background: theme.mainBackground,
color: theme.palette.text.secondary,
// zIndex: -1,
}),
midSection: css({
flex: "1 1 1", display: "flex", flexFlow: "column nowrap", alignContent: "center", justifyContent: "center"
}),
editSection: css({
flex: "0 0 0", display: "flex", flexFlow: "column nowrap", justifyContent: "center", position: "relative", width: 60, height: 28, minHeight: 28
})
});
export interface PluginControlProps extends WithStyles<typeof pluginControlStyles> {
uiControl?: UiControl;
instanceId: number;
value: number;
onPreviewChange?: (value: number) => void;
onChange: (value: number) => void;
theme: Theme;
requestIMEEdit: (uiControl: UiControl, value: number) => void;
}
type PluginControlState = {
error: boolean;
};
const PluginControl =
withTheme(withStyles(
class extends Component<PluginControlProps, PluginControlState> {
frameRef: React.RefObject<HTMLDivElement|null>;
imgRef: React.RefObject<SVGSVGElement|null>;
inputRef: React.RefObject<HTMLInputElement|null>;
selectRef: React.RefObject<HTMLSelectElement|null>;
displayValueRef: React.RefObject<HTMLDivElement|null>;
model: PiPedalModel;
constructor(props: PluginControlProps) {
super(props);
this.state = {
error: false
};
this.model = PiPedalModelFactory.getInstance();
this.imgRef = React.createRef();
this.inputRef = React.createRef();
this.selectRef = React.createRef();
this.displayValueRef = React.createRef();
this.frameRef = React.createRef();
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerLostCapture = this.onPointerLostCapture.bind(this);
this.onBodyPointerDownCapture = this.onBodyPointerDownCapture.bind(this);
this.onTouchStart = this.onTouchStart.bind(this);
this.onTouchMove = this.onTouchMove.bind(this);
//this.onTouchCancel = this.onTouchCancel.bind(this);
this.onSelectChanged = this.onSelectChanged.bind(this);
this.onInputChange = this.onInputChange.bind(this);
this.onInputLostFocus = this.onInputLostFocus.bind(this);
this.onInputFocus = this.onInputFocus.bind(this);
this.onInputKeyPress = this.onInputKeyPress.bind(this);
}
isTouchDevice(): boolean {
return Utility.needsZoomedControls();
}
showZoomedControl() {
if (this.props.uiControl && this.frameRef.current) {
this.model.zoomUiControl(this.frameRef.current, this.props.instanceId, this.props.uiControl);
}
}
hideZoomedControl() {
if (this.frameRef.current && this.model.zoomedUiControl.get()?.source === this.frameRef.current) {
this.model.clearZoomedControl();
}
}
componentWillUnmount() {
this.hideZoomedControl();
}
inputChanged: boolean = false;
onInputLostFocus(event: any): void {
if (this.inputChanged) // validation requried?
{
this.inputChanged = false;
this.validateInput(event, true);
}
this.displayValueRef.current!.style.display = "block";
}
onInputFocus(event: SyntheticEvent): void {
this.displayValueRef.current!.style.display = "none";
if (Utility.hasIMEKeyboard()) {
event.preventDefault();
event.stopPropagation();
this.inputRef.current?.blur();
this.props.requestIMEEdit(nullCast(this.props.uiControl), this.props.value)
}
}
onInputKeyPress(e: any): void {
if (e.charCode === 13 && this.inputChanged) {
this.inputChanged = false;
this.validateInput(e, true);
}
}
onInputChange(event: any): void {
this.inputChanged = true;
this.validateInput(event, false);
}
validateInput(event: any, commitValue: boolean) {
if (!this.inputRef.current) return;
let text = this.inputRef.current.value;
let valid = false;
let result: number = this.currentValue;
try {
if (text.length === 0) {
valid = false;
} else {
let v = Number(text);
if (isNaN(v)) {
valid = false;
} else {
valid = true;
result = v;
}
}
} catch (error) {
valid = false;
}
if (commitValue) {
this.setState({ error: false });
if (!valid) {
result = this.currentValue; // reset the value!
}
// clamp and quantize.
let range = this.valueToRange(result);
result = this.rangeToValue(range);
let displayVal = this.props.uiControl?.formatShortValue(result) ?? "";
if (event.currentTarget) {
event.currentTarget.value = displayVal;
}
this.previewInputValue(result, true);
this.inputRef.current.value = displayVal; // no rerender because the value won't change.
} else {
this.setState({ error: !valid });
if (valid) {
this.previewInputValue(result, false);
}
}
}
startX: number = 0;
startY: number = 0;
mouseDown: boolean = false;
onDrag(e: SyntheticEvent) {
e.preventDefault();
}
isValidPointer(e: PointerEvent<SVGSVGElement>): boolean {
if (e.pointerType === "mouse") {
return e.button === 0;
} else if (e.pointerType === "pen") {
return true;
} else if (e.pointerType === "touch") {
return true;
}
return false;
}
touchDown: boolean = false;
touchIdentifier: number = -1;
private isTap: boolean = false;
onTouchStart(e: TouchEvent<SVGSVGElement>) {
}
onTouchMove(e: TouchEvent<SVGSVGElement>) {
// e.preventDefault();
e.stopPropagation(); // cancels scroll!!!
}
capturedPointers: number[] = [];
onBodyPointerDownCapture(e_: any): any {
let e = e_ as PointerEvent;
if (this.isExtraTouch(e)) {
this.isTap = false;
this.captureElement!.setPointerCapture(e.pointerId);
this.capturedPointers.push(e.pointerId);
++this.pointersDown;
}
}
pointerId: number = 0;
pointerType: string = "";
isCapturedPointer(e: PointerEvent<SVGSVGElement>): boolean {
return this.mouseDown
&& e.pointerId === this.pointerId
&& e.pointerType === this.pointerType;
}
lastX: number = 0;
lastY: number = 0;
dRange: number = 0;
pointersDown: number = 0;
captureElement?: SVGSVGElement = undefined;
onPointerDown(e: PointerEvent<SVGSVGElement>): void {
if (!this.mouseDown && this.isValidPointer(e)) {
e.preventDefault();
e.stopPropagation();
if (this.isTouchDevice()) {
if (this.props.uiControl?.isDial() ?? false) {
this.isTap = false;
this.showZoomedControl();
return;
}
}
++this.pointersDown;
this.mouseDown = true;
if (this.pointersDown === 1) {
this.isTap = true;
this.tapStartMs = Date.now();
} else {
this.isTap = false;
}
this.pointerId = e.pointerId;
this.pointerType = e.pointerType;
this.startX = e.clientX;
this.startY = e.clientY;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.dRange = 0;
let img = this.imgRef.current;
if (img) {
this.captureElement = img;
document.body.addEventListener(
"pointerdown",
this.onBodyPointerDownCapture, true
);
img.setPointerCapture(e.pointerId);
if (img.style) {
img.style.opacity = "" + SELECTED_OPACITY;
}
}
} else {
if (this.isExtraTouch(e)) {
++this.pointersDown;
this.isTap = false;
}
}
}
isExtraTouch(e: PointerEvent): boolean {
return (this.mouseDown && this.pointerType === "touch" && e.pointerType === "touch" && e.pointerId !== this.pointerId);
}
onPointerLostCapture(e: PointerEvent<SVGSVGElement>) {
if (this.isCapturedPointer(e)) {
--this.pointersDown;
this.isTap = false;
this.releaseCapture(e);
}
}
updateRange(e: PointerEvent<SVGSVGElement>): number {
let ultraHigh = false;
let high = false;
if (e.ctrlKey) {
ultraHigh = true;
}
if (e.shiftKey) {
high = true;
}
if (e.pointerType === "touch") {
if (this.pointersDown >= 3) {
ultraHigh = true;
} else if (this.pointersDown === 2) {
high = true;
}
}
if (ultraHigh) {
this.dRange += (this.lastY - e.clientY) / ULTRA_FINE_RANGE_SCALE;
} else if (high) {
this.dRange += (this.lastY - e.clientY) / FINE_RANGE_SCALE;
} else {
this.dRange += (this.lastY - e.clientY) / RANGE_SCALE;
}
this.lastY = e.clientY;
this.lastX = e.clientX;
return this.dRange;
}
private lastTapMs = 0;
resetToDefaultValue(uiControl: UiControl): void {
let value = uiControl.default_value;
this.model.setPedalboardControl(this.props.instanceId, uiControl.symbol, value);
}
onPointerDoubleTap() {
let uiControl = this.props.uiControl;
if (uiControl) {
if (uiControl.isDial()) {
this.resetToDefaultValue(uiControl);
}
}
}
onPointerTap() {
let tapTime = Date.now();
let dT = tapTime - this.lastTapMs;
this.lastTapMs = tapTime;
if (dT < 500) {
this.onPointerDoubleTap();
}
}
private tapStartMs: number = 0;
onPointerUp(e: PointerEvent<SVGSVGElement>) {
if (this.isCapturedPointer(e)) {
--this.pointersDown;
e.preventDefault();
let dRange = this.updateRange(e)
this.previewRange(dRange, true);
this.releaseCapture(e);
if (this.isTap) {
let ms = Date.now() - this.tapStartMs;
if (ms < 200) {
this.onPointerTap();
}
}
} else {
--this.pointersDown;
}
}
releaseCapture(e: PointerEvent<SVGSVGElement>) {
let img = this.imgRef.current;
if (img && img.style) {
img.releasePointerCapture(e.pointerId);
img.style.opacity = "" + DEFAULT_OPACITY;
// they get automaticlly released.
// for (let i = 0; i < this.capturedPointers.length; ++i)
// {
// img.releasePointerCapture (this.capturedPointers[i]);
// }
this.capturedPointers = [];
}
document.body.removeEventListener(
"pointerdown",
this.onBodyPointerDownCapture, true
);
this.mouseDown = false;
}
clickSlop() {
return 3.5; // maybe larger on touch devices.
}
onPointerMove(e: PointerEvent<SVGSVGElement>): void {
if (this.isCapturedPointer(e)) {
e.preventDefault();
let dRange = this.updateRange(e)
this.previewRange(dRange, false);
let x = e.clientX;
let y = e.clientY;
let dx = x - this.startX;
let dy = y - this.startY;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= this.clickSlop()) {
this.isTap = false;
}
}
}
handleTriggerMouseDown() {
let uiControl = this.props.uiControl;
if (uiControl) {
let value = uiControl.max_value;
if (uiControl.max_value === uiControl.default_value) {
value = uiControl.min_value;
}
this.model.sendPedalboardControlTrigger(this.props.instanceId, uiControl.symbol, value);
}
}
handleTriggerMouseUp() {
// triggers are reset on the audio thread.
}
previewInputValue(value: number, commitValue: boolean) {
let range = this.valueToRange(value);
value = this.rangeToValue(range);
let transform = this.rangeToRotationTransform(range);
if (this.mouseDown && !commitValue) {
transform += " scale(1.5, 1.5)";
}
let imgElement = this.imgRef.current
if (imgElement) {
if (imgElement.style) {
imgElement.style.transform = transform;
}
}
if (commitValue) {
this.props.onChange(value);
}
if (this.props.onPreviewChange) {
this.props.onPreviewChange(value);
}
}
previewRange(dRange: number, commitValue: boolean): void {
let range = this.valueToRange(this.currentValue) + dRange;
if (range > 1) range = 1;
if (range < 0) range = 0;
let value = this.rangeToValue(range);
// apply value quantization and clipping.
range = this.valueToRange(value);
let imgElement = this.imgRef.current
if (imgElement) {
if (imgElement.style) {
imgElement.style.transform = this.rangeToRotationTransform(range);
}
}
let inputElement = this.inputRef.current;
if (inputElement) {
let v = this.props.uiControl?.formatShortValue(value) ?? "";
inputElement.value = v;
}
let displayValue = this.displayValueRef.current;
if (displayValue) {
let v = this.formatDisplayValue(this.props.uiControl, value);
displayValue.childNodes[0].textContent = v;
}
let selectElement = this.selectRef.current;
if (selectElement) {
//let v = this.formatValue(this.props.uiControl,value);
selectElement.selectedIndex = value;
}
if (commitValue) {
this.currentValue = value;
this.props.onChange(value);
} else {
if (this.props.onPreviewChange) {
this.props.onPreviewChange(value);
}
}
}
onCheckChanged(checked: boolean): void {
this.props.onChange(checked ? 1 : 0);
}
onSelectChanged(e: any, value: any) {
let target = e.target;
setTimeout(() => {
this.props.onChange(target.value);
}, 0);
}
makeSelect(control: UiControl, value: number): ReactNode {
if (control.isOnOffSwitch()) {
// normal gray unchecked state.
return (
<Switch checked={value !== 0} color="primary"
onChange={(event) => {
this.onCheckChanged(event.target.checked);
}}
/>
);
}
if (control.isAbToggle()) {
let classes = withStyles.getClasses(this.props);
// unchecked color is not gray.
return (
<Switch checked={value !== 0} color="primary"
onChange={(event) => {
this.onCheckChanged(event.target.checked);
}}
classes={{
track: classes.switchTrack
}}
style={{ color: this.props.theme.palette.primary.main }}
/>
);
} else {
return (
<Select variant="standard"
ref={this.selectRef}
value={control.clampSelectValue(value)}
onChange={this.onSelectChanged}
inputProps={{
name: control.name,
id: 'id' + control.symbol,
style: { fontSize: FONT_SIZE }
}}
style={{ marginLeft: 4, marginRight: 4, width: 140, fontSize: 14 }}
>
{control.scale_points.map((scale_point: ScalePoint) => (
<MenuItem key={scale_point.value} value={scale_point.value}>{scale_point.label}</MenuItem>
))}
</Select>
);
}
}
formatDisplayValue(uiControl: UiControl | undefined, value: number): string {
if (!uiControl) return "";
return uiControl.formatDisplayValue(value);
}
valueToRange(value: number): number {
let uiControl = this.props.uiControl;
if (uiControl) {
if (uiControl.integer_property) {
value = Math.round(value);
}
let range: number;
if (uiControl.is_logarithmic) {
let minValue = uiControl.min_value;
if (minValue === 0) // LSP plugins do this.
{
minValue = 0.0001;
}
range = Math.log(value / minValue) / Math.log(uiControl.max_value / minValue);
if (!isFinite(range)) {
if (range < 0)
{
range = 0;
} else {
range = 1.0;
}
} else if (isNaN(range)) {
range = 0;
}
if (uiControl.range_steps > 1) {
range = Math.round(range * (uiControl.range_steps - 1)) / (uiControl.range_steps - 1);
}
} else {
range = (value - uiControl.min_value) / (uiControl.max_value - uiControl.min_value);
if (!isFinite(range)) {
if (range < 0)
{
range = 0;
} else {
range = 1.0;
}
} else if (isNaN(range)) {
range = 0;
}
}
if (range > 1) range = 1;
if (range < 0) range = 0;
return range;
}
return 0;
}
rangeToValue(range: number): number {
if (range < 0) range = 0;
if (range > 1) range = 1;
let uiControl = this.props.uiControl;
if (uiControl) {
if (uiControl.range_steps > 1) {
range = Math.round(range * (uiControl.range_steps - 1)) / (uiControl.range_steps - 1);
}
let value: number;
if (uiControl.min_value === uiControl.max_value) {
value = uiControl.min_value;
} else {
if (uiControl.is_logarithmic) {
let minValue = uiControl.min_value;
if (minValue === 0) // LSP controls.
{
minValue = 0.0001;
}
value = minValue * Math.pow(uiControl.max_value / minValue, range);
if (!isFinite(value)) {
value = uiControl.max_value;
} else if (isNaN(value)) {
value = uiControl.min_value;
}
} else {
value = range * (uiControl.max_value - uiControl.min_value) + uiControl.min_value;
}
if (uiControl.integer_property) {
value = Math.round(value);
}
}
return value;
}
return 0;
}
rangeToRotationTransform(range: number): string {
let angle = range * (MAX_ANGLE - MIN_ANGLE) + MIN_ANGLE;
return "rotate(" + angle + "deg)";
}
getRotationTransform(): string {
let range = 0;
let uiControl = this.props.uiControl;
if (uiControl) {
let value = this.props.value;
range = this.valueToRange(value);
}
return this.rangeToRotationTransform(range);
}
currentValue: number = 0;
uiControl?: UiControl = undefined;
render() {
const classes = withStyles.getClasses(this.props);
let t = this.props.uiControl;
if (!t) {
return (<div>#Error</div>);
}
let dialColor = this.props.theme.palette.text.primary;
let control: UiControl = t;
this.uiControl = control;
let value = this.props.value;
this.currentValue = value;
let switchText = "";
let isSelect = control.isSelect();
let isAbSwitch = control.isAbToggle();
let isOnOffSwitch = control.isOnOffSwitch();
let isTrigger = control.isTrigger();
if (isAbSwitch) {
switchText = control.scale_points[0].value === value ? control.scale_points[0].label : control.scale_points[1].label;
}
let item_width: number | undefined = isSelect ? 160 : 80;
if (isTrigger) {
item_width = undefined;
}
return (
<div ref={this.frameRef}
className={classes.controlFrame}
style={{ width: item_width }}
>
{/* TITLE SECTION */}
<div className={classes.titleSection}
style={
{
alignSelf: "stretch", marginBottom: 8, marginLeft: isSelect ? 8 : 0, marginRight: 0
}}>
<ControlTooltip uiControl={control} >
<Typography variant="caption" display="block" noWrap style={{
width: "100%",
textAlign: isSelect ? "left" : "center"
}}> {isTrigger ? "\u00A0" : control.name}</Typography>
</ControlTooltip>
</div>
{/* CONTROL SECTION */}
<div className={classes.midSection}>
{isTrigger ?
(
control.name.length !== 1 ? (
<Button variant="contained" color="primary" size="small"
onMouseDown={
(evt) => { this.handleTriggerMouseDown(); }
}
onMouseUp={
(evt) => { this.handleTriggerMouseUp(); }
}
style={{
textTransform: "none",
background: (isDarkMode() ? "#6750A4" : undefined),
marginLeft: 8, marginRight: 8, minWidth: 60,
marginTop: 0
}}
>
{control.name}
</Button>
) : (
<Button variant="contained" color="primary" size="small"
onMouseDown={
(evt) => { this.handleTriggerMouseDown(); }
}
onMouseUp={
(evt) => { this.handleTriggerMouseUp(); }
}
style={{
textTransform: "none",
background: (isDarkMode() ? "#6750A4" : undefined),
marginLeft: 8, marginRight: 8,
paddingLeft: 0, paddingRight: 0,
width: 36, height: 36,
marginTop: 0,
borderRadius: 8,
minWidth: 0,
fontSize: "1.2em"
}}
>
{control.name}
</Button>
)
)
: ((isSelect || isAbSwitch || isOnOffSwitch) ? (
this.makeSelect(control, value)
) : (
<div style={{ flex: "0 1 auto" }}>
<DialIcon ref={this.imgRef}
style={{
overscrollBehavior: "none", touchAction: "none", fill: dialColor,
width: 36, height: 36, opacity: DEFAULT_OPACITY, transform: this.getRotationTransform()
}}
onTouchStart={this.onTouchStart} onTouchMove={this.onTouchMove}
onPointerDown={this.onPointerDown} onPointerUp={this.onPointerUp} onPointerMoveCapture={this.onPointerMove} onDrag={this.onDrag}
/>
</div>
)
)
}
</div>
{/* LABEL/EDIT SECTION*/}
<div className={classes.editSection} >
{(!(isSelect || isOnOffSwitch || isTrigger)) &&
(
(isAbSwitch) ? (
<Typography variant="caption" display="block" textAlign="center" noWrap style={{
width: "100%"
}}> {switchText} </Typography>
) : (
<div>
<Input key={value}
type="number"
defaultValue={control.formatShortValue(value)}
error={this.state.error}
inputProps={{
'aria-label':
control.symbol + " value",
style: { textAlign: "center", fontSize: FONT_SIZE },
}}
inputRef={this.inputRef} onChange={this.onInputChange}
onBlur={this.onInputLostFocus}
onFocus={this.onInputFocus}
onKeyPress={this.onInputKeyPress} />
<div className={classes.displayValue} ref={this.displayValueRef} onClick={(e) => { this.inputRef.current!.focus(); }} >
<Typography noWrap color="inherit" style={{ fontSize: "12.8px", paddingTop: 4, paddingBottom: 6 }}
>
{this.formatDisplayValue(control, value)}</Typography>
</div>
</div>
)
)
}
</div>
</div >
);
}
/*
isSamePointer(PointerEvent<SVGSVGElement> e): boolean
{
return e.pointerId === this.pointerId
&& e.pointerType === this.pointerType;
}
*/
touchPointerId: number = 0;
},
pluginControlStyles
));
export default PluginControl;
+818
View File
@@ -0,0 +1,818 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { ReactNode } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles, {withTheme} from './WithStyles';
import {createStyles} from './WithStyles';
import { css } from '@emotion/react';
import { withStyles } from "tss-react/mui";
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { UiPlugin, UiControl, UiFileProperty, UiFrequencyPlot, ScalePoint } from './Lv2Plugin';
import {
Pedalboard, PedalboardItem, ControlValue
} from './Pedalboard';
import PluginControl from './PluginControl';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import VuMeter from './VuMeter';
import { nullCast } from './Utility'
import { PiPedalStateError } from './PiPedalError';
import Typography from '@mui/material/Typography';
import FullScreenIME from './FullScreenIME';
import FilePropertyControl from './FilePropertyControl';
import FilePropertyDialog from './FilePropertyDialog';
import JsonAtom from './JsonAtom';
import PluginOutputControl from './PluginOutputControl';
import Units from './Units';
import ToobFrequencyResponseView from './ToobFrequencyResponseView';
import Tooltip from '@mui/material/Tooltip';
import MidiChannelBindingControl from './MidiChannelBindingControl';
import MidiChannelBinding from './MidiChannelBinding';
export const StandardItemSize = { width: 80, height: 110 };
const LANDSCAPE_HEIGHT_BREAK = 500;
function makeIoPluginInfo(name: string, uri: string): UiPlugin {
let result = new UiPlugin();
result.name = name;
result.uri = uri;
let volumeControl = new UiControl();
volumeControl.name = "Volume";
volumeControl.symbol = "volume_db";
volumeControl.index = 0;
volumeControl.is_input = true;
volumeControl.min_value = -60;
volumeControl.max_value = 30;
volumeControl.default_value = 0;
volumeControl.units = Units.db;
volumeControl.scale_points = [
new ScalePoint().deserialize({ label: "-INF", value: -60 })
];
result.controls = [
volumeControl
];
return result;
}
let startPluginInfo: UiPlugin =
makeIoPluginInfo("Input", Pedalboard.START_PEDALBOARD_ITEM_URI);
let endPluginInfo: UiPlugin =
makeIoPluginInfo("Output", Pedalboard.END_PEDALBOARD_ITEM_URI);
const styles = (theme: Theme) => createStyles({
frame: css({
display: "block",
position: "relative",
flexDirection: "row",
flexWrap: "nowrap",
paddingTop: "8px",
paddingBottom: "0px",
height: "100%",
overflowX: "hidden",
overflowY: "hidden"
}),
frameScrollLandscape: css({
display: "block",
position: "absolute",
left: 0, top: 0, right: 0, bottom:0,
flexDirection: "row",
flexWrap: "nowrap",
paddingTop: "0px",
paddingBottom: "0px",
overflowX: "auto",
overflowY: "hidden"
}),
frameScrollPortrait: css({
display: "block",
position: "absolute",
left: 0, top: 0, right: 0, bottom:0,
flexDirection: "row",
flexWrap: "nowrap",
paddingTop: "0px",
paddingBottom: "0px",
overflowX: "hidden",
overflowY: "auto"
}),
vuMeterL: css({
position: "absolute",
left: 0, top: 0,
paddingLeft: 6,
paddingRight: 4,
paddingBottom: 12, // cover bottom line of a portgroup in landscape.
background: theme.mainBackground,
zIndex: 3
}),
vuMeterR: css({
position: "absolute",
right: 0, top: 0,
marginRight: 20, // has to potentially clear a scrollbar.
paddingLeft: 4,
paddingBottom: 12,
background: theme.mainBackground,
zIndex: 3
}),
vuMeterRLandscape: css({
position: "absolute",
right: 0, top: 0,
paddingRight: 6,
paddingLeft: 12,
paddingBottom: 12, // cover bottom line of a portgroup in landscape.
background: theme.mainBackground,
zIndex: 3
}),
normalGrid: css({
position: "relative",
paddingLeft: 30,
paddingRight: 30,
paddingTop: 8,
flex: "1 1 auto",
display: "flex", flexDirection: "row", flexWrap: "wrap",
justifyContent: "flex-start", alignItems: "flex_start",
rowGap: 14,
height: "fit-content"
}),
landscapeGrid: css({
paddingLeft: 40, paddingRight: 40, paddingTop: 8,
// marginRight: 40, : bug in chrome layout engine wrt/ right margin/padding.
// See the spacer div added after all controls in render() with provides the same effect.
display: "flex", flexDirection: "row", flexWrap: "nowrap",
justifyContent: "flex-start", alignItems: "flex-start",
overflowX: "hidden",
overflowY: "hidden",
flex: "0 0 auto",
width: "fit-content"
}),
portgroupControlPadding: css({
flex: "0 0 auto",
marginTop: 12,
marginBottom: 8
}),
controlPadding: css({
flex: "0 0 auto",
marginTop: 0,
marginBottom: 0,
height: 116
}),
controlPair: css({
display: "flex", flexFlow: "row nowrap",
flex: "0 0 auto",
height: 116
}),
controlSpacer: css({
display: "none",
minWidth: 0,
width: 0,
height: 116
}),
portGroup: css({
marginLeft: 8,
marginTop: 0,
marginRight: 8,
marginBottom: 12,
position: "relative",
paddingLeft: 3,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
border: "2pt #AAA solid",
borderRadius: 8,
elevation: 12,
display: "flex",
flexDirection: "row", flexWrap: "wrap",
flex: "0 1 auto",
}),
portGroupLandscape: css({
marginLeft: 8,
marginTop: 0,
marginRight: 8,
marginBottom: 12,
position: "relative",
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
border: "2pt #AAA solid",
borderRadius: 8,
elevation: 12,
display: "inline-flex",
textOverflow: "ellipsis",
flexDirection: "row", flexWrap: "nowrap",
flex: "0 0 auto",
width: "fit-content",
minWidth: "max-content"
}),
portGroupTitle: css({
position: "absolute",
top: -15,
background: theme.mainBackground,
textOverflow: "ellipsis",
minWidth: 0,
marginLeft: 20,
paddingLeft: 8,
paddingRight: 8,
margin_right: 28
}),
portGroupControls: css({
display: "flex",
flexDirection: "row", flexWrap: "wrap",
paddingTop: 6,
paddingBottom: 8
}),
portGroupControlsLandscape: css({
display: "flex",
flexFlow: "row nowrap",
width: "fit-content",
paddingTop: 6,
paddingBottom: 8
})
});
export class ControlGroup {
constructor(name: string, indexes: number[], controls: ReactNode[]) {
this.name = name;
this.indexes = indexes;
this.controls = controls;
}
name: string;
indexes: number[];
controls: React.ReactNode[];
}
export type ControlNodes = (ReactNode | ControlGroup)[];
export interface ControlViewCustomization {
ModifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[];
}
export interface PluginControlViewProps extends WithStyles<typeof styles> {
theme: Theme;
instanceId: number;
item: PedalboardItem;
customization?: ControlViewCustomization;
customizationId?: number;
}
type PluginControlViewState = {
landscapeGrid: boolean;
imeUiControl?: UiControl;
imeValue: number;
imeCaption: string;
imeInitialHeight: number;
showFileDialog: boolean,
dialogFileProperty: UiFileProperty,
dialogFileValue: string
};
const PluginControlView =
withTheme(withStyles(
class extends ResizeResponsiveComponent<PluginControlViewProps, PluginControlViewState> {
model: PiPedalModel;
constructor(props: PluginControlViewProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
landscapeGrid: false,
imeUiControl: undefined,
imeValue: 0,
imeCaption: "",
imeInitialHeight: 0,
showFileDialog: false,
dialogFileProperty: new UiFileProperty(),
dialogFileValue: ""
}
this.onPedalboardChanged = this.onPedalboardChanged.bind(this);
this.onControlValueChanged = this.onControlValueChanged.bind(this);
this.onPreviewChange = this.onPreviewChange.bind(this);
}
onPreviewChange(key: string, value: number): void {
this.model.previewPedalboardValue(this.props.instanceId, key, value);
}
onControlValueChanged(key: string, value: number): void {
this.model.setPedalboardControl(this.props.instanceId, key, value);
}
onPedalboardChanged(value?: Pedalboard) {
//let item = this.model.pedalboard.get().maybeGetItem(this.props.instanceId);
//this.setState({ pedalboardItem: item });
}
componentDidMount() {
super.componentDidMount();
this.model.pedalboard.addOnChangedHandler(this.onPedalboardChanged);
}
componentWillUnmount() {
this.model.pedalboard.removeOnChangedHandler(this.onPedalboardChanged);
super.componentWillUnmount();
}
onWindowSizeChanged(width: number, height: number): void {
this.setState({ landscapeGrid: height < LANDSCAPE_HEIGHT_BREAK });
}
filterNotOnGui(controlValues: ControlValue[], uiPlugin: UiPlugin): ControlValue[] {
let result: ControlValue[] = [];
for (let i = 0; i < controlValues.length; ++i) {
let controlValue = controlValues[i];
let control = uiPlugin.getControl(controlValue.key);
if (control && !control.isHidden()) {
result.push(controlValue);
}
}
return result;
}
requestImeEdit(uiControl: UiControl, value: number) {
// eslint-disable-next-line no-restricted-globals
this.setState({
imeUiControl: uiControl,
imeValue: value,
imeCaption: uiControl.name,
imeInitialHeight: window.innerHeight
});
}
makeFilePropertyUI(fileProperty: UiFileProperty): ReactNode {
return ((
<FilePropertyControl pedalboardItem={this.props.item}
fileProperty={fileProperty}
onFileClick={(fileProperty, selectedFile) => {
this.setState({ showFileDialog: true, dialogFileProperty: fileProperty, dialogFileValue: selectedFile });
}}
/>
));
}
makeFrequencyPlotUI(frequencyPlot: UiFrequencyPlot): ReactNode {
return ((
<ToobFrequencyResponseView instanceId={this.props.instanceId}
propertyName={frequencyPlot.patchProperty}
width={frequencyPlot.width}
/>
));
}
makeStandardControl(uiControl: UiControl, controlValues: ControlValue[]): ReactNode {
let symbol = uiControl.symbol;
if (!uiControl.is_input) {
return (
<PluginOutputControl instanceId={this.props.instanceId} uiControl={uiControl} />
);
}
let controlValue: ControlValue | undefined = undefined;
for (let i = 0; i < controlValues.length; ++i) {
if (controlValues[i].key === symbol) {
controlValue = controlValues[i];
break;
}
}
if (!controlValue) {
throw new PiPedalStateError("Missing control value.");
}
return ((
<PluginControl instanceId={this.props.instanceId} uiControl={uiControl} value={controlValue.value}
onChange={(value: number) => { this.onControlValueChanged(controlValue!.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(controlValue!.key, value) }}
requestIMEEdit={(uiControl: any, value: any) => this.requestImeEdit(uiControl, value)}
/>
));
}
push_control(controls:ControlNodes, pluginControl: UiControl,controlValues: ControlValue[])
{
// combine lamps with their previous control
if ((pluginControl.isLamp() || pluginControl.isDbVu()) && controls.length !== 0)
{
const classes = withStyles.getClasses(this.props);
let newControl = this.makeStandardControl(pluginControl,controlValues);
let previousControl = controls[controls.length-1];
if (!(previousControl instanceof ControlGroup)) {
let pair = (
<div className={classes.controlPair}>
{previousControl as ReactNode}
{newControl}
</div>
);
controls[controls.length-1] = pair;
// push a spacer control in order to make placing of extended controls predictable.
// (e.g.. inserting at position 4 still places the extended control after four previous controls
controls.push((
<div className={classes.controlSpacer} />
));
} else {
controls.push(newControl);
}
} else {
controls.push(
this.makeStandardControl(pluginControl, controlValues)
)
}
}
getStandardControlNodes(plugin: UiPlugin, controlValues: ControlValue[]): ControlNodes {
let result: ControlNodes = [];
let portGroupMap: { [id: string]: ControlGroup } = {};
for (let i = 0; i < plugin.controls.length; ++i) {
let pluginControl = plugin.controls[i];
if (!pluginControl.isHidden()) {
if (pluginControl.port_group !== "" && plugin.getPortGroupBySymbol(pluginControl.port_group)) {
let portGroup = nullCast(plugin.getPortGroupBySymbol(pluginControl.port_group));
let groupControls: ReactNode[] = [];
let indexes: number[] = [];
groupControls.push(
this.makeStandardControl(pluginControl, controlValues)
);
indexes.push(pluginControl.index);
while (i + 1 < plugin.controls.length && plugin.controls[i + 1].port_group === pluginControl.port_group) {
++i;
pluginControl = plugin.controls[i];
if (!pluginControl.isHidden()) {
this.push_control(groupControls,pluginControl,controlValues);
indexes.push(pluginControl.index);
}
}
let controlGroup = new ControlGroup(portGroup.name, indexes, groupControls);
result.push(
controlGroup
)
portGroupMap[pluginControl.port_group] = controlGroup;
} else {
this.push_control(result,pluginControl,controlValues);
}
}
}
for (let i = 0; i < plugin.fileProperties.length; ++i) {
let fileProperty = plugin.fileProperties[i];
let filePropertyUi = this.makeFilePropertyUI(fileProperty);
if (fileProperty.portGroup !== "" && plugin.getPortGroupByUri(fileProperty.portGroup)) {
let portGroup = nullCast(plugin.getPortGroupByUri(fileProperty.portGroup));
let controlGroup = portGroupMap[portGroup.symbol];
if (controlGroup) {
let insertPosition = controlGroup.indexes.length;
if (fileProperty.index !== -1) {
for (let i = 0; i < controlGroup.controls.length; ++i) {
if (controlGroup.indexes[i] >= fileProperty.index) {
insertPosition = i;
break;
}
}
}
let index = fileProperty.index !== -1 ? fileProperty.index : 100;
controlGroup.controls.splice(insertPosition, 0, filePropertyUi);
controlGroup.indexes.splice(insertPosition, 0, index);
} else {
let index = fileProperty.index !== -1 ? fileProperty.index : 100;
let controlGroup = new ControlGroup(
portGroup.name,
[index],
[filePropertyUi]);
result.push(
controlGroup
);
portGroupMap[portGroup.symbol] = controlGroup;
}
} else if (fileProperty.index !== -1) {
result.splice(fileProperty.index, 0, filePropertyUi);
} else {
result.push(
filePropertyUi
);
}
}
for (let i = 0; i < plugin.frequencyPlots.length; ++i) {
let frequencyPlot = plugin.frequencyPlots[i];
let frequencyPlotUi = this.makeFrequencyPlotUI(frequencyPlot);
if (frequencyPlot.portGroup !== "" && plugin.getPortGroupByUri(frequencyPlot.portGroup)) {
let portGroup = nullCast(plugin.getPortGroupByUri(frequencyPlot.portGroup));
let controlGroup = portGroupMap[portGroup.symbol];
if (controlGroup) {
let insertPosition = controlGroup.indexes.length;
if (frequencyPlot.index !== -1) {
for (let i = 0; i < controlGroup.controls.length; ++i) {
if (controlGroup.indexes[i] >= frequencyPlot.index) {
insertPosition = i;
break;
}
}
}
let index = frequencyPlot.index !== -1 ? frequencyPlot.index : 100;
controlGroup.controls.splice(insertPosition, 0, frequencyPlotUi);
controlGroup.indexes.splice(insertPosition, 0, index);
} else {
let index = frequencyPlot.index !== -1 ? frequencyPlot.index : 100;
let controlGroup = new ControlGroup(
portGroup.name,
[index],
[frequencyPlotUi]);
result.push(
controlGroup
);
portGroupMap[portGroup.symbol] = controlGroup;
}
} else if (frequencyPlot.index !== -1) {
result.splice(frequencyPlot.index, 0, frequencyPlotUi);
} else {
result.push(
frequencyPlotUi
);
}
}
return result;
}
getControl(controlValues: ControlValue[], key: string) {
for (let i = 0; i < controlValues.length; ++i) {
if (controlValues[i].key === key) {
return controlValues[i];
}
}
throw new Error("Not found.");
}
onImeValueChange(key: string, value: number) {
this.model.setPedalboardControl(this.props.instanceId, key, value);
this.onImeClose();
}
onImeClose() {
this.setState({
imeUiControl: undefined
});
}
hasGroups(nodes: (ReactNode | ControlGroup)[]): boolean {
for (let i = 0; i < nodes.length; ++i) {
let node = nodes[i];
if (node instanceof ControlGroup) return true;
}
return false;
}
controlKeyIndex: number = 0;
controlNodesToNodes(nodes: (ReactNode | ControlGroup)[]): ReactNode[] {
const classes = withStyles.getClasses(this.props);
let isLandscapeGrid = this.state.landscapeGrid;
let hasGroups = this.hasGroups(nodes);
let result: ReactNode[] = [];
for (let i = 0; i < nodes.length; ++i) {
let node = nodes[i];
if (node instanceof ControlGroup) {
let controlGroup = node as ControlGroup;
let controls: ReactNode[] = [];
for (let j = 0; j < controlGroup.controls.length; ++j) {
let item = controlGroup.controls[j];
controls.push(
(
<div key={"ctl" + (this.controlKeyIndex++)} className={classes.controlPadding}>
{item}
</div>
)
);
}
result.push((
<div key={"ctl" + (this.controlKeyIndex++)} className={!isLandscapeGrid ? classes.portGroup : classes.portGroupLandscape}>
<div className={classes.portGroupTitle}>
<Tooltip title={controlGroup.name}
placement="top-start" arrow enterDelay={1500} enterNextDelay={1500}
>
<Typography noWrap variant="caption" >{controlGroup.name}</Typography>
</Tooltip>
</div>
<div className={
this.state.landscapeGrid ? classes.portGroupControlsLandscape : classes.portGroupControls} >
{
controls
}
</div>
</div>
));
} else {
result.push((
<div key={"ctl" + (this.controlKeyIndex++)} className={hasGroups ? classes.portgroupControlPadding : classes.controlPadding} >
{node as ReactNode}
</div>
));
}
}
return result;
}
static startPluginInfo: UiPlugin =
makeIoPluginInfo("Input", Pedalboard.START_PEDALBOARD_ITEM_URI);
static endPluginInfo: UiPlugin =
makeIoPluginInfo("Output", Pedalboard.END_PEDALBOARD_ITEM_URI);
midiBindingControl(pedalboardItem: PedalboardItem): ReactNode {
if (!pedalboardItem.midiChannelBinding) {
return false;
}
return (
<MidiChannelBindingControl key="channelBindingCtl" midiChannelBinding={pedalboardItem.midiChannelBinding}
onChange={(result)=> {
}}
/>
)
}
render(): ReactNode {
this.controlKeyIndex = 0;
const classes = withStyles.getClasses(this.props);
let pedalboardItem: PedalboardItem;
let pedalboard = this.model.pedalboard.get();
if (this.props.instanceId === Pedalboard.START_CONTROL) {
pedalboardItem = pedalboard.makeStartItem();
} else if (this.props.instanceId === Pedalboard.END_CONTROL) {
pedalboardItem = pedalboard.makeEndItem();
} else {
pedalboardItem = pedalboard.getItem(this.props.instanceId);
}
if (!pedalboardItem)
return (<div className={classes.frame} ></div>);
let controlValues = pedalboardItem.controlValues;
let plugin: UiPlugin;
if (pedalboardItem.isStart()) {
plugin = startPluginInfo;
controlValues = [new ControlValue("volume_db", pedalboard.input_volume_db)];
} else if (pedalboardItem.isEnd()) {
plugin = endPluginInfo;
controlValues = [new ControlValue("volume_db", pedalboard.output_volume_db)];
} else {
plugin = nullCast(this.model.getUiPlugin(pedalboardItem.uri));
}
controlValues = this.filterNotOnGui(controlValues, plugin);
let gridClass = this.state.landscapeGrid ? classes.landscapeGrid : classes.normalGrid;
let scrollClass = this.state.landscapeGrid ? classes.frameScrollLandscape : classes.frameScrollPortrait;
let vuMeterRClass = this.state.landscapeGrid ? classes.vuMeterRLandscape : classes.vuMeterR;
let controlNodes: ControlNodes;
controlNodes = this.getStandardControlNodes(plugin, controlValues);
if (this.props.customization) {
// allow wrapper class to insert/remove/rebuild controls.
controlNodes = this.props.customization.ModifyControls(controlNodes);
}
let nodes = this.controlNodesToNodes(controlNodes);
if (plugin.has_midi_input && !pedalboardItem.midiChannelBinding)
{
pedalboardItem.midiChannelBinding = MidiChannelBinding.CreateMissingValue();
}
if (pedalboardItem.midiChannelBinding) {
nodes.push(this.midiBindingControl(pedalboardItem));
}
return (
<div className={classes.frame}>
<div className={classes.vuMeterL}>
<VuMeter displayText={true} display="input" instanceId={pedalboardItem.instanceId} />
</div>
<div className={vuMeterRClass}>
<VuMeter displayText={true} display="output" instanceId={pedalboardItem.instanceId} />
</div>
<div className={scrollClass}>
<div className={gridClass} >
{
nodes
}
{/* Extra space to allow scrolling right to the end in lascape especially */}
<div style={{ flex: "0 0 40px", width: 40, height: 40 }} />
{
(!this.state.landscapeGrid) && (
<div style={{ flex: "0 1 100%", width: "0px", height: 40 }} />
)
}
</div>
</div>
{this.state.showFileDialog && (
<FilePropertyDialog open={this.state.showFileDialog}
fileProperty={this.state.dialogFileProperty}
instanceId={this.props.instanceId}
selectedFile={this.state.dialogFileValue}
onCancel={() => {
this.setState({ showFileDialog: false });
}}
onOk={(fileProperty, selectedFile) => {
this.model.setPatchProperty(
this.props.instanceId,
fileProperty.patchProperty,
JsonAtom.Path(selectedFile)
)
.then(() => {
})
.catch((error) => {
this.model.showAlert("Unable to complete the operation. Audio is not running." + error);
});
this.setState({ showFileDialog: false });
}
}
/>
)}
<FullScreenIME uiControl={this.state.imeUiControl} value={this.state.imeValue}
onChange={(key, value) => this.onImeValueChange(key, value)}
initialHeight={
this.state.imeInitialHeight}
caption={this.state.imeCaption}
onClose={() => this.onImeClose()} />
</div >
);
}
},
styles
));
export default PluginControlView;
+290
View File
@@ -0,0 +1,290 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { PiPedalModelFactory } from "./PiPedalModel";
import { PluginType } from './Lv2Plugin';
import FxSimulatorIcon from './svg/fx_simulator.svg?react';
import FxPhaserIcon from './svg/fx_phaser.svg?react';
import FxFilterIcon from './svg/fx_filter.svg?react';
import FxDelayIcon from './svg/fx_delay.svg?react';
import FxAmplifierIcon from './svg/fx_amplifier.svg?react';
import FxChorusIcon from './svg/fx_chorus.svg?react';
import FxModulatorIcon from './svg/fx_modulator.svg?react';
import FxReverbIcon from './svg/fx_reverb.svg?react';
import FxDistortionIcon from './svg/fx_distortion.svg?react';
import FxFlangerIcon from './svg/fx_flanger.svg?react';
import FxFilterHpIcon from './svg/fx_filter_hp.svg?react';
import FxParametricEqIcon from './svg/fx_parametric_eq.svg?react';
import FxEqIcon from './svg/fx_eq.svg?react';
import FxUtilityIcon from './svg/fx_utility.svg?react';
import FxCompressorIcon from './svg/fx_compressor.svg?react';
import FxLimiterIcon from './svg/fx_limiter.svg?react';
import FxConstantIcon from './svg/fx_constant.svg?react';
import FxFunctionIcon from './svg/fx_function.svg?react';
import FxGateIcon from './svg/fx_gate.svg?react';
import FxConverterIcon from './svg/fx_converter.svg?react';
import FxGeneratorIcon from './svg/fx_generator.svg?react';
import FxOscillatorIcon from './svg/fx_oscillator.svg?react';
import FxInstrumentIcon from './svg/fx_instrument.svg?react';
import FxMixerIcon from './svg/fx_mixer.svg?react';
import FxPitchIcon from './svg/fx_pitch.svg?react';
import FxSpatialIcon from './svg/fx_spatial.svg?react';
import FxSpectralIcon from './svg/fx_spectral.svg?react';
import FxAnalyzerIcon from './svg/fx_analyzer.svg?react';
import FxPluginIcon from './svg/fx_plugin.svg?react';
import SplitAIcon from './svg/fx_split_a.svg?react';
import SplitBIcon from './svg/fx_split_b.svg?react';
import SplitMixIcon from './svg/fx_dial.svg?react';
import SplitLrIcon from './svg/fx_lr.svg?react';
import FxEmptyIcon from './svg/fx_empty.svg?react';
import FxTerminalIcon from './svg/fx_terminal.svg?react';
import { isDarkMode } from './DarkMode';
const styles = (theme: Theme) =>
createStyles({
icon: {
width: "24px",
height: "24px",
margin: "0px",
opacity: "0.6",
color: theme.palette.text.primary,
fill: theme.palette.text.primary
},
});
export interface PluginIconProps extends WithStyles<typeof styles> {
size?: number;
opacity?: number;
offsetY?: number;
pluginType: PluginType;
pluginMissing?: boolean;
}
export function SelectSvgIcon(plugin_type: PluginType, className: string, size: number = 24, opacity: number = 1.0, missingPlugin: boolean = false) {
let color = "";
if (missingPlugin) {
color = isDarkMode() ? "#C00000" : "#B00000";
}
let myStyle = { width: size, height: size, opacity: opacity, color: color, fill: color };
switch (plugin_type) {
case PluginType.PhaserPlugin:
return <FxPhaserIcon className={className} style={myStyle} />;
case PluginType.FilterPlugin:
return <FxFilterIcon className={className} style={myStyle} />;
case PluginType.DelayPlugin:
return <FxDelayIcon className={className} style={myStyle} />;
case PluginType.SimulatorPlugin:
return <FxSimulatorIcon className={className} style={myStyle} />;
case PluginType.AmplifierPlugin:
return <FxAmplifierIcon className={className} style={myStyle} />;
case PluginType.ChorusPlugin:
return <FxChorusIcon className={className} style={myStyle} />;
case PluginType.ModulatorPlugin:
case PluginType.CombPlugin:
return <FxModulatorIcon className={className} style={myStyle} />;
case PluginType.ReverbPlugin:
return <FxReverbIcon className={className} style={myStyle} />;
case PluginType.DistortionPlugin:
return <FxDistortionIcon className={className} style={myStyle} />;
case PluginType.FlangerPlugin:
return <FxFlangerIcon className={className} style={myStyle} />;
case PluginType.LowpassPlugin:
return <FxFilterIcon className={className} style={myStyle} />;
case PluginType.HighpassPlugin:
return <FxFilterHpIcon className={className} style={myStyle} />;
case PluginType.ParaEQPlugin:
return <FxParametricEqIcon className={className} style={myStyle} />;
case PluginType.MultiEQPlugin:
case PluginType.EQPlugin:
return <FxEqIcon className={className} style={myStyle} />;
case PluginType.UtilityPlugin:
return <FxUtilityIcon className={className} style={myStyle} />;
case PluginType.CompressorPlugin:
case PluginType.DynamicsPlugin:
case PluginType.ExpanderPlugin:
return <FxCompressorIcon className={className} style={myStyle} />;
case PluginType.LimiterPlugin:
return <FxLimiterIcon className={className} style={myStyle} />;
case PluginType.ConstantPlugin:
return <FxConstantIcon className={className} style={myStyle} />;
case PluginType.FunctionPlugin:
case PluginType.WaveshaperPlugin:
return <FxFunctionIcon className={className} style={myStyle} />;
case PluginType.GatePlugin:
return <FxGateIcon className={className} style={myStyle} />;
case PluginType.ConverterPlugin:
return <FxConverterIcon className={className} style={myStyle} />;
case PluginType.GeneratorPlugin:
return <FxGeneratorIcon className={className} style={myStyle} />;
case PluginType.OscillatorPlugin:
return <FxOscillatorIcon className={className} style={myStyle} />;
case PluginType.InstrumentPlugin:
return <FxInstrumentIcon className={className} style={myStyle} />;
case PluginType.MixerPlugin:
return <FxMixerIcon className={className} style={myStyle} />;
case PluginType.PitchPlugin:
return <FxPitchIcon className={className} style={myStyle} />;
case PluginType.SpatialPlugin:
return <FxSpatialIcon className={className} style={myStyle} />;
case PluginType.SpectralPlugin:
return <FxSpectralIcon className={className} style={myStyle} />;
case PluginType.AnalyserPlugin:
return <FxAnalyzerIcon className={className} style={myStyle} />;
case PluginType.SplitA:
return <SplitAIcon className={className} style={myStyle} />;
case PluginType.SplitB:
return <SplitBIcon className={className} style={myStyle} />;
case PluginType.SplitMix:
return <SplitMixIcon className={className} style={myStyle} />;
case PluginType.SplitLR:
return <SplitLrIcon className={className} style={myStyle} />;
case PluginType.None:
return <FxEmptyIcon className={className} style={myStyle} />;
case PluginType.Terminal:
return <FxTerminalIcon className={className} style={myStyle} />;
default:
case PluginType.Plugin:
return <FxPluginIcon className={className} style={myStyle} />;
}
}
export function SelectBaseIcon(plugin_type: PluginType): string {
switch (plugin_type) {
case PluginType.PhaserPlugin:
return "fx_phaser.svg";
case PluginType.FilterPlugin:
return "fx_filter.svg";
case PluginType.DelayPlugin:
return "fx_delay.svg";
case PluginType.SimulatorPlugin:
return "fx_simulator.svg";
case PluginType.AmplifierPlugin:
return "fx_amplifier.svg";
case PluginType.ChorusPlugin:
return "fx_chorus.svg";
case PluginType.ModulatorPlugin:
case PluginType.CombPlugin:
return "fx_modulator.svg";
case PluginType.ReverbPlugin:
return "fx_reverb.svg";
case PluginType.DistortionPlugin:
return "fx_distortion.svg";
case PluginType.FlangerPlugin:
return "fx_flanger.svg";
case PluginType.LowpassPlugin:
return "fx_filter.svg";
case PluginType.HighpassPlugin:
return "fx_filter_hp.svg";
case PluginType.ParaEQPlugin:
return "fx_parametric_eq.svg";
case PluginType.MultiEQPlugin:
case PluginType.EQPlugin:
return "fx_eq.svg";
case PluginType.UtilityPlugin:
return "fx_utility.svg";
case PluginType.CompressorPlugin:
case PluginType.DynamicsPlugin:
case PluginType.ExpanderPlugin:
return "fx_compressor.svg";
case PluginType.LimiterPlugin:
return "fx_limiter.svg";
case PluginType.ConstantPlugin:
return "fx_constant.svg";
case PluginType.FunctionPlugin:
case PluginType.WaveshaperPlugin:
return "fx_function.svg";
case PluginType.GatePlugin:
return "fx_gate.svg";
case PluginType.ConverterPlugin:
return "fx_converter.svg"
case PluginType.GeneratorPlugin:
return "fx_generator.svg";
case PluginType.OscillatorPlugin:
return "fx_oscillator.svg";
case PluginType.InstrumentPlugin:
return "fx_instrument.svg";
case PluginType.MixerPlugin:
return "fx_mixer.svg";
case PluginType.PitchPlugin:
return "fx_pitch.svg";
case PluginType.SpatialPlugin:
return "fx_spatial.svg";
case PluginType.SpectralPlugin:
return "fx_spectral.svg";
case PluginType.AnalyserPlugin:
return "fx_analyzer.svg";
case PluginType.Plugin:
default:
return "fx_plugin.svg";
}
}
export function SelectIconUri(plugin_type: PluginType) {
let icon = SelectBaseIcon(plugin_type);
return PiPedalModelFactory.getInstance().svgImgUrl(icon);
}
const PluginIcon = withStyles((props: PluginIconProps) => {
const { pluginType, opacity, pluginMissing } = props;
const classes = withStyles.getClasses(props);
let pluginMissing_: boolean = pluginMissing ?? false;
let size: number = 24;
if (props.size) size = props.size;
let topVal: number = (props.offsetY ?? 0);
let svgIcon = SelectSvgIcon(pluginType, classes.icon, size, opacity, pluginMissing_);
if (svgIcon) {
return svgIcon;
}
// colored icons that don't have dark/light variants.
return (
<img src={SelectIconUri(pluginType)} className={classes.icon} style={{ width: size, height: size, position: "relative", top: topVal, opacity: " " }} alt=''
/>);
// let maskText = "url(" + SelectIcon(pluginType, pluginUri) + ") no-repeat center";
// let color=colorFromHash(pluginUri);
// return (
// <div style={{ display: "block", opacity: 0.8, width: size, height: size, backgroundColor: color,
// mask: maskText, WebkitMask: maskText, WebkitMaskSize: size + "px", maskSize: size + "px" }}
// >&nbsp;</div>
// );
},
styles);
export default PluginIcon;
+343
View File
@@ -0,0 +1,343 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import Button from '@mui/material/Button';
import DialogEx from './DialogEx';
import MuiDialogTitle from '@mui/material/DialogTitle';
import MuiDialogContent from '@mui/material/DialogContent';
import MuiDialogActions from '@mui/material/DialogActions';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import { PiPedalModelFactory } from "./PiPedalModel";
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { UiPlugin, UiControl } from './Lv2Plugin';
import PluginIcon from './PluginIcon';
import { Remark } from 'react-remark';
import { css } from '@emotion/react';
const styles = (theme: Theme) => {
return createStyles({
root: {
margin: 0,
padding: theme.spacing(2),
},
closeButton: css({
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
}),
icon: {
fill: theme.palette.text.primary,
opacity: 0.6
}
});
};
export interface PluginInfoDialogTitleProps extends WithStyles<typeof styles> {
id: string;
children: React.ReactNode;
onClose: () => void;
}
// const PluginInfoDialogTitle = withStyles(styles)((props: PluginInfoDialogTitleProps) => {
// const { children, classes, onClose, ...other } = props;
// return (
// <MuiDialogTitle style={{ display: "flex", flexDirection: "row", alignItems: "start", flexWrap: "nowrap" }}>
// <div style={{ flex: "0 0 auto", marginRight: 8 }}>
// <PluginIcon pluginType={plugin.plugin_type} pluginUri={plugin.uri} offsetY={5} />
// </div>
// <div style={{ flex: "1 1 auto" }}>
// {children}
// </div>
// <IconButton aria-label="close" className={classes.closeButton} onClick={() => handleClose()}
// style={{ flex: "0 0 auto" }}>
// <CloseIcon />
// </IconButton>
// </MuiDialogTitle>
// );
// });
const PluginInfoDialogContent = withStyles(
MuiDialogContent,
(theme: Theme) => ({
root: {
padding: theme.spacing(2),
}
})
);
const PluginInfoDialogActions = withStyles(MuiDialogActions,
(theme: Theme) => ({
root: {
margin: 0,
padding: theme.spacing(1),
},
})
);
export interface PluginInfoProps extends WithStyles<typeof styles> {
plugin_uri: string
}
function displayChannelCount(count: number): string {
if (count === 0) return "None";
if (count === 1) return "Mono";
if (count === 2) return "Stereo";
return count + " channels";
}
function ioDescription(plugin: UiPlugin): string {
let result = "Input: " + displayChannelCount(plugin.audio_inputs) + ". Output: " + displayChannelCount(plugin.audio_outputs) + ".";
if (plugin.has_midi_input) {
if (plugin.has_midi_output) {
result += " Midi in/out.";
} else {
result += " Midi in.";
}
} else {
if (plugin.has_midi_output) {
result += "Midi out.";
}
}
return result;
}
// function makeParagraphs(description: string) {
// description = description.replaceAll('\r', '');
// description = description.replaceAll('\n\n', '\r');
// description = description.replaceAll('\n', ' ');
// let paragraphs: string[] = description.split('\r');
// return (
// <div style={{ paddingLeft: "24px" }}>
// {paragraphs.map((para) => (
// <Typography variant="body2" paragraph >
// {para}
// </Typography>
// ))}
// </div>
// );
// }
function makeControls(controls: UiControl[]) {
let hasComments = false;
for (let i = 0; i < controls.length; ++i) {
if (controls[i].comment !== "") {
hasComments = true;
break;
}
}
hasComments = true;
if (hasComments) {
let trs: React.ReactElement[] = [];
for (let i = 0; i < controls.length; ++i) {
let control = controls[i];
if (!(control.not_on_gui) && control.is_input)
trs.push((
<tr>
<td style={{ verticalAlign: "top" }}>
<Typography variant="body2" style={{ whiteSpace: "nowrap" }}>
{control.name}
</Typography>
</td>
<td style={{ paddingLeft: "16px", verticalAlign: "top" }}>
<Typography variant="body2">
{control.comment}
</Typography>
</td>
</tr >
));
}
return (
<table style={{ paddingLeft: "24px", verticalAlign: "top" }}>
{
trs
}
</table>
);
} else {
return (
<Grid container direction="row" justifyContent="flex-start" alignItems="flex-start" spacing={1} style={{ paddingLeft: "24px" }}>
{
controls.map((control) => (
<Grid xs={6} sm={4} key={control.symbol + "x"} >
<Typography variant="body2">
{control.name}
</Typography>
</Grid>
))
}
</Grid>
);
}
}
const PluginInfoDialog = withStyles((props: PluginInfoProps) => {
let model = PiPedalModelFactory.getInstance();
const [open, setOpen] = React.useState(false);
let { plugin_uri } = props;
let classes = withStyles.getClasses(props);
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
let uri = props.plugin_uri;
let visible = true;
if (uri === null || uri === "") {
visible = false;
}
if (!visible) {
return (<div></div>);
};
let plugin = model.getUiPlugin(plugin_uri);
if (plugin === null) {
return (<div></div>)
}
return (
<div>
<IconButton
style={{ display: (props.plugin_uri !== "") ? "inline-flex" : "none" }}
onClick={handleClickOpen}
size="large">
<InfoOutlinedIcon className={classes.icon} color='inherit' />
</IconButton>
{open && (
<DialogEx tag="info" onClose={handleClose} open={open} fullWidth maxWidth="md"
onEnterKey={handleClose}
>
<MuiDialogTitle >
<div style={{ display: "flex", flexDirection: "row", alignItems: "start", flexWrap: "nowrap" }}>
<div style={{ flex: "0 0 auto", marginRight: 16 }}>
<PluginIcon pluginType={plugin.plugin_type} offsetY={3} />
</div>
<div style={{ flex: "1 1 auto" }}>
<Typography variant="h6">{plugin.name}</Typography>
</div>
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={() => handleClose()}
style={{ flex: "0 0 auto" }}
size="large">
<CloseIcon />
</IconButton>
</div>
</MuiDialogTitle>
<PluginInfoDialogContent dividers style={{ width: "100%", maxHeight: "80%", overflowX: "hidden" }}>
<div style={{ width: "100%", display: "flex", flexFlow: "row", justifyItems: "stretch", flexWrap: "nowrap" }} >
<div style={{ flex: "1 1 auto", whiteSpace: "nowrap", minWidth: "auto" }}>
<Typography gutterBottom variant="body2" >
Author:&nbsp;
{(plugin.author_homepage !== "")
? (<a href={plugin.author_homepage} target="_blank" rel="noopener noreferrer">
{plugin.author_name}
</a>
)
: (
<span>{plugin.author_name}</span>
)
}
</Typography>
</div>
<div style={{ flex: "0 0 auto" }}>
<Typography gutterBottom variant="body2" >
{ioDescription(plugin)}
</Typography>
</div>
</div>
<Typography variant="body2" gutterBottom style={{ paddingTop: "1em" }}>
Controls:
</Typography>
{
makeControls(plugin.controls)
}
{plugin.description.length > 0 && (
<div>
<Typography gutterBottom variant="body2" style={{ paddingTop: "1em" }}>
Description:
</Typography>
<div style={{ marginLeft: 24, marginTop: 16 }}>
<Remark
rehypeReactOptions={{
components: {
p: (props: any) => {
// return (
// <p className="MuiTypography-root MuiTypography-body2" {...props} />
// );
return (
<Typography variant="body2" paragraph={true} {...props} />
);
},
code: (props: any) => {
return (<code style={{ fontSize: 14 }} {...props} />);
},
a: (props: any) => {
return (
<a target="_blank" {...props} />
);
}
},
}}
>
{plugin.description}
</Remark>
</div>
</div>
)}
</PluginInfoDialogContent>
<PluginInfoDialogActions>
<Button variant="dialogPrimary" autoFocus onClick={handleClose} style={{ width: "130px" }}>
OK
</Button>
</PluginInfoDialogActions>
</DialogEx>
)}
</div >
);
},
styles);
export default PluginInfoDialog;
+558
View File
@@ -0,0 +1,558 @@
// Copyright (c) 2023 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { Component } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { UiControl } from './Lv2Plugin';
import Typography from '@mui/material/Typography';
import { PiPedalModel, PiPedalModelFactory, MonitorPortHandle, State } from './PiPedalModel';
import { isDarkMode } from './DarkMode';
import GxTunerControl from './GxTunerControl';
import Units from './Units';
import ControlTooltip from './ControlTooltip';
import { css } from '@emotion/react';
function makeLedGradient(color: string) {
if (color === "green") {
if (isDarkMode()) {
return "radial-gradient(circle at center, #0F0 0, #0F0 20%, #333 100%)";
} else {
return "radial-gradient(circle at center, #4F4 0, #4F4 40%, #464 100%)";
}
}
if (color === "blue") {
if (isDarkMode()) {
return "radial-gradient(circle at center, #00F 0, #00F 20%, #333 100%)";
} else {
return "radial-gradient(circle at center, #44F 0, #44F 40%, #446 100%)";
}
}
if (color === "yellow") {
if (isDarkMode()) {
return "radial-gradient(circle at center, #FF0 0, #FF0 20%, #333 100%)";
} else {
return "radial-gradient(circle at center, #FF4 0, #FF4 40%, #664 100%)";
}
}
if (isDarkMode()) {
return "radial-gradient(circle at center, #F000 0, #F00 20%, #333 100%)";
} else {
return "radial-gradient(circle at center, #F44 0, #F44 40%, #644 100%)";
}
}
const styles = (theme: Theme) => createStyles({
frame: css({
position: "relative",
margin: "12px"
}),
switchTrack: css({
height: '100%',
width: '100%',
borderRadius: 14 / 2,
zIndex: -1,
transition: theme.transitions.create(['opacity', 'background-color'], {
duration: theme.transitions.duration.shortest
}),
backgroundColor: theme.palette.secondary.main,
opacity: theme.palette.mode === 'light' ? 0.38 : 0.3
}),
displayValue: css({
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 4,
textAlign: "center",
background: "white",
color: "#666",
// zIndex: -1,
}),
controlFrame: css({
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "space-between", height: 116
}),
titleSection: css({
flex: "0 0 auto", alignSelf:"stretch", marginBottom: 8, marginLeft: 0, marginRight: 0
}),
midSection: css({
flex: "1 1 1", display: "flex",flexFlow: "column nowrap",alignContent: "center",justifyContent: "center"
}),
editSection: css({
flex: "0 0 28", position: "relative", width: 60, height: 28, minHeight: 28
}),
editSectionNoContent: css({
flex: "0 0 28", position: "relative", width: 1, height: 28, minHeight: 28
})
});
export interface PluginOutputControlProps extends WithStyles<typeof styles> {
uiControl: UiControl;
instanceId: number;
}
type PluginOutputControlState = {
hasValue: boolean;
value: number;
};
const PluginOutputControl =
withStyles(
class extends Component<PluginOutputControlProps, PluginOutputControlState> {
private model: PiPedalModel;
private vuRef: React.RefObject<HTMLDivElement|null>;
private dbVuRef: React.RefObject<HTMLDivElement|null>;
private dbVuTelltaleRef: React.RefObject<HTMLDivElement|null>;
private lampRef: React.RefObject<HTMLDivElement|null>;
constructor(props: PluginOutputControlProps) {
super(props);
this.vuRef = React.createRef<HTMLDivElement>();
this.dbVuRef = React.createRef<HTMLDivElement>();
this.dbVuTelltaleRef = React.createRef<HTMLDivElement>();
this.lampRef = React.createRef<HTMLDivElement>();
this.state = {
hasValue: false,
value: 0
};
this.model = PiPedalModelFactory.getInstance();
this.onConnectionStateChanged = this.onConnectionStateChanged.bind(this);
}
private onConnectionStateChanged(state: State) {
if (state === State.Ready) {
this.unsubscribe();
this.subscribe();
}
}
private mounted: boolean = false;
componentWillUnmount() {
if (super.componentWillUnmount) {
super.componentWillUnmount();
}
this.mounted = false;
this.model.state.removeOnChangedHandler(this.onConnectionStateChanged);
this.unsubscribe();
}
componentDidMount(): void {
this.mounted = true;
this.subscribe();
this.model.state.addOnChangedHandler(this.onConnectionStateChanged);
if (super.componentDidMount) {
super.componentDidMount();
}
}
componentDidUpdate(prevProps: Readonly<PluginOutputControlProps>, prevState: Readonly<PluginOutputControlState>, snapshot?: any): void {
if (prevProps.instanceId !== this.props.instanceId) {
this.unsubscribe();
this.subscribe();
}
}
private VU_HEIGHT = 60 - 4;
private DB_VU_HEIGHT = 60 - 4;
private animationHandle: number | undefined = undefined;
private dbVuTelltale = -96.0;
private dbVuValue = -96.0;
private dbVuHoldTime = 0.0;
private requestDbVuAnimation() {
if (!this.animationHandle) {
this.animationHandle = requestAnimationFrame(
() => {
let value = this.dbVuValue;
let range = this.vuMap(value);
let top = range - this.VU_HEIGHT;
if (top > 0) top = 0;
if (value > this.dbVuTelltale) {
this.dbVuTelltale = value;
this.dbVuHoldTime = Date.now() + 2000;
}
if (this.dbVuRef.current) {
this.dbVuRef.current.style.marginTop = top + "px";
}
this.animationHandle = undefined;
this.updateDbVuTelltale();
}
)
}
}
updateDbVuTelltale() {
let telltaleDone = true;
if (this.dbVuHoldTime !== 0) {
telltaleDone = false;
let t = Date.now();
if (t >= this.dbVuHoldTime) {
let dt = t - this.dbVuHoldTime;
let telltaleValue = this.dbVuTelltale - 30 * dt / 1000;
if (telltaleValue < -200) {
telltaleValue = -200;
telltaleDone = true;
}
this.dbVuTelltale = telltaleValue;
this.dbVuHoldTime = t;
}
let y = this.dbVuMap(this.dbVuTelltale);
if (y < 0) y = 0;
if (this.dbVuTelltaleRef.current) {
let telltaleStyle = this.dbVuTelltaleRef.current.style;
telltaleStyle.marginTop = y + "px";
let telltaleColor = "#0C0";
if (this.dbVuTelltale >= 0) {
telltaleColor = "#F00";
} else if (this.dbVuTelltale >= -10) {
telltaleColor = "#FF0";
}
telltaleStyle.background = telltaleColor;
}
}
if (!telltaleDone) {
this.requestDbVuAnimation();
}
}
private updateValue(value: number) {
if (this.lampRef.current) {
let control = this.props.uiControl;
let range = (value - control.min_value) / (control.max_value - control.min_value);
this.lampRef.current.style.opacity = range + "";
} else if (this.dbVuRef.current) {
this.dbVuValue = value;
this.requestDbVuAnimation();
}
else if (this.vuRef.current) {
let control = this.props.uiControl;
let range = (value - control.min_value) / (control.max_value - control.min_value);
let top = this.VU_HEIGHT - range * this.VU_HEIGHT;
if (!this.animationHandle) {
this.animationHandle = requestAnimationFrame(
() => {
if (this.vuRef.current) {
this.vuRef.current.style.marginTop = top + "px";
}
this.animationHandle = undefined;
}
)
}
} else {
if (this.mounted) {
this.setState({ hasValue: true, value: value });
}
}
}
private monitorPortHandle?: MonitorPortHandle;
private subscribe(): void {
this.unsubscribe();
this.monitorPortHandle = this.model.monitorPort(
this.props.instanceId,
this.props.uiControl.symbol,
1 / 15.0, // update rate.
(value) => {
this.updateValue(value);
}
);
}
private unsubscribe(): void {
if (this.monitorPortHandle) {
this.model.unmonitorPort(this.monitorPortHandle)
this.monitorPortHandle = undefined;
}
}
isShortSelectOrText(control: UiControl) {
if (control.scale_points.length === 0) {
return true;
}
for (let scale_point of control.scale_points) {
if (scale_point.label.length > 12) return false;
}
return true;
}
formatDisplayValue(uiControl: UiControl | undefined, value: number): string {
if (!uiControl) return "";
return uiControl.formatDisplayValue(value);
}
dbVuMap(value: number): number {
let control = this.props.uiControl;
let y = (control.max_value - value) * this.DB_VU_HEIGHT / (control.max_value - control.min_value);
return y;
}
vuMap(value: number): number {
let control = this.props.uiControl;
let y = (control.max_value - value) * this.VU_HEIGHT / (control.max_value - control.min_value);
return y;
}
render() {
const classes = withStyles.getClasses(this.props);
let control: UiControl = this.props.uiControl;
let text = "";
if (this.state.hasValue) {
let value = this.state.value;
if (control.isOnOffSwitch()) {
text = value === 0 ? "OFF" : "ON";
} else {
text = this.formatDisplayValue(control, value);
}
}
let isText = control.isOutputText();
let item_width: number | undefined = isText ? 160 : 80;
if (isText) {
if (this.isShortSelectOrText(control)) {
item_width = 80;
}
}
if (control.isTuner()) {
item_width = undefined;
return (
<div style={{ display: "flex", flexDirection: "column", width: item_width, margin: 8, height: 98 }}>
<GxTunerControl instanceId={this.props.instanceId} symbolName={control.symbol}
valueIsMidi={control.units === Units.midiNote}
/>
</div >
);
} else if (control.isDbVu()) {
item_width = undefined;
let redLevel = this.dbVuMap(0);
let yellowLevel = this.dbVuMap(-10);
return (
<div className={classes.controlFrame}
style={{ width: item_width }}>
{/* TITLE SECTION */}
<div className={classes.titleSection}
style={{ width: "100%" }}>
<ControlTooltip uiControl={control}>
<Typography variant="caption" display="block" style={{
width: "100%",
textAlign: "center"
}}> {control.name === "" ? "\u00A0" : control.name}</Typography>
</ControlTooltip>
</div>
{/* CONTROL SECTION */}
<div className={classes.midSection}
style={{ flex: "1 1 1", display: "flex", justifyContent: "center", alignItems: "start", flexFlow: "row nowrap" }}>
<div style={{ width: 8, height: this.DB_VU_HEIGHT + 4, background: "#000", }}>
<div style={{ height: this.DB_VU_HEIGHT, width: 4, overflow: "hidden", position: "absolute", margin: 2 }}>
<div style={{ width: 4, height: redLevel, position: "absolute", marginTop: 0, background: "#F00" }} />
<div style={{ width: 4, height: (yellowLevel - redLevel), position: "absolute", marginTop: redLevel, background: "#CC0" }} />
<div style={{ width: 4, height: (this.DB_VU_HEIGHT - yellowLevel), position: "absolute", marginTop: yellowLevel, background: "#0A0" }} />
<div ref={this.dbVuRef} style={{ width: 4, position: "absolute", marginTop: 0, height: this.VU_HEIGHT, background: "#000" }} />
<div ref={this.dbVuTelltaleRef} style={{ width: 4, position: "absolute", marginTop: 100, height: 3, background: "#C00" }} />
</div>
</div>
</div>
<div className={classes.editSectionNoContent}>
</div>
</div >
);
}
else if (control.isVu()) {
// yyx: convert this to a horizontal progress bar.
item_width = undefined;
return (
<div className={classes.controlFrame} style={{ width: item_width}}>
{/* TITLE SECTION */}
<div className={classes.titleSection}
style={{ width: "100%" }}>
<ControlTooltip uiControl={control}>
<Typography noWrap display="block" variant="caption" style={{
width: item_width,
textAlign: "center"
}}> {control.name === "" ? "\u00A0" : control.name}</Typography>
</ControlTooltip>
</div>
{/* CONTROL SECTION */}
<div className={classes.midSection}
style={{ display: "flex", justifyContent: "center", alignItems: "start", flexFlow: "row nowrap" }}>
<div style={{ width: 8, height: this.VU_HEIGHT + 4, background: "#000", }}>
<div style={{ height: this.VU_HEIGHT, overflow: "hidden", position: "absolute", margin: 2 }}>
<div ref={this.vuRef} style={{ width: 4, height: this.VU_HEIGHT, background: "#0C0", }} />
</div>
</div>
</div>
{/* LABEL/EDIT SECTION*, strictly a placeholder for visual alignment purposes.*/}
<div className={classes.editSectionNoContent}>
</div>
</div >
);
} else if (control.isLamp()) {
item_width = undefined;
let attachedLamp = control.name === "" || control.name === "\u00A0";
let ledGradient: string;
if (this.props.uiControl.pipedal_ledColor.length === 0) {
ledGradient = (isDarkMode() ? "radial-gradient(circle at center, #F00 0, #F00 20%, #333 100%)"
: "radial-gradient(circle at center, #F44 0, #F44 40%, #644 100%)");
} else {
ledGradient = makeLedGradient(this.props.uiControl.pipedal_ledColor);
}
return (
<div className={classes.controlFrame}
style={{width: attachedLamp? 15: item_width}}
>
{/* TITLE SECTION */}
<div className={classes.titleSection}
style={{ flex: "0 0 auto", width: "100%"}}>
<ControlTooltip uiControl={control}>
<Typography noWrap variant="caption" display="block" style={{
width: "100%",
textAlign: "center"
}}> {control.name === "" ? "\u00A0" : (control.name)}</Typography>
</ControlTooltip>
</div>
{/* CONTROL SECTION */}
<div className={classes.midSection}
style={{
display: "flex", justifyContent: "center", alignItems: "center", flexFlow: "row nowrap",
}}>
<div style={{
width: 12, height: 12, background: isDarkMode() ? "#111" :
"#444", borderRadius: 5, position: "relative"
}}>
<div ref={this.lampRef} style={{
width: 8, height: 8,
background: ledGradient,
opacity: 0, borderRadius: 3, margin: 2, position: "absolute"
}} />
</div>
</div>
{/* LABEL/EDIT SECTION*, strictly a placeholder for visual alignment purposes.*/}
<div className={classes.editSectionNoContent}>
</div>
</div >
);
} if (control.isOutputText()) {
return (
<div className={classes.controlFrame}
style={{ display: "flex", flexDirection: "column", width: item_width }}>
{/* TITLE SECTION */}
<div className={classes.titleSection}
style={{ flex: "0 0 auto", width: "100%" }}>
<ControlTooltip uiControl={control}>
<Typography noWrap variant="caption" display="block" style={{
width: "100%",
textAlign: "left"
}}> {control.name === "" ? "\u00A0": control.name}</Typography>
</ControlTooltip>
</div>
{/* CONTROL SECTION */}
<div className={classes.midSection} style={{
display: "flex", justifyContent: "center",
flexFlow: "row nowrap", paddingTop: 6
}}>
<Typography variant="caption" display="block" noWrap style={{ width: "100%" }}>
{text}
</Typography>
</div>
{/* LABEL/EDIT SECTION*, strictly a placeholder for visual alignment purposes.*/}
<div className={classes.editSectionNoContent}>
</div>
</div >
);
} else {
return (
<div className={classes.controlFrame}
style={{ display: "flex", flexDirection: "column", width: item_width }}>
{/* TITLE SECTION */}
<div className={classes.titleSection}
style={{ flex: "0 0 auto", width: "100%" }}>
<ControlTooltip uiControl={control}>
<Typography noWrap variant="caption" display="block" style={{
width: "100%",
textAlign: "left"
}}> {control.name}</Typography>
</ControlTooltip>
</div>
{/* CONTROL SECTION */}
<div className={classes.midSection}
style={{ display: "flex", justifyContent: "start", alignItems: "center", flexFlow: "row nowrap" }}>
<Typography noWrap variant="caption" display="block" style={{ width: "100%" }}>
{text}
</Typography>
</div>
{/* LABEL/EDIT SECTION*, strictly a placeholder for visual alignment purposes.*/}
<div className={classes.editSectionNoContent}>
</div>
</div >
);
}
}
},
styles
);
export default PluginOutputControl;
+87
View File
@@ -0,0 +1,87 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export class PluginUiPreset {
deserialize(input: any): PluginUiPreset {
this.instanceId = input.instanceId;
this.label = input.label;
return this;
}
static deserialize_array(input: any) : PluginUiPreset[] {
let result: PluginUiPreset[] = [];
for (let i = 0; i < input.length; ++i)
{
result.push(new PluginUiPreset().deserialize(input[i]));
}
return result;
}
instanceId: number = -1;
label: string = "";
static equals(left: PluginUiPreset, right: PluginUiPreset): boolean
{
return left.instanceId === right.instanceId && left.label === right.label;
}
}
export class PluginUiPresets {
deserialize(input: any): PluginUiPresets {
this.pluginUri = input.pluginUri;
this.presets = PluginUiPreset.deserialize_array(input.presets);
return this;
}
clone(): PluginUiPresets {
return new PluginUiPresets().deserialize(this);
}
movePreset(from: number, to: number): void
{
let t = this.presets[from];
this.presets.splice(from,1);
this.presets.splice(to,0,t);
}
getItem(instanceId: number): PluginUiPreset | undefined {
for (let i = 0; i < this.presets.length; ++i)
{
if (this.presets[i].instanceId === instanceId)
{
return this.presets[i];
}
}
return undefined;
}
static equals(left: PluginUiPresets,right: PluginUiPresets): boolean
{
if (left.pluginUri !== right.pluginUri) return false;
if (left.presets.length !== right.presets.length) return false;
for (let i = 0; i < left.presets.length; ++i)
{
if (!PluginUiPreset.equals(left.presets[i],right.presets[i]))
{
return false;
}
}
return true;
}
pluginUri: string = "";
presets: PluginUiPreset[] = [];
}
+347
View File
@@ -0,0 +1,347 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { SyntheticEvent, Component } from 'react';
import IconButton from '@mui/material/IconButton';
import { PiPedalModel, PiPedalModelFactory, PluginPresetsChangedHandle } from './PiPedalModel';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import {createStyles} from './WithStyles';
import PluginPresetsDialog from './PluginPresetsDialog';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Fade from '@mui/material/Fade';
import RenameDialog from './RenameDialog'
import {PluginUiPresets} from './PluginPreset';
import Divider from "@mui/material/Divider";
import PluginPresetsIcon from "./svg/ic_pluginpreset.svg?react";
import PluginPresetIcon from "./svg/ic_pluginpreset2.svg?react";
interface PluginPresetSelectorProps extends WithStyles<typeof styles> {
pluginUri?: string;
instanceId: number;
}
interface PluginPresetSelectorState {
presets : PluginUiPresets;
enabled: boolean;
presetChanged: boolean;
showPresetsDialog: boolean;
showEditPresetsDialog: boolean;
presetsMenuAnchorRef: HTMLElement | null;
renameDialogOpen: boolean;
renameDialogDefaultName: string;
renameDialogActionName: string;
renameDialogOnOk?: (name: string) => void;
saveAsName: string;
}
const styles = (theme: Theme) => createStyles({
itemIcon: {
width: 24, height: 24, marginRight: "4px", opacity: 0.6
},
pluginIcon: {
width: 24, height: 24, opacity: 0.6,fill: theme.palette.text.primary
},
pluginMenuIcon: {
width: 24, height: 24, opacity: 0.6,fill: theme.palette.text.primary,marginRight: 4
}
});
const PluginPresetSelector =
withStyles(
class extends Component<PluginPresetSelectorProps, PluginPresetSelectorState> {
model: PiPedalModel;
constructor(props: PluginPresetSelectorProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
presets: new PluginUiPresets(),
enabled: false,
presetChanged: false,
showPresetsDialog: false,
showEditPresetsDialog: false,
presetsMenuAnchorRef: null,
renameDialogOpen: false,
renameDialogDefaultName: "",
renameDialogActionName: "",
renameDialogOnOk: undefined,
saveAsName: ""
};
this.handleDialogClose = this.handleDialogClose.bind(this);
this.handlePresetsMenuClose = this.handlePresetsMenuClose.bind(this);
}
handleLoadPluginPreset(instanceId: number)
{
this.handlePresetsMenuClose();
this.model.loadPluginPreset(this.props.instanceId,instanceId);
let presetName = this.getPresetName(instanceId);
this.setState({saveAsName: presetName});
}
getPresetName(instanceId: number)
{
for (let preset of this.state.presets.presets)
{
if (preset.instanceId === instanceId)
{
return preset.label;
}
}
return "";
}
handlePresetMenuClick(e: SyntheticEvent): void {
this.setState({ presetsMenuAnchorRef: (e.currentTarget as HTMLElement) });
}
handlePresetsMenuClose(): void {
this.setState({ presetsMenuAnchorRef: null });
}
handlePluginPresetsMenuSaveAs(e: SyntheticEvent): void {
this.handlePresetsMenuClose();
e.stopPropagation();
let name = this.state.saveAsName;
this.renameDialogOpen(name, "Save As")
.then((newName) => {
this.setState({saveAsName: newName});
return this.model.saveCurrentPluginPresetAs(this.props.instanceId,newName);
})
.then((newInstanceId) => {
// s'fine. dealt with by updates, but we do need error handling.
})
.catch((error) => {
this.showError(error);
})
;
}
showError(error: string) {
this.model.showAlert(error);
}
renameDialogOpen(defaultText: string, acceptButtonText: string): Promise<string> {
let result = new Promise<string>(
(resolve, reject) => {
this.setState(
{
renameDialogOpen: true,
renameDialogDefaultName: defaultText,
renameDialogActionName: acceptButtonText,
renameDialogOnOk: (name) => {
resolve(name);
}
}
);
}
);
return result;
}
loadPresets():void {
if (!this.currentUri) return;
let captureUri: string = this.currentUri;
this.model.getPluginPresets(captureUri)
.then((presets: PluginUiPresets) => {
if (captureUri === this.currentUri)
{
this.setState({presets: presets});
}
})
.catch(error => {
if (captureUri === this.currentUri)
{
this.setState({presets: new PluginUiPresets()});
}
});
}
onPluginPresetsChanged(pluginUri: string): void {
if (pluginUri === this.props.pluginUri)
{
this.loadPresets();
}
}
_pluginPresetsChangedHandle?: PluginPresetsChangedHandle;
componentDidMount()
{
this._pluginPresetsChangedHandle = this.model.addPluginPresetsChangedListener(
(pluginUri: string) => {
this.onPluginPresetsChanged(pluginUri);
}
);
}
componentWillUnmount()
{
if (this._pluginPresetsChangedHandle)
{
this.model.removePluginPresetsChangedListener(this._pluginPresetsChangedHandle);
this._pluginPresetsChangedHandle = undefined;
}
}
currentUri?: string = "";
componentDidUpdate()
{
if (this.currentUri !== this.props.pluginUri)
{
this.currentUri = this.props.pluginUri;
if (!this.props.pluginUri)
{
this.setState({presets: new PluginUiPresets()});
} else {
this.loadPresets();
}
}
}
handleRenameDialogClose(): void {
this.setState({
renameDialogOpen: false,
renameDialogOnOk: undefined
});
}
handleRenameDialogOk(name: string): void {
let renameDialogOnOk = this.state.renameDialogOnOk;
this.handleRenameDialogClose();
if (renameDialogOnOk) {
renameDialogOnOk(name);
}
}
handleMenuEditPluginPresets(): void {
this.handlePresetsMenuClose();
this.showEditPluginPresetsDialog(true);
}
handleSave() {
this.model.saveCurrentPreset();
}
showPresetDialog(show: boolean) {
this.setState({
showPresetsDialog: show,
showEditPresetsDialog: false
});
}
showEditPluginPresetsDialog(show: boolean) {
this.setState({
showPresetsDialog: show,
showEditPresetsDialog: true
});
}
handleDialogClose(): void {
this.showPresetDialog(false);
}
handleChange(event: any, extra: any): void {
// misses click on default.
// this.model.loadPreset(event.target.value as number);
}
handleSelectClose(event: any): void {
let value = event.currentTarget.getAttribute("data-value");
if (value && value.length > 0) {
this.model.loadPreset(parseInt(value));
}
//this.model.loadPreset(event.target.value as number);
}
render() {
const classes = withStyles.getClasses(this.props);
//const classes = withStyles.getClasses(this.props);
//const classes = withStyles.getClasses(this.props);
if ((!this.props.pluginUri))
{
return (<div/>);
}
return (
<div >
<IconButton onClick={(e)=> this.handlePresetMenuClick(e)} size="large">
<PluginPresetsIcon className={classes.pluginIcon}/>
</IconButton>
<Menu
id="edit-plugin-presets-menu"
anchorEl={this.state.presetsMenuAnchorRef}
open={Boolean(this.state.presetsMenuAnchorRef)}
onClose={() => this.handlePresetsMenuClose()}
TransitionComponent={Fade}
MenuListProps={
{
style: {minWidth: 180}
}
}
>
<MenuItem onClick={(e) => this.handlePluginPresetsMenuSaveAs(e)}>Save plugin preset...</MenuItem>
<MenuItem onClick={(e) => this.handleMenuEditPluginPresets()}>Manage plugin presets...</MenuItem>
{ this.state.presets.presets.length !== 0 &&
(<Divider/>)
}
{
this.state.presets.presets.map((preset) => {
return (<MenuItem key={preset.instanceId}
onClick={(e) => this.handleLoadPluginPreset(preset.instanceId)}
>
<PluginPresetIcon className={classes.pluginMenuIcon}/>
{preset.label}</MenuItem>);
})
}
</Menu>
<PluginPresetsDialog
instanceId={this.props.instanceId}
presets={this.state.presets}
show={this.state.showPresetsDialog}
isEditDialog={this.state.showEditPresetsDialog}
onDialogClose={() => this.handleDialogClose()} />
<RenameDialog open={this.state.renameDialogOpen}
defaultName={this.state.renameDialogDefaultName}
acceptActionName={this.state.renameDialogActionName}
onClose={() => this.handleRenameDialogClose()}
onOk={(name: string) => this.handleRenameDialogOk(name)} />
</div>
);
}
},
styles);
export default PluginPresetSelector;
+505
View File
@@ -0,0 +1,505 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { SyntheticEvent,Component } from 'react';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import Button from "@mui/material/Button";
import ButtonBase from "@mui/material/ButtonBase";
import DialogEx from './DialogEx';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import DraggableGrid, { ScrollDirection } from './DraggableGrid';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Fade from '@mui/material/Fade';
import UploadPresetDialog from './UploadPresetDialog';
import {PluginUiPresets,PluginUiPreset} from './PluginPreset';
import SelectHoverBackground from './SelectHoverBackground';
import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import EditIcon from '@mui/icons-material/Edit';
import RenameDialog from './RenameDialog';
import Slide, {SlideProps} from '@mui/material/Slide';
import {createStyles} from './WithStyles';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import PluginPresetIcon from "./svg/ic_pluginpreset2.svg?react";
import DownloadIcon from './svg/file_download_black_24dp.svg?react';
import UploadIcon from './svg/file_upload_black_24dp.svg?react';
import { css } from '@emotion/react';
interface PluginPresetsDialogProps extends WithStyles<typeof styles> {
show: boolean;
isEditDialog: boolean;
instanceId: number;
presets :PluginUiPresets;
onDialogClose: () => void;
};
interface PluginPresetsDialogState {
showActionBar: boolean;
selectedItem: number;
renameOpen: boolean;
moreMenuAnchorEl: HTMLElement | null;
openUploadPresetDialog: boolean;
};
const styles = (theme: Theme) => createStyles({
listIcon: css({
width: 24, height: 24, opacity: 0.6,fill: theme.palette.text.primary
}),
dialogAppBar: css({
position: 'relative',
top: 0, left: 0
}),
itemBackground: css({
background: theme.palette.background.paper
}),
dialogActionBar: css({
position: 'relative',
top: 0, left: 0,
background: "black"
}),
dialogTitle: css({
marginLeft: theme.spacing(2),
textOverflow: "ellipsis",
flex: "1 1",
}),
itemFrame: css({
display: "flex",
flexDirection: "row",
flexWrap: "nowrap",
width: "100%",
height: "56px",
alignItems: "center",
textAlign: "left",
justifyContent: "center",
paddingLeft: 8
}),
iconFrame: css({
flex: "0 0 auto",
}),
itemIcon: css({
width: 24, height: 24, margin: 12, opacity: 0.6,
fill: theme.palette.text.primary
}),
itemLabel: css({
flex: "1 1 1px",
marginLeft: 8
})
});
const Transition = React.forwardRef(function Transition(
props: SlideProps, ref: React.Ref<unknown>
) {
return (<Slide direction="up" ref={ref} {...props} />);
});
const PluginPresetsDialog = withStyles(
class extends Component<PluginPresetsDialogProps, PluginPresetsDialogState> {
model: PiPedalModel;
constructor(props: PluginPresetsDialogProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.handleDialogClose = this.handleDialogClose.bind(this);
this.state = {
showActionBar: false,
selectedItem: -1,
renameOpen: false,
moreMenuAnchorEl: null,
openUploadPresetDialog: false
};
}
selectItemAtIndex(index: number) {
let instanceId = this.props.presets.presets[index].instanceId;
this.setState({ selectedItem: instanceId });
}
isEditMode() {
return this.state.showActionBar || this.props.isEditDialog;
}
onMoreClick(e: SyntheticEvent): void {
this.setState({ moreMenuAnchorEl: e.currentTarget as HTMLElement })
}
handleDownloadPresets() {
this.handleMoreClose();
if (this.props.presets.pluginUri !== "")
{
this.model.download("downloadPluginPresets", this.props.presets.pluginUri);
}
}
handleUploadPresets() {
this.handleMoreClose();
this.setState({ openUploadPresetDialog: true });
}
handleMoreClose(): void {
this.setState({ moreMenuAnchorEl: null });
}
componentDidUpdate()
{
if (!this.props.presets.getItem(this.state.selectedItem))
{
if (this.props.presets.presets.length !== 0)
{
this.setState({selectedItem: this.props.presets.presets[0].instanceId});
}
}
}
componentDidMount() {
// scroll selected item into view.
}
componentWillUnmount() {
}
getSelectedIndex() {
let instanceId = this.state.selectedItem;
let presets = this.props.presets;
for (let i = 0; i < presets.presets.length; ++i) {
if (presets.presets[i].instanceId === instanceId) return i;
}
return -1;
}
handleDeleteClick() {
let selectedItem = this.state.selectedItem;
if (selectedItem !== -1) {
let newPresets = this.props.presets.clone();
for (let i = 0; i < newPresets.presets.length; ++i)
{
if (newPresets.presets[i].instanceId === selectedItem)
{
newPresets.presets.splice(i,1);
this.model.updatePluginPresets(newPresets.pluginUri,newPresets)
.catch((error) => {
this.model.showAlert(error);
});
let newPos = i;
if (newPos >= newPresets.presets.length) {
--i;
}
let newSelection = i < 0? -1: newPresets.presets[i].instanceId;
this.setState({selectedItem: newSelection});
}
}
}
}
handleDialogClose() {
this.props.onDialogClose();
}
handleItemClick(instanceId: number): void {
if (this.isEditMode()) {
this.setState({ selectedItem: instanceId });
} else {
this.model.loadPreset(instanceId);
this.props.onDialogClose();
}
}
showActionBar(show: boolean): void {
this.setState({ showActionBar: show });
}
mapElement(el: any): React.ReactNode {
let presetEntry = el as PluginUiPreset;
const classes = withStyles.getClasses(this.props);
let selectedItem = this.state.selectedItem;
return (
<div key={presetEntry.instanceId} className="backgroundItem" >
<ButtonBase style={{ width: "100%", height: 48 }}
onClick={() => this.handleItemClick(presetEntry.instanceId)}
>
<SelectHoverBackground selected={presetEntry.instanceId === selectedItem} showHover={true} />
<div className={classes.itemFrame}>
<div className={classes.iconFrame}>
<PluginPresetIcon className={classes.itemIcon} />
</div>
<div className={classes.itemLabel}>
<Typography noWrap>
{presetEntry.label}
</Typography>
</div>
</div>
</ButtonBase>
</div>
);
}
updateServerPluginPresets(newPresets: PluginUiPresets) {
newPresets = newPresets.clone();
this.model.updatePluginPresets(this.getPluginUri(),newPresets)
.catch((error) => {
this.model.showAlert(error);
});
}
moveElement(from: number, to: number): void {
let newPresets: PluginUiPresets = this.props.presets.clone();
newPresets.movePreset(from, to);
this.setState({
selectedItem: newPresets.presets[to].instanceId
});
this.updateServerPluginPresets(newPresets);
}
getSelectedName(): string {
let item = this.props.presets.getItem(this.state.selectedItem);
if (item) return item.label;
return "";
}
handleRenameClick() {
let item = this.props.presets.getItem(this.state.selectedItem);
if (item) {
this.setState({ renameOpen: true });
}
}
handleRenameOk(text: string) {
let item = this.props.presets.getItem(this.state.selectedItem);
if (!item) return;
if (item.label !== text) {
let newPresets = this.props.presets.clone();
let newItem = newPresets.getItem(this.state.selectedItem);
if (!newItem) return;
newItem.label = text;
this.updateServerPluginPresets(newPresets);
}
this.setState({ renameOpen: false });
}
getPluginUri() : string {
let pedalboardItem = this.model.pedalboard.get().getItem(this.props.instanceId);
return pedalboardItem.uri;
}
handleCopy() {
let item = this.props.presets.getItem(this.state.selectedItem);
if (!item) return;
this.model.duplicatePluginPreset(this.getPluginUri(),this.state.selectedItem)
.then((newId) => {
this.setState({ selectedItem: newId });
}).catch((error) => {
this.onError(error);
});
}
onError(error: string): void {
this.model?.showAlert(error);
}
render() {
const classes = withStyles.getClasses(this.props);
let actionBarClass = this.props.isEditDialog ? classes.dialogAppBar : classes.dialogActionBar;
let defaultSelectedIndex = this.getSelectedIndex();
let title: string = "";
let pluginUri = this.getPluginUri();
let plugin = this.model.getUiPlugin(pluginUri);
if (plugin) {
title = "Plugin Presets - " + plugin.name;
}
return (
<DialogEx tag="pluginPresets" fullScreen open={this.props.show}
onClose={() => { this.handleDialogClose() }} TransitionComponent={Transition}
style={{userSelect: "none"}}
onEnterKey={()=>{}}
>
<div style={{ display: "flex", flexDirection: "column", flexWrap: "nowrap", width: "100%", height: "100%", overflow: "hidden" }}>
<div style={{ flex: "0 0 auto" }}>
<AppBar className={classes.dialogAppBar} style={{ display: this.isEditMode() ? "none" : "block" }} >
<Toolbar>
<IconButton edge="start" color="inherit" onClick={this.handleDialogClose} aria-label="back"
disabled={this.isEditMode()}
>
<ArrowBackIcon />
</IconButton>
<Typography noWrap variant="h6" className={classes.dialogTitle}>
{title}
</Typography>
<IconButton color="inherit" onClick={(e) => this.showActionBar(true)} >
<EditIcon />
</IconButton>
</Toolbar>
</AppBar>
<AppBar className={actionBarClass} style={{ display: this.isEditMode() ? "block" : "none" }}
onClick={(e) => { e.stopPropagation(); e.preventDefault(); }}
>
<Toolbar>
{(!this.props.isEditDialog) ? (
<IconButton edge="start" color="inherit" onClick={(e) => this.showActionBar(false)} aria-label="close">
<CloseIcon />
</IconButton>
) : (
<IconButton edge="start" color="inherit" onClick={this.handleDialogClose} aria-label="back"
>
<ArrowBackIcon />
</IconButton>
)}
<Typography noWrap variant="h6" className={classes.dialogTitle}>
{ title }
</Typography>
{(this.props.presets.getItem(this.state.selectedItem) != null)
&& (
<div style={{ flex: "0 0 auto" }}>
<Button color="inherit" onClick={(e) => this.handleCopy()}>
Copy
</Button>
<Button color="inherit" onClick={() => this.handleRenameClick()}>
Rename
</Button>
<RenameDialog
open={this.state.renameOpen}
defaultName={this.getSelectedName()}
acceptActionName={"Rename"}
onClose={() => { this.setState({ renameOpen: false }) }}
onOk={(text: string) => {
this.handleRenameOk(text);
}
}
/>
<IconButton color="inherit" onClick={(e) => this.handleDeleteClick()} >
<img src="/img/old_delete_outline_white_24dp.svg" alt="Delete" style={{ width: 24, height: 24, opacity: 0.6 }} />
</IconButton>
<IconButton color="inherit" onClick={(e) => { this.onMoreClick(e) }} >
<MoreVertIcon />
</IconButton>
<Menu
id="more-menu"
anchorEl={this.state.moreMenuAnchorEl}
keepMounted
open={Boolean(this.state.moreMenuAnchorEl)}
onClose={() => this.handleMoreClose()}
TransitionComponent={Fade}
>
<MenuItem onClick={() => { this.handleDownloadPresets(); }}
disabled={this.props.presets.presets.length === 0}
>
<ListItemIcon>
<DownloadIcon className={classes.listIcon} />
</ListItemIcon>
<ListItemText>
Download plugin presets
</ListItemText>
</MenuItem>
<MenuItem onClick={() => { this.handleUploadPresets() }}>
<ListItemIcon>
<UploadIcon className={classes.listIcon} />
</ListItemIcon>
<ListItemText>
Upload plugin presets
</ListItemText>
</MenuItem>
</Menu>
</div>
)
}
</Toolbar>
</AppBar>
</div>
<div style={{ flex: "1 1 auto", position: "relative", overflow: "hidden" }} >
<DraggableGrid
onLongPress={(item) => this.showActionBar(true)}
canDrag={this.isEditMode()}
onDragStart={(index, x, y) => { this.selectItemAtIndex(index) }}
moveElement={(from, to) => { this.moveElement(from, to); }}
scroll={ScrollDirection.Y}
defaultSelectedIndex={defaultSelectedIndex}
>
{
this.props.presets.presets.map((element) => {
return this.mapElement(element);
})
}
</DraggableGrid>
</div>
</div>
<UploadPresetDialog
title="Upload Plugin Presets"
extension='.piPluginPresets'
uploadPage='uploadPluginPresets'
onUploaded={(instanceId) => {} }
uploadAfter={this.state.selectedItem}
open={this.state.openUploadPresetDialog}
onClose={() => { this.setState({ openUploadPresetDialog: false }) }} />
</DialogEx>
);
}
},
styles
);
export default PluginPresetsDialog;
+483
View File
@@ -0,0 +1,483 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { SyntheticEvent,Component } from 'react';
import { css } from '@emotion/react';
import {isDarkMode} from './DarkMode';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import { PiPedalModel, PiPedalModelFactory, PresetIndexEntry, PresetIndex } from './PiPedalModel';
import Button from "@mui/material/Button";
import ButtonBase from "@mui/material/ButtonBase";
import DialogEx from './DialogEx';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import DraggableGrid, { ScrollDirection } from './DraggableGrid';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Fade from '@mui/material/Fade';
import UploadPresetDialog from './UploadPresetDialog';
import SelectHoverBackground from './SelectHoverBackground';
import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import EditIcon from '@mui/icons-material/Edit';
import RenameDialog from './RenameDialog';
import Slide, {SlideProps} from '@mui/material/Slide';
import {createStyles} from './WithStyles';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import DownloadIcon from './svg/file_download_black_24dp.svg?react';
import UploadIcon from './svg/file_upload_black_24dp.svg?react';
interface PresetDialogProps extends WithStyles<typeof styles> {
show: boolean;
isEditDialog: boolean;
onDialogClose: () => void;
};
interface PresetDialogState {
presets: PresetIndex;
showActionBar: boolean;
selectedItem: number;
renameOpen: boolean;
moreMenuAnchorEl: HTMLElement | null;
openUploadPresetDialog: boolean;
};
const styles = (theme: Theme) => createStyles({
listIcon: css({
width: 24, height: 24, opacity: 0.6, fill: theme.palette.text.primary
}),
dialogAppBar: css({
position: 'relative',
top: 0, left: 0
}),
dialogActionBar: css({
position: 'relative',
top: 0, left: 0,
background: "black"
}),
dialogTitle: css({
marginLeft: theme.spacing(2),
flex: 1,
}),
itemBackground: css({
background: theme.palette.background.paper
}),
itemFrame: css({
display: "flex",
flexDirection: "row",
flexWrap: "nowrap",
width: "100%",
height: "56px",
alignItems: "center",
textAlign: "left",
justifyContent: "center",
paddingLeft: 8
}),
iconFrame: css({
flex: "0 0 auto",
}),
itemIcon: css({
width: 24, height: 24, margin: 12, opacity: 0.6
}),
itemLabel: css({
flex: "1 1 1px",
marginLeft: 8
})
});
const Transition = React.forwardRef(function Transition(
props: SlideProps, ref: React.Ref<unknown>
) {
return (<Slide direction="up" ref={ref} {...props} />);
});
const PresetDialog = withStyles(
class extends Component<PresetDialogProps, PresetDialogState> {
model: PiPedalModel;
constructor(props: PresetDialogProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.handleDialogClose = this.handleDialogClose.bind(this);
let presets = this.model.presets.get();
this.state = {
presets: presets,
showActionBar: false,
selectedItem: presets.selectedInstanceId,
renameOpen: false,
moreMenuAnchorEl: null,
openUploadPresetDialog: false
};
this.handlePresetsChanged = this.handlePresetsChanged.bind(this);
}
selectItemAtIndex(index: number) {
let instanceId = this.state.presets.presets[index].instanceId;
this.setState({ selectedItem: instanceId });
}
isEditMode() {
return this.state.showActionBar || this.props.isEditDialog;
}
onMoreClick(e: SyntheticEvent): void {
this.setState({ moreMenuAnchorEl: e.currentTarget as HTMLElement })
}
handleDownloadPreset() {
this.handleMoreClose();
this.model.download("downloadPreset", this.state.selectedItem);
}
handleUploadPreset() {
this.handleMoreClose();
this.setState({ openUploadPresetDialog: true });
}
handleMoreClose(): void {
this.setState({ moreMenuAnchorEl: null });
}
handlePresetsChanged() {
let presets = this.model.presets.get();
if (!presets.areEqual(this.state.presets, false)) // avoid a bunch of peculiar effects if we update while a drag is in progress
{
// if we don't have a valid selection, then use the current preset.
if (this.state.presets.getItem(this.state.selectedItem) == null) {
this.setState({ presets: presets, selectedItem: presets.selectedInstanceId });
} else {
this.setState({ presets: presets });
}
}
}
componentDidMount() {
this.model.presets.addOnChangedHandler(this.handlePresetsChanged);
this.handlePresetsChanged();
// scroll selected item into view.
}
componentWillUnmount() {
this.model.presets.removeOnChangedHandler(this.handlePresetsChanged);
}
getSelectedIndex() {
let instanceId = this.isEditMode() ? this.state.selectedItem : this.state.presets.selectedInstanceId;
let presets = this.state.presets;
for (let i = 0; i < presets.presets.length; ++i) {
if (presets.presets[i].instanceId === instanceId) return i;
}
return -1;
}
handleDeleteClick() {
if (!this.state.selectedItem) return;
let selectedItem = this.state.selectedItem;
if (selectedItem !== -1) {
this.model.deletePresetItem(selectedItem)
.then((selectedItem: number) => {
this.setState({ selectedItem: selectedItem });
})
.catch((error) => {
this.model.showAlert(error);
});
}
}
handleDialogClose() {
this.props.onDialogClose();
}
handleItemClick(instanceId: number): void {
if (this.isEditMode()) {
this.setState({ selectedItem: instanceId });
} else {
this.model.loadPreset(instanceId);
this.props.onDialogClose();
}
}
showActionBar(show: boolean): void {
this.setState({ showActionBar: show });
}
mapElement(el: any): React.ReactNode {
let presetEntry = el as PresetIndexEntry;
const classes = withStyles.getClasses(this.props);
let selectedItem = this.isEditMode() ? this.state.selectedItem : this.state.presets.selectedInstanceId;
return (
<div key={presetEntry.instanceId} className="itemBackground">
<ButtonBase style={{ width: "100%", height: 48 }}
onClick={() => this.handleItemClick(presetEntry.instanceId)}
>
<SelectHoverBackground selected={presetEntry.instanceId === selectedItem} showHover={true} />
<div className={classes.itemFrame}>
<div className={classes.iconFrame}>
<img
src={isDarkMode()? "img/ic_presets_white.svg": "img/ic_presets.svg"}
className={classes.itemIcon} alt="" />
</div>
<div className={classes.itemLabel}>
<Typography>
{presetEntry.name}
</Typography>
</div>
</div>
</ButtonBase>
</div>
);
}
updateServerPresets(newPresets: PresetIndex) {
newPresets = newPresets.clone();
this.model.updatePresets(newPresets)
.catch((error) => {
this.model.showAlert(error);
});
}
moveElement(from: number, to: number): void {
let newPresets = this.state.presets.clone();
newPresets.movePreset(from, to);
this.setState({
presets: newPresets,
selectedItem: newPresets.presets[to].instanceId
});
this.updateServerPresets(newPresets);
}
getSelectedName(): string {
let item = this.state.presets.getItem(this.state.selectedItem);
if (item) return item.name;
return "";
}
handleRenameClick() {
let item = this.state.presets.getItem(this.state.selectedItem);
if (item) {
this.setState({ renameOpen: true });
}
}
handleRenameOk(text: string) {
let item = this.state.presets.getItem(this.state.selectedItem);
if (!item) return;
if (item.name !== text) {
this.model.renamePresetItem(this.state.selectedItem, text)
.catch((error) => {
this.onError(error);
});
}
this.setState({ renameOpen: false });
}
handleCopy() {
let item = this.state.presets.getItem(this.state.selectedItem);
if (!item) return;
this.model.duplicatePreset(this.state.selectedItem)
.then((newId) => {
this.setState({ selectedItem: newId });
}).catch((error) => {
this.onError(error);
});
}
onError(error: string): void {
this.model?.showAlert(error);
}
render() {
const classes = withStyles.getClasses(this.props);
let actionBarClass = this.props.isEditDialog ? classes.dialogAppBar : classes.dialogActionBar;
let defaultSelectedIndex = this.getSelectedIndex();
return (
<DialogEx tag="preset" fullScreen open={this.props.show}
onClose={() => { this.handleDialogClose() }} TransitionComponent={Transition}
style={{userSelect: "none"}}
onEnterKey={()=>{}}
>
<div style={{ display: "flex", flexDirection: "column", flexWrap: "nowrap", width: "100%", height: "100%", overflow: "hidden" }}>
<div style={{ flex: "0 0 auto" }}>
<AppBar className={classes.dialogAppBar} style={{ display: this.isEditMode() ? "none" : "block" }} >
<Toolbar>
<IconButton edge="start" color="inherit" onClick={this.handleDialogClose} aria-label="back"
disabled={this.isEditMode()}
>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6" className={classes.dialogTitle}>
Presets
</Typography>
<IconButton color="inherit" onClick={(e) => this.showActionBar(true)} >
<EditIcon />
</IconButton>
</Toolbar>
</AppBar>
<AppBar className={actionBarClass} style={{ display: this.isEditMode() ? "block" : "none" }}
onClick={(e) => { e.stopPropagation(); e.preventDefault(); }}
>
<Toolbar>
{(!this.props.isEditDialog) ? (
<IconButton edge="start" color="inherit" onClick={(e) => this.showActionBar(false)} aria-label="close">
<CloseIcon />
</IconButton>
) : (
<IconButton edge="start" color="inherit" onClick={this.handleDialogClose} aria-label="back"
>
<ArrowBackIcon />
</IconButton>
)}
<Typography variant="h6" className={classes.dialogTitle}>
Presets
</Typography>
{(this.state.presets.getItem(this.state.selectedItem) != null)
&& (
<div style={{ flex: "0 0 auto" }}>
<Button color="inherit" onClick={(e) => this.handleCopy()}>
Copy
</Button>
<Button color="inherit" onClick={() => this.handleRenameClick()}>
Rename
</Button>
<RenameDialog
open={this.state.renameOpen}
defaultName={this.getSelectedName()}
acceptActionName={"Rename"}
onClose={() => { this.setState({ renameOpen: false }) }}
onOk={(text: string) => {
this.handleRenameOk(text);
}
}
/>
<IconButton color="inherit" onClick={(e) => this.handleDeleteClick()} >
<img src="/img/old_delete_outline_white_24dp.svg" alt="Delete" style={{ width: 24, height: 24, opacity: 0.6 }} />
</IconButton>
<IconButton color="inherit" onClick={(e) => { this.onMoreClick(e) }} >
<MoreVertIcon />
</IconButton>
<Menu
id="more-menu"
anchorEl={this.state.moreMenuAnchorEl}
keepMounted
open={Boolean(this.state.moreMenuAnchorEl)}
onClose={() => this.handleMoreClose()}
TransitionComponent={Fade}
>
<MenuItem onClick={() => { this.handleDownloadPreset(); }} >
<ListItemIcon>
<DownloadIcon className={classes.listIcon} />
</ListItemIcon>
<ListItemText>
Download preset
</ListItemText>
</MenuItem>
<MenuItem onClick={() => { this.handleUploadPreset() }}>
<ListItemIcon>
<UploadIcon className={classes.listIcon} />
</ListItemIcon>
<ListItemText>
Upload preset
</ListItemText>
</MenuItem>
</Menu>
</div>
)
}
</Toolbar>
</AppBar>
</div>
<div style={{ flex: "1 1 auto", position: "relative", overflow: "hidden" }} >
<DraggableGrid
onLongPress={(item) => this.showActionBar(true)}
canDrag={this.isEditMode()}
onDragStart={(index, x, y) => { this.selectItemAtIndex(index) }}
moveElement={(from, to) => { this.moveElement(from, to); }}
scroll={ScrollDirection.Y}
defaultSelectedIndex={defaultSelectedIndex}
>
{
this.state.presets.presets.map((element) => {
return this.mapElement(element);
})
}
</DraggableGrid>
</div>
</div>
<UploadPresetDialog
title='Upload preset'
extension='.piPreset'
uploadPage='uploadPreset'
onUploaded={(instanceId) => this.setState({selectedItem: instanceId}) }
uploadAfter={this.state.selectedItem}
open={this.state.openUploadPresetDialog}
onClose={() => { this.setState({ openUploadPresetDialog: false }) }} />
</DialogEx>
);
}
},
styles
);
export default PresetDialog;
+387
View File
@@ -0,0 +1,387 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { SyntheticEvent, Component } from 'react';
import IconButton from '@mui/material/IconButton';
import { PiPedalModel, PiPedalModelFactory, PresetIndex } from './PiPedalModel';
import SaveIconOutline from '@mui/icons-material/Save';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import {createStyles} from './WithStyles';
import PresetDialog from './PresetDialog';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Fade from '@mui/material/Fade';
import Divider from '@mui/material/Divider';
import RenameDialog from './RenameDialog'
import Select from '@mui/material/Select';
import UploadPresetDialog from './UploadPresetDialog';
import {isDarkMode} from './DarkMode';
interface PresetSelectorProps extends WithStyles<typeof styles> {
}
interface PresetSelectorState {
presets: PresetIndex;
enabled: boolean;
showPresetsDialog: boolean;
showEditPresetsDialog: boolean;
presetsMenuAnchorRef: HTMLElement | null;
renameDialogOpen: boolean;
renameDialogDefaultName: string;
renameDialogActionName: string;
renameDialogOnOk?: (name: string) => void;
openUploadPresetDialog: boolean;
}
const selectColor = isDarkMode()? "#888": "#FFFFFF";
const styles = (theme: Theme) => createStyles({
select: { // fu fu fu.Overrides for white selector on dark background.
'&:before': {
borderColor: selectColor,
},
'&:after': {
borderColor: selectColor,
},
'&:hover:not(.Mui-disabled):before': {
borderColor: selectColor,
}
},
icon: {
fill: selectColor,
},
});
const PresetSelector =
withStyles(
class extends Component<PresetSelectorProps, PresetSelectorState> {
model: PiPedalModel;
constructor(props: PresetSelectorProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
presets:
this.model.presets.get(),
enabled: false,
showPresetsDialog: false,
showEditPresetsDialog: false,
presetsMenuAnchorRef: null,
renameDialogOpen: false,
renameDialogDefaultName: "",
renameDialogActionName: "",
renameDialogOnOk: undefined,
openUploadPresetDialog: false
};
this.handlePresetsChanged = this.handlePresetsChanged.bind(this);
this.handleDialogClose = this.handleDialogClose.bind(this);
this.handlePresetsMenuClose = this.handlePresetsMenuClose.bind(this);
}
handlePresetMenuClick(event: SyntheticEvent): void {
this.setState({ presetsMenuAnchorRef: (event.currentTarget as HTMLElement) });
}
handlePresetsMenuClose(): void {
this.setState({ presetsMenuAnchorRef: null });
}
handleDownloadPreset(e: SyntheticEvent) {
this.handlePresetsMenuClose();
e.preventDefault();
this.model.download("downloadPreset", this.model.presets.get().selectedInstanceId);
}
handleUploadPreset(e: SyntheticEvent) {
this.handlePresetsMenuClose();
e.preventDefault();
this.setState({ openUploadPresetDialog: true });
}
handlePresetsMenuSave(e: SyntheticEvent): void {
this.handlePresetsMenuClose();
e.preventDefault();
this.model.saveCurrentPreset();
}
handlePresetsMenuSaveAs(e: SyntheticEvent): void {
this.handlePresetsMenuClose();
e.stopPropagation();
let currentPresets = this.model.presets.get();
let item = currentPresets.getItem(currentPresets.selectedInstanceId);
if (item == null) return;
let name = item.name;
this.renameDialogOpen(name, "Save As")
.then((newName) => {
return this.model.saveCurrentPresetAs(newName);
})
.then((newInstanceId) => {
})
.catch((error) => {
this.showError(error);
})
;
}
handlePresetsMenuRename(e: SyntheticEvent): void {
this.handlePresetsMenuClose();
e.stopPropagation();
let currentPresets = this.model.presets.get();
let item = currentPresets.getItem(currentPresets.selectedInstanceId);
if (item == null) return;
let name = item.name;
this.renameDialogOpen(name, "Rename")
.then((newName) => {
if (newName === name) return;
return this.model.renamePresetItem(this.model.presets.get().selectedInstanceId, newName);
})
.catch((error) => {
this.showError(error);
})
;
}
handlePresetsMenuNew(e: SyntheticEvent): void {
this.handlePresetsMenuClose();
e.stopPropagation();
let currentPresets = this.model.presets.get();
let item = currentPresets.getItem(currentPresets.selectedInstanceId);
if (item == null) return;
this.model.newPresetItem(currentPresets.selectedInstanceId)
.then((instanceId) => {
this.model.loadPreset(instanceId);
})
.catch((error) => {
this.showError(error);
});
}
showError(error: string) {
this.model.showAlert(error);
}
renameDialogOpen(defaultText: string, acceptButtonText: string): Promise<string> {
let result = new Promise<string>(
(resolve, reject) => {
this.setState(
{
renameDialogOpen: true,
renameDialogDefaultName: defaultText,
renameDialogActionName: acceptButtonText,
renameDialogOnOk: (name) => {
resolve(name);
}
}
);
}
);
return result;
}
handleRenameDialogClose(): void {
this.setState({
renameDialogOpen: false,
renameDialogOnOk: undefined
});
}
handleRenameDialogOk(name: string): void {
let renameDialogOnOk = this.state.renameDialogOnOk;
this.handleRenameDialogClose();
if (renameDialogOnOk) {
renameDialogOnOk(name);
}
}
handleMenuEditPresets(): void {
this.handlePresetsMenuClose();
this.showEditPresetsDialog(true);
}
updatePresetState() {
let presets = this.model.presets.get();
let enabled = presets.presets.length > 0 && presets.selectedInstanceId !== -1;
this.setState(
{
presets: presets,
enabled: enabled,
}
);
}
handleSave() {
this.model.saveCurrentPreset();
}
handlePresetsChanged() {
this.updatePresetState();
}
componentDidMount() {
this.model.presets.addOnChangedHandler(this.handlePresetsChanged);
this.updatePresetState();
}
componentWillUnmount() {
this.model.presets.removeOnChangedHandler(this.handlePresetsChanged)
}
showPresetDialog(show: boolean) {
this.setState({
showPresetsDialog: show,
showEditPresetsDialog: false
});
}
showEditPresetsDialog(show: boolean) {
this.setState({
showPresetsDialog: show,
showEditPresetsDialog: true
});
}
handleDialogClose(): void {
this.showPresetDialog(false);
}
handleChange(event: any, extra: any): void {
// misses click on default.
// this.model.loadPreset(event.target.value as number);
}
handleSelectClose(event: any): void {
let value = event.currentTarget.getAttribute("data-value");
if (value && value.length > 0) {
this.model.loadPreset(parseInt(value));
}
//this.model.loadPreset(event.target.value as number);
}
render() {
//const classes = withStyles.getClasses(this.props);
let presets = this.state.presets;
const classes = withStyles.getClasses(this.props);
return (
<div style={{
marginLeft: 12, display: "flex", flexDirection: "row",
justifyContent: "left", flexWrap: "nowrap", alignItems: "center", height: "100%", position: "relative"
}}>
<div style={{ flex: "0 0 auto" }}>
<IconButton
style={{ flex: "0 0 auto", opacity: this.state.presets.presetChanged ? 1.0 : 0.0, color: "#FFFFFF" }}
onClick={(e) => { this.handleSave(); }}
size="large">
<SaveIconOutline style={{ opacity: 0.75 }} color="inherit" />
</IconButton>
</div>
<div style={{ flex: "1 1 auto", minWidth: 60, maxWidth: 300, position: "relative", paddingRight: 12 }} >
<Select variant="standard"
className={classes.select}
style={{ width: "100%", position: "relative", top: 0, color: "#FFFFFF" }} disabled={!this.state.enabled}
onChange={(e, extra) => this.handleChange(e, extra)}
onClose={(e) => this.handleSelectClose(e)}
displayEmpty
value={presets.selectedInstanceId === 0? '' : presets.selectedInstanceId}
inputProps={{
classes: { icon: classes.icon },
'aria-label': "Select preset"
}
}
>
{
presets.presets.map((preset) => {
return (
<MenuItem key={preset.instanceId} value={preset.instanceId} >
{(presets.presetChanged && preset.instanceId === presets.selectedInstanceId)
? (preset.name + "*")
: (preset.name)
}
</MenuItem>
);
})
}
</Select>
</div>
<div style={{ flex: "0 0 auto"}}>
<IconButton
style={{ flex: "0 0 auto", color: "#FFFFFF" }}
onClick={(e) => this.handlePresetMenuClick(e)}
size="large"
>
<MoreVertIcon style={{ opacity: 0.75 }} color="inherit" />
</IconButton>
<Menu
id="edit-presets-menu"
anchorEl={this.state.presetsMenuAnchorRef}
open={Boolean(this.state.presetsMenuAnchorRef)}
onClose={() => this.handlePresetsMenuClose()}
TransitionComponent={Fade}
>
<MenuItem onClick={(e) => this.handlePresetsMenuSave(e)}>Save preset</MenuItem>
<MenuItem onClick={(e) => this.handlePresetsMenuSaveAs(e)}>Save preset as...</MenuItem>
<MenuItem onClick={(e) => this.handlePresetsMenuRename(e)}>Rename...</MenuItem>
<MenuItem onClick={(e) => this.handlePresetsMenuNew(e)}>New...</MenuItem>
<Divider />
<MenuItem onClick={(e) => { this.handleDownloadPreset(e); }} >Download preset</MenuItem>
<MenuItem onClick={(e) => { this.handleUploadPreset(e) }}>Upload preset</MenuItem>
<Divider />
<MenuItem onClick={(e) => this.handleMenuEditPresets()}>Manage presets...</MenuItem>
</Menu>
</div>
<PresetDialog show={this.state.showPresetsDialog} isEditDialog={this.state.showEditPresetsDialog} onDialogClose={() => this.handleDialogClose()} />
<RenameDialog open={this.state.renameDialogOpen}
defaultName={this.state.renameDialogDefaultName}
acceptActionName={this.state.renameDialogActionName}
onClose={() => this.handleRenameDialogClose()}
onOk={(name: string) => this.handleRenameDialogOk(name)} />
<UploadPresetDialog
title='Upload preset'
extension='.piPreset'
uploadPage='uploadPreset'
onUploaded={(instanceId) => { this.model.loadPreset(instanceId); }}
open={this.state.openUploadPresetDialog}
uploadAfter={-1}
onClose={() => { this.setState({ openUploadPresetDialog: false }) }} />
</div>
);
}
},
styles);
export default PresetSelector;
+111
View File
@@ -0,0 +1,111 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import Radio from '@mui/material/Radio';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import DialogEx from './DialogEx';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
export interface RadioSelectDialogProps {
open: boolean,
title: string,
width: number,
selectedItem: string,
items: string[],
onOk: (selectedItem: string) => void,
onClose: () => void
};
export interface RadioSelectDialogState {
fullScreen: boolean;
};
export default class RadioSelectDialog extends ResizeResponsiveComponent<RadioSelectDialogProps, RadioSelectDialogState> {
refText: React.RefObject<HTMLInputElement|null>;
constructor(props: RadioSelectDialogProps) {
super(props);
this.state = {
fullScreen: false
};
this.refText = React.createRef<HTMLInputElement>();
}
mounted: boolean = false;
onWindowSizeChanged(width: number, height: number): void {
this.setState({ fullScreen: height < 200 })
}
componentDidMount() {
super.componentDidMount();
this.mounted = true;
}
componentWillUnmount() {
super.componentWillUnmount();
this.mounted = false;
}
componentDidUpdate() {
}
render() {
return (
<DialogEx tag="list" onClose={()=>this.props.onClose()} open={this.props.open}
onEnterKey={()=>{ }}
>
<List sx={{pt: 0}}>
{
this.props.items.map(
(value: string, index: number) => {
return (
<ListItem key={index} >
<ListItemButton role={undefined} onClick={()=> this.props.onOk(value)} dense>
<ListItemIcon>
<Radio
edge="start"
checked={value === this.props.selectedItem}
disableRipple
/>
</ListItemIcon>
<ListItemText primary={value} />
</ListItemButton>
</ListItem>
);
}
)
}
</List>
</DialogEx>
);
}
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
const LARGE_NUMBER = 0x1FFFFFFF;
const EMPTY_WIDTH = -LARGE_NUMBER * 2;
class Rect {
x: number;
y: number;
width: number;
height: number;
constructor(x: number = LARGE_NUMBER, y: number = LARGE_NUMBER, width: number = EMPTY_WIDTH, height: number = EMPTY_WIDTH)
{
this.x = x; this.y = y; this.width = width; this.height = height;
}
toString() { return '{' + this.x + "," + this.y + " " + this.width + "," + this.height +"}"; }
copy(): Rect {
let result = new Rect();
result.x = this.x;
result.y = this.y;
result.width = this.width;
result.height = this.height;
return result;
}
contains(x: number, y: number)
{
return (x >= this.x)
&& x < this.x+this.width
&& y >= this.y
&& y < this.y + this.height;
}
get right(): number {
return this.x + this.width;
}
get bottom(): number {
return this.y + this.height;
}
isEmpty(): boolean {
return this.width === 0 || this.height === 0;
}
clear(): void {
this.x = LARGE_NUMBER;
this.y = LARGE_NUMBER;
this.width = EMPTY_WIDTH;
this.height = EMPTY_WIDTH;
}
accumulate(rect: Rect): void {
let right = Math.max(rect.x + rect.width, this.x + this.width);
let bottom = Math.max(rect.y + rect.height, this.y + this.height);
this.x = Math.min(rect.x, this.x);
this.y = Math.min(rect.y, this.y);
this.width = right - this.x;
this.height = bottom - this.y;
}
union(rect: Rect): Rect {
let result = new Rect();
result.x = Math.min(this.x, rect.x);
result.y = Math.min(this.y, rect.y);
result.width = Math.max(this.x + this.width, rect.x + rect.width) - result.x;
result.height = Math.max(this.y + this.height, rect.x + rect.height) - result.y;
return result;
}
offset(xOffset: number, yOffset: number) {
this.x += xOffset;
this.y += yOffset;
}
}
export default Rect;
+32
View File
@@ -0,0 +1,32 @@
export default class Rectangle {
top: number;
left: number;
width: number;
height: number;
constructor(top?: number, left?: number, width?: number, height?: number) {
this.top = top ? top: 0;
this.left = left ? left: 0;
this.width = width ? width: 0;
this.height = height ? height: 0;
}
get right(): number {
return this.left + this.width;
}
get bottom(): number {
return this.top + this.height;
}
set right(value: number) {
this.width = value - this.left;
}
set bottom(value: number) {
this.height = value - this.top;
}
};
+152
View File
@@ -0,0 +1,152 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import Button from '@mui/material/Button';
import DialogEx from './DialogEx';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import { nullCast } from './Utility';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import {PiPedalModel,PiPedalModelFactory} from './PiPedalModel';
//import TextFieldEx from './TextFieldEx';
import TextField from '@mui/material/TextField';
export interface RenameDialogProps {
open: boolean,
defaultName: string,
acceptActionName: string,
onOk: (text: string) => void,
onClose: () => void
};
export interface RenameDialogState {
fullScreen: boolean;
};
export default class RenameDialog extends ResizeResponsiveComponent<RenameDialogProps, RenameDialogState> {
refText: React.RefObject<HTMLInputElement|null>;
constructor(props: RenameDialogProps) {
super(props);
this.state = {
fullScreen: false
};
this.refText = React.createRef<HTMLInputElement>();
}
mounted: boolean = false;
onWindowSizeChanged(width: number, height: number): void
{
this.setState({fullScreen: height < 200})
}
componentDidMount()
{
super.componentDidMount();
this.mounted = true;
}
componentWillUnmount()
{
super.componentWillUnmount();
this.mounted = false;
}
componentDidUpdate()
{
}
checkForIllegalCharacters(filename: string) {
if (filename.indexOf('/') !== -1)
{
throw new Error("Illegal character: '/'");
}
}
render() {
let props = this.props;
let { open, defaultName, acceptActionName, onClose, onOk } = props;
const handleClose = () => {
onClose();
};
const handleOk = () => {
let text = nullCast(this.refText.current).value;
text = text.trim();
try {
this.checkForIllegalCharacters(text);
} catch (e:any)
{
let model:PiPedalModel = PiPedalModelFactory.getInstance();
model.showAlert(e.toString());
return;
}
if (text.length === 0) return;
onOk(text);
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
// 'keypress' event misbehaves on mobile so we track 'Enter' key via 'keydown' event
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
handleOk();
}
};
return (
<DialogEx tag="nameDialog" open={open} fullWidth maxWidth="sm" onClose={handleClose} aria-labelledby="Rename-dialog-title"
fullScreen={this.state.fullScreen}
style={{userSelect: "none"}}
onEnterKey={()=>{}}
>
<DialogContent style={{minHeight: 96}}>
<TextField
onKeyDown={handleKeyDown}
margin="dense"
variant="outlined"
slotProps={{
input: {
style: { scrollMargin: 24 }
}
}}
id="name"
label="Name"
type="text"
fullWidth
defaultValue={defaultName}
inputRef={this.refText}
/>
</DialogContent>
<DialogActions style={{flexShrink: 1}}>
<Button onClick={handleClose} variant="dialogSecondary" >
Cancel
</Button>
<Button onClick={handleOk} variant="dialogPrimary" >
{acceptActionName}
</Button>
</DialogActions>
</DialogEx>
);
}
}
@@ -0,0 +1,63 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
class ResizeResponsiveComponent<PROPS={},STATE={},S=any> extends React.Component<PROPS,STATE,S> {
constructor(props: PROPS) {
super(props);
this.handleWindowResize = this.handleWindowResize.bind(this);
this.windowSize.width = window.innerWidth;
this.windowSize.height = window.innerHeight;
}
windowSize: {width: number,height:number} = {width: 1280, height: 1024};
onWindowSizeChanged(width: number, height: number): void {
}
handleWindowResize() {
let width_ = window.innerWidth;
let height_ = window.innerHeight;
if (width_ !== this.windowSize.width || height_ !== this.windowSize.height)
{
this.windowSize = {width: width_, height: height_};
this.onWindowSizeChanged(this.windowSize.width,this.windowSize.height);
}
}
componentDidMount() {
window.addEventListener('resize', this.handleWindowResize);
let width_ = window.innerWidth;
let height_ = window.innerHeight;
this.windowSize = {width: width_, height: height_};
this.onWindowSizeChanged(this.windowSize.width,this.windowSize.height);
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleWindowResize);
}
}
export default ResizeResponsiveComponent;
+153
View File
@@ -0,0 +1,153 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { KeyboardEvent } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles, {withTheme} from './WithStyles';
import { withStyles } from "tss-react/mui";
import {createStyles} from './WithStyles';
import Input from '@mui/material/Input';
import IconButton from '@mui/material/IconButton';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import InputAdornment from '@mui/material/InputAdornment';
const styles = (theme: Theme) => createStyles({
});
interface SearchControlProps extends WithStyles<typeof styles> {
theme: Theme,
collapsed?: boolean;
searchText: string;
onTextChanged: (searchString: string) => void;
onClick: () => void;
onClearFilterClick: () => void;
onApplyFilter: () => void;
inputRef?: React.RefObject<HTMLInputElement|null>;
showSearchIcon?: boolean
}
interface SearchControlState {
}
const SearchControl = withTheme(withStyles(
class extends React.Component<SearchControlProps, SearchControlState> {
defaultInputRef: React.RefObject<HTMLInputElement|null>;
constructor(props: SearchControlProps) {
super(props);
this.state = {
};
this.defaultInputRef = React.createRef<HTMLInputElement>();
}
getInputRef(): React.RefObject<HTMLInputElement|null> {
if (this.props.inputRef) {
return this.props.inputRef;
}
return this.defaultInputRef;
}
onKeyDown(ev: KeyboardEvent)
{
ev.stopPropagation(); // don't let the ENTER key propagate propagate the enclosing dialog Enter key handler.
if (ev.key === "Enter")
{
this.props.onApplyFilter();
} else if (ev.key === "Escape")
{
this.props.onClearFilterClick();
}
}
componentDidUpdate(oldProps: SearchControlProps) {
if (oldProps.collapsed ?? false !== this.props.collapsed ?? false) {
let inputRef = this.getInputRef();
if (inputRef.current) {
if (!(this.props.collapsed ?? false)) {
setTimeout(() => {
let inputRef = this.getInputRef();
if (inputRef.current) {
inputRef.current.focus();
}
}, 0);
} else {
inputRef.current.value = "";
this.props.onTextChanged("");
}
}
}
}
render() {
return (
<div style={{ display: "flex", flexFlow: "row nowrap", justifyContent: "flex-end", alignItems: "center" }}>
{(this.props.showSearchIcon ?? true) && (
<IconButton onClick={() => { this.props.onClick(); }} size="large">
<SearchIcon />
</IconButton>
)
}
<Input spellCheck={false}
onKeyDown={(ev)=>{this.onKeyDown(ev);}}
style={{
flex: "1 1", visibility: (this.props.collapsed ?? false) ? "collapse" : "visible", marginRight: 12, height: 32,
}}
inputRef={this.getInputRef()}
defaultValue={this.props.searchText}
placeholder="Search"
endAdornment={(
<InputAdornment position="end">
<IconButton
aria-label="clear search filter"
onClick={() => {
this.props.onClearFilterClick();
let inputRef = this.getInputRef();
if (inputRef.current) {
inputRef.current.value = "";
}
}}
edge="end"
size="large">
<ClearIcon fontSize='small' style={{ opacity: 0.6 }} />
</IconButton>
</InputAdornment>
)}
onChange={
(e) => {
this.props.onTextChanged(e.target.value);
}
}
/>
</div>
);
}
},
styles
));
export default SearchControl;
+74
View File
@@ -0,0 +1,74 @@
let escapedCharacters = /[^a-zA-Z0-9]/g;
function stringToRegex(text: string): RegExp
{
text = text.replace(escapedCharacters,"\\$&");
return new RegExp(text,"i");
}
class SearchFilter {
regexes: RegExp[];
constructor(text: string)
{
let regexes: RegExp[] = [];
let bits = text.split(' ');
for (let bit of bits)
{
if (bit.length !== 0)
{
regexes.push(stringToRegex(bit));
}
}
this.regexes = regexes;
}
score(...strings: string[]): number {
let score = 0;
if (this.regexes.length === 0) return 1;
for (let r = 0; r < this.regexes.length; ++r)
{
let regex = this.regexes[r];
regex.lastIndex = 0;
let matched = false;
for (let i = 0; i < strings.length; ++i)
{
let s = strings[i];
let m = regex.exec(s);
if (m) {
matched = true;
if (m.index === 0)
{
if (r === 0 && i === 0) {
score += 16;
} else {
score += 8;
}
}
let end = m.index + m[0].length;
if (end === s.length || (end < s.length && s[end] === ' ')) // word boundary
{
score += 2;
} else {
score += 1;
}
break;
}
}
if (!matched) return 0;
}
return score;
}
};
export default SearchFilter;
+191
View File
@@ -0,0 +1,191 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { useState } from 'react';
import Button from '@mui/material/Button';
import List from '@mui/material/List';
import ListItemText from '@mui/material/ListItemText';
import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import DialogEx from './DialogEx';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import { ListItemButton } from '@mui/material';
export interface SelectChannelsDialogProps {
open: boolean;
selectedChannels: string[];
availableChannels: string[];
onClose: (selectedChannels: string[] | null) => void;
}
function isChecked(selectedChannels: string[], channel: string): boolean {
for (let i = 0; i < selectedChannels.length; ++i) {
if (selectedChannels[i] === channel) return true;
}
return false;
}
function addPort(availableChannels: string[], selectedChannels: string[], newChannel: string) {
let result: string[] = [];
for (let i = 0; i < availableChannels.length; ++i) {
let channel = availableChannels[i];
if (isChecked(selectedChannels, channel) || channel === newChannel) {
result.push(channel);
}
}
return result;
}
function removePort(selectedChannels: string[], channel: string) {
let result: string[] = [];
for (let i = 0; i < selectedChannels.length; ++i) {
if (selectedChannels[i] !== channel) {
result.push(selectedChannels[i]);
}
}
return result;
}
function makeChannelName(id: string)
{
let pos = id.lastIndexOf("_");
if (pos === -1)
{
return id;
}
let i = Number(id.substring(pos+1));
return "Channel " + (i+1);
}
function SelectChannelsDialog(props: SelectChannelsDialogProps) {
//const classes = useStyles();
const { onClose, selectedChannels, availableChannels, open } = props;
const [currentSelection, setCurrentSelection] = useState(selectedChannels);
let showCheckboxes = availableChannels.length !== 2;
if (showCheckboxes) {
let toggleSelect = (value: string) => {
if (!isChecked(currentSelection, value)) {
setCurrentSelection(addPort(availableChannels, currentSelection, value));
} else {
setCurrentSelection(removePort(currentSelection, value));
}
};
const handleClose = (): void => {
onClose(null);
};
const handleOk = (): void => {
if (currentSelection.length >= 1 && currentSelection.length <= 2)
{
onClose(currentSelection);
}
};
return (
<DialogEx tag="audioChannels" onClose={handleClose} aria-labelledby="select-channels-title" open={open} fullWidth maxWidth="xs"
onEnterKey={handleOk}
>
<DialogTitle id="simple-dialog-title">Select Channels</DialogTitle>
<List>
{availableChannels.map((channel) => (
<ListItemButton key={channel}>
<FormControlLabel
control={
<Checkbox checked={isChecked(currentSelection, channel)} onClick={() => toggleSelect(channel)} style={{marginLeft: 24}} />
}
label={makeChannelName(channel)}
/>
</ListItemButton>
)
)}
</List>
<DialogActions>
<Button onClick={handleClose} variant="dialogSecondary" >
Cancel
</Button>
<Button onClick={handleOk} variant="dialogPrimary" disabled={currentSelection.length === 0 || currentSelection.length > 2} >
OK
</Button>
</DialogActions>
</DialogEx>
);
} else {
const handleCancel = () => {
onClose(null);
}
const handleOk = (channels: string[]) => {
onClose(channels);
};
const handleListItemClick = (value: string) => {
switch (value) {
case "Stereo":
handleOk(availableChannels);
break;
case "Left":
handleOk([availableChannels[0]]);
break;
case "Right":
handleOk([availableChannels[1]]);
break;
case "None":
default:
handleOk([]);
}
};
let selectionKey = "None";
if (currentSelection.length === 2) {
selectionKey = "Stereo";
} else if (currentSelection.length === 1) {
if (currentSelection[0] === availableChannels[0]) {
selectionKey = "Left";
} else {
selectionKey = "Right";
}
}
return (
<DialogEx tag="channels" onClose={handleCancel} aria-labelledby="select-channels-title" open={open}
onEnterKey={()=>{}}
>
<List style={{ marginLeft: 0, marginRight: 0}}>
<ListItemButton onClick={() => handleListItemClick("Stereo")} key={"Stereo"} selected={selectionKey === "Stereo"} >
<ListItemText primary={"Stereo"} />
</ListItemButton>
<ListItemButton onClick={() => handleListItemClick("Left")} key={"LeftChannel"} selected={selectionKey === "Left"} >
<ListItemText primary={"Mono (left channel only)"} />
</ListItemButton>
<ListItemButton onClick={() => handleListItemClick("Right")} key={"RightChannel"} selected={selectionKey === "Right"}>
<ListItemText primary={"Mono (right channel only"} />
</ListItemButton>
</List>
</DialogEx>
);
}
}
export default SelectChannelsDialog;
+126
View File
@@ -0,0 +1,126 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React,{ Component, SyntheticEvent } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { css } from '@emotion/react';
const styles = ({ palette }: Theme) => createStyles({
frame: css({
position: "absolute",
width: "100%",
height: "100%"
}),
hover: css({
position: "absolute",
width: "100%",
height: "100%",
background: palette.action.hover,
//opacity: palette.action.hoverOpacity
}),
selected: css({
position: "absolute",
width: "100%",
height: "100%",
background: palette.action.selected,
//opacity: palette.action.selectedOpacity
})
});
interface HoverProps extends WithStyles<typeof styles> {
selected: boolean;
showHover?: boolean;
borderRadius?: number;
children?: React.ReactNode;
}
interface HoverState {
}
export const SelectHoverBackground =
withStyles(
class extends Component<HoverProps, HoverState>
{
constructor(props: HoverProps) {
super(props);
this.state = {
};
this.selfRef = React.createRef<HTMLDivElement>();
this.hoverElementRef = React.createRef<HTMLDivElement>();
this.handleMouseOver = this.handleMouseOver.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
}
handleMouseOver(e: SyntheticEvent): void {
if (this.props.showHover ?? true) {
if (this.hoverElementRef.current)
{
this.hoverElementRef.current.style.display = "block";
}
}
}
handleMouseLeave(e: SyntheticEvent): void {
if (this.props.showHover ?? true) {
if (this.hoverElementRef.current)
{
this.hoverElementRef.current.style.display = "none";
}
}
}
private selfRef: React.RefObject<HTMLDivElement| null>;
private hoverElementRef: React.RefObject<HTMLDivElement| null>;
render() {
const classes = withStyles.getClasses(this.props);
let isSelected = this.props.selected;
return (<div className={classes.frame} ref={this.selfRef}
onMouseOver={(e) => { this.handleMouseOver(e); }} onMouseLeave={(e) => { this.handleMouseLeave(e) }}
>
<div className={classes.selected} style={{ display: (isSelected ? "block" : "none"), borderRadius: this.props.borderRadius }} />
<div ref={this.hoverElementRef} className={classes.hover} style={{ display: "none", borderRadius: this.props.borderRadius }} />
{
this.props.children
}
</div>);
}
},
styles
);
export default SelectHoverBackground;
@@ -0,0 +1,126 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { useState } from 'react';
import Button from '@mui/material/Button';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import {AlsaMidiDeviceInfo} from './AlsaMidiDeviceInfo';
import DialogEx from './DialogEx';
export interface SelectMidiChannelsDialogProps {
open: boolean;
selectedChannels: AlsaMidiDeviceInfo[];
availableChannels: AlsaMidiDeviceInfo[];
onClose: (selectedChannels: AlsaMidiDeviceInfo[] | null) => void;
}
function isChecked(selectedChannels: AlsaMidiDeviceInfo[], channel: AlsaMidiDeviceInfo): boolean {
for (let i = 0; i < selectedChannels.length; ++i) {
if (selectedChannels[i].equals(channel)) return true;
}
return false;
}
function addPort(availableChannels: AlsaMidiDeviceInfo[], selectedChannels: AlsaMidiDeviceInfo[], newChannel: AlsaMidiDeviceInfo)
:AlsaMidiDeviceInfo[]
{
let result: AlsaMidiDeviceInfo[] = [];
for (let i = 0; i < availableChannels.length; ++i) {
let channel = availableChannels[i];
if (isChecked(selectedChannels, channel) || channel.equals(newChannel)) {
result.push(channel);
}
}
return result;
}
function removePort(selectedChannels: AlsaMidiDeviceInfo[], channel: AlsaMidiDeviceInfo)
:AlsaMidiDeviceInfo[]
{
let result: AlsaMidiDeviceInfo[] = [];
for (let i = 0; i < selectedChannels.length; ++i) {
if (!selectedChannels[i].equals(channel)) {
result.push(selectedChannels[i]);
}
}
return result;
}
function SelectMidiChannelsDialog(props: SelectMidiChannelsDialogProps) {
//const classes = useStyles();
const { onClose, selectedChannels, availableChannels, open } = props;
const [currentSelection, setCurrentSelection] = useState(selectedChannels);
let toggleSelect = (value: AlsaMidiDeviceInfo) => {
if (!isChecked(currentSelection, value)) {
setCurrentSelection(addPort(availableChannels, currentSelection, value));
} else {
setCurrentSelection(removePort(currentSelection, value));
}
};
const handleClose = (): void => {
onClose(null);
};
const handleOk = (): void => {
onClose(currentSelection);
};
return (
<DialogEx tag="midiChannels" onClose={handleClose} aria-labelledby="select-channels-title" open={open}
onEnterKey={handleOk}
>
<DialogTitle id="simple-dialog-title">Select MIDI Device</DialogTitle>
<List>
{availableChannels.map((channel) => (
<ListItemButton>
<FormControlLabel
control={
<Checkbox checked={isChecked(currentSelection, channel)} onClick={() => toggleSelect(channel)} key={channel.name} />
}
label={channel.description}
/>
</ListItemButton>
)
)}
</List>
<DialogActions>
<Button onClick={handleClose} variant="dialogSecondary" >
Cancel
</Button>
<Button onClick={handleOk} variant="dialogPrimary" >
OK
</Button>
</DialogActions>
</DialogEx>
);
}
export default SelectMidiChannelsDialog;
+95
View File
@@ -0,0 +1,95 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// Copyright (c) 2023 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { useState } from 'react';
import Button from '@mui/material/Button';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import DialogEx from './DialogEx';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormControl from '@mui/material/FormControl';
import RadioGroup from '@mui/material/RadioGroup';
import Radio from '@mui/material/Radio';
import {ColorTheme} from './DarkMode';
export interface SelectThemesDialogProps {
open: boolean;
defaultTheme: ColorTheme;
onClose: () => void;
onOk: (value: ColorTheme) => void;
}
function SelectThemesDialog(props: SelectThemesDialogProps) {
//const classes = useStyles();
const { onClose, defaultTheme, open,onOk } = props;
const [ selectedTheme, setSelectedTheme ] = useState(defaultTheme);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
let value = ColorTheme.Light;
if ((event.target as HTMLInputElement).value === '1')
{
value = ColorTheme.Dark;
} else if ((event.target as HTMLInputElement).value === '2')
{
value = ColorTheme.System;
}
setSelectedTheme(value);
};
const handleClose = (): void => {
onClose();
};
const handleOk = (): void => {
onOk(selectedTheme)
};
return (
<DialogEx tag="theme" onClose={handleClose} open={open}
onEnterKey={handleOk}
>
<DialogTitle id="simple-dialog-title">Theme</DialogTitle>
<DialogContent>
<FormControl>
<RadioGroup
defaultValue={defaultTheme}
onChange={handleChange}
>
<FormControlLabel value={ColorTheme.Light} control={<Radio size='small' />} label="Light" />
<FormControlLabel value={ColorTheme.Dark} control={<Radio size='small' />} label="Dark" />
<FormControlLabel value={ColorTheme.System} control={<Radio size='small' />} label="Use system setting" />
</RadioGroup>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} variant="dialogSecondary" >
Cancel
</Button>
<Button onClick={handleOk} variant="dialogPrimary" >
OK
</Button>
</DialogActions>
</DialogEx>
);
}
export default SelectThemesDialog;
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
/*
* MIT License
*
* Copyright (c) 2024 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import ButtonBase from '@mui/material/ButtonBase';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import SaveIconOutline from '@mui/icons-material/Save';
import Button from "@mui/material/Button";
import EditIconOutline from '@mui/icons-material/Edit';
import { Snapshot } from './Pedalboard';
import { isDarkMode } from './DarkMode';
import { colorKeys, getBackgroundColor, getBorderColor } from './MaterialColors';
import { PiPedalModel, PiPedalModelFactory, SnapshotModifiedEvent } from './PiPedalModel';
export interface SnapshotButtonProps {
snapshot: Snapshot | null;
snapshotIndex: number;
selected: boolean;
largeText: boolean;
collapseButtons: boolean;
onEditSnapshot: (index: number) => void;
onSaveSnapshot: (index: number) => void;
onSelectSnapshot: (index: number) => void;
};
export interface SnapshotButtonState {
modified: boolean;
uiScale: number;
};
export default class SnapshotButton extends ResizeResponsiveComponent<SnapshotButtonProps, SnapshotButtonState> {
private model: PiPedalModel;
constructor(props: SnapshotButtonProps) {
super(props);
this.state = {
modified: this.props.snapshot?.isModified ?? false,
uiScale: 1.0
};
this.model = PiPedalModelFactory.getInstance();
this.onSnapshotModified = this.onSnapshotModified.bind(this);
}
is2x3Layout(width: number, height: number) {
return width * 2 < height * 3;
}
onWindowSizeChanged(width: number, height: number): void {
let uiScale: number;
if (this.is2x3Layout(width,height)) {
uiScale = width / 400;
} else {
uiScale = (width/3)/(400/2);
}
if (height < width) {
// 1 at 400, 2 at 600
let hUiScale = (height-340)/(400-340);
hUiScale += (height-400)/(460-400);
uiScale = Math.min(hUiScale,uiScale);
}
if (uiScale < 1) uiScale = 1;
if (uiScale > 4) uiScale = 4;
this.setState({uiScale: uiScale});
}
onSnapshotModified(event: SnapshotModifiedEvent) {
if (event.snapshotIndex === this.props.snapshotIndex) {
this.setState({ modified: event.modified });
}
}
componentDidMount(): void {
super.componentDidMount?.();
this.model.onSnapshotModified.addEventHandler(this.onSnapshotModified);
}
componentWillUnmount(): void {
this.model.onSnapshotModified.removeEventHandler(this.onSnapshotModified);
super.componentWillUnmount();
}
componentDidUpdate(
prevProps: Readonly<SnapshotButtonProps>,
prevState: Readonly<SnapshotButtonState>): void {
super.componentDidUpdate?.(prevProps, prevState);
if (this.props.snapshot !== prevProps.snapshot) {
this.setState({
modified: this.props.snapshot?.isModified ?? false
})
}
}
render() {
let { snapshot, snapshotIndex, selected } = this.props;
let { modified, uiScale } = this.state;
// (snapshot: Snapshot | null, index: number) {
//let state = this.state;
let color: string;
let bordercolor: string;
if (snapshot) {
if (colorKeys.indexOf(snapshot.color) === -1) {
bordercolor = color = snapshot.color;
} else {
color = getBackgroundColor(snapshot.color);
bordercolor = getBorderColor(snapshot.color);
}
} else {
bordercolor = color = getBackgroundColor("grey");
}
let title = snapshot ? snapshot.name : "<unassigned>";
if (modified) {
title = title + "*";
}
let disabled = snapshot === null;
let fontSize = (16*uiScale) + "px";
return (
<div style={{ display: "flex", position: "relative", flexGrow: 1, flexBasis: 1, flexFlow: "column nowrap", alignItems: "stretch", borderRadius: 16 }}>
<ButtonBase style={{
display: "flex", flexGrow: 1, flexBasis: 1, flexFlow: "column nowrap", alignItems: "stretch", borderRadius: 16,
background: color,
boxShadow: "1px 3px 6px #00000090"
}}
onMouseDown={(ev) => {
if (!selected) ev.stopPropagation();
}}
onClick={() => { if (snapshot) this.props.onSelectSnapshot(snapshotIndex); }}
>
{/* select background mask*/}
<div style={{position: "absolute", left:0,top:0,right:0,bottom:0, visibility: this.props.selected ? "visible": "hidden",
background: isDarkMode() ? "#FFFFFF": "#000000", borderRadius: 16,
opacity: 0.2
}}
/>
<div
style={{ flexGrow: 1, flexShrink: 1, display: "flex", flexFlow: "column nowrap", alignItems: "stretch" }}
onMouseDown={(ev) => {
if (disabled) ev.stopPropagation();
}}
>
<div style={{
flexGrow: 1, flexShrink: 1, display: "flex", flexFlow: "column nowrap", alignItems: "stretch", justifyContent: "center", margin: 6,
borderColor: selected ? bordercolor : "transparent", borderStyle: "solid", borderWidth: 3, borderRadius: 10
}}
>
<div style={{ flexGrow: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
<Typography display="block" color="textPrimary" align="center" variant="body1"
style={{ fontSize: fontSize }}
>{title}</Typography>
</div>
<Typography variant="h3"
style={{
position: "absolute",bottom: 0, left: 0, paddingBottom: 12, paddingLeft: 16, paddingRight: 12,
pointerEvents: "none", opacity: 0.35, fontSize: "44px", fontWeight: 900, fontFamily: "Arial Black", fontStyle: "italic"
}}
>
{(snapshotIndex + 1).toString()}
</Typography>
<div style={{ height: 54, flexGrow: 0, flexShrink: 0 }}>
{/* placeholder where we will float the buttons, which can't be witin another button (tons of DOM validation warning messages in chrome) */}
</div>
</div>
</div>
</ButtonBase >
<div style={{ flexGrow: 0, position: "absolute", bottom: 0, right: 0, paddingBottom: 12, marginRight: 12 }} >
{!this.props.collapseButtons ? (
<div style={{ marginRight: 8, display: "flex", flexFlow: "row nowrap" }}>
<Button variant="dialogSecondary" startIcon={<SaveIconOutline />} color="inherit" style={{ textTransform: "none" }}
onMouseDown={(ev) => { ev.stopPropagation(); /*don't prop to card*/ }}
onMouseUp={(ev) => { ev.stopPropagation(); /*don't prop to card*/ }}
disabled={false}
onClick={(ev) => { ev.stopPropagation(); this.props.onSaveSnapshot(snapshotIndex); }}
>
Save
</Button>
<Button variant="dialogSecondary" color="inherit" startIcon={<EditIconOutline />} style={{ textTransform: "none" }}
onMouseDown={(ev) => { ev.stopPropagation(); /*don't prop to card*/ }}
onMouseUp={(ev) => { ev.stopPropagation(); /*don't prop to card*/ }}
disabled={disabled}
onClick={(ev) => { ev.stopPropagation(); this.props.onEditSnapshot(snapshotIndex); }}
>
Edit
</Button>
</div>
) : (
<div style={{ marginLeft: "auto", marginRight: 8, display: "flex", flexFlow: "row nowrap" }}>
<div style={{ flexGrow: 1, flexBasis: 1 }} ></div>
<IconButton color="inherit" style={{ opacity: 0.66 }} disabled={false}
onMouseDown={(ev) => { ev.stopPropagation(); /*don't prop to card*/ }}
onClick={() => { this.props.onSaveSnapshot(snapshotIndex); }}
>
<SaveIconOutline />
</IconButton>
<IconButton color="inherit" disabled={disabled} style={{ opacity: 0.66 }}
onMouseDown={(ev) => { ev.stopPropagation(); /*don't prop to card*/ }}
onClick={() => { this.props.onEditSnapshot(snapshotIndex); }}
>
<EditIconOutline />
</IconButton>
</div>
)}
</div>
</div >
);
}
}
+54
View File
@@ -0,0 +1,54 @@
/*
* MIT License
*
* Copyright (c) 2024 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// const darkcolors: string[] =
// [
// "#b71C1C","#880E4F","#4A148c","#311B92","#1A237E","#0D47A1","#01579B","#006064","#004D40"
// ];
// const lighcolors: string[] = [
// "#FFcDD2","#F8BBd0","#E1BEE7","#D1c4E9","#C5CAE9","#BBDEFB","#B3E5FC","#B2EbF2","B2DFDB"
// ];
export function getLightSnapshotcolors() {
let result:string[] = [];
for (let h = 0; h < 360; h += 30)
{
result.push("hsl(" + h + ",100%,79%)");
}
return result;
}
export function getDarkSnapshotColors() {
let result:string[] = [];
for (let h = 0; h < 360; h += 30)
{
result.push("hsl(" + h + ",90%,20%)");
}
return result;
}
export function getSnapshotColors() {
return getDarkSnapshotColors();
}
+134
View File
@@ -0,0 +1,134 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
* MIT License
*
* Copyright (c) 2024 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from 'react';
import Typography from '@mui/material/Typography';
import DialogEx from './DialogEx';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import IconButton from '@mui/material/IconButton';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { TransitionProps } from '@mui/material/transitions';
import Fade from '@mui/material/Fade';
import Slide from '@mui/material/Slide';
import SnapshotPanel from './SnapshotPanel';
const FadeTransition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement<any, any>;
},
ref: React.Ref<unknown>,
) {
return <Fade in={true} ref={ref} {...props} />;
});
const SlideTransition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement<any, any>;
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
export interface SnapshotDialogProps {
open: boolean,
onOk: () => void,
};
export interface SnapshotDialogState {
fullScreen: boolean,
};
export default class SnapshotDialog extends ResizeResponsiveComponent<SnapshotDialogProps, SnapshotDialogState> {
constructor(props: SnapshotDialogProps) {
super(props);
this.state = {
fullScreen: this.getFullScreen(),
};
}
componentDidMount(): void {
super.componentDidMount();
}
componentWillUnmount(): void {
super.componentWillUnmount();
}
getFullScreen() {
return window.innerHeight < 450 || window.innerWidth < 450;
}
onWindowSizeChanged(width: number, height: number): void {
this.setState(
{
fullScreen: this.getFullScreen(),
}
);
}
render() {
return (
<DialogEx fullWidth={true} tag="shapshot" open={this.props.open} onClose={() => this.props.onOk()} fullScreen={this.state.fullScreen}
TransitionComponent={this.state.fullScreen? SlideTransition: FadeTransition} keepMounted
style={{ userSelect: "none", }}
PaperProps={{
style: {
},
}}
onEnterKey={()=>{} }
>
<DialogTitle>
<div>
<IconButton edge="start" color="inherit" onClick={() => { this.props.onOk(); }} aria-label="back"
>
<ArrowBackIcon fontSize="small" style={{ opacity: "0.6" }} />
</IconButton>
<Typography display="inline" variant="body1" color="textSecondary" >Snapshots</Typography>
</div>
</DialogTitle>
<DialogContent style={{
overflow: "hidden", margin: 0, height: "80vh",
paddingLeft: 16, paddingRight: 16, paddingTop: 0, paddingBottom: 24,
}}>
<SnapshotPanel panelHeight="80vh" />
</DialogContent>
</DialogEx>
);
}
}
+364
View File
@@ -0,0 +1,364 @@
// Copyright (c) 2024 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import WithStyles from './WithStyles';
import { css } from '@emotion/react';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CloseIcon from '@mui/icons-material/Close';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import CssBaseline from '@mui/material/CssBaseline';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import IconButton from '@mui/material/IconButton';
import FullscreenIcon from '@mui/icons-material/Fullscreen';
import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { PiPedalModelFactory, PiPedalModel, State, ZoomedControlInfo } from './PiPedalModel';
import ZoomedUiControl from './ZoomedUiControl'
import MainPage from './MainPage';
import JackStatusView from './JackStatusView';
import { Theme } from '@mui/material/styles';
import { isDarkMode } from './DarkMode';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import { Snapshot } from './Pedalboard';
import ColorDropdownButton, { DropdownAlignment } from './ColorDropdownButton';
import { getBackgroundColor } from './MaterialColors';
const selectColor = isDarkMode() ? "#888" : "#FFFFFF";
const appStyles = (theme: Theme) => createStyles({
"&": css({ // :root
colorScheme: (isDarkMode() ? "dark" : "light")
}),
mainFrame: css({
overflow: "hidden",
display: "flex",
flexFlow: "column",
marginTop: 8,
flex: "1 1 100%"
}),
heroContent: css({
backgroundColor: theme.mainBackground,
position: "relative",
height: "100%",
width: "100%"
}),
shadowCatcher: css({
backgroundColor: theme.mainBackground
}),
select: css({ // fu fu fu.Overrides for white selector on dark background.
'&:before': {
borderColor: selectColor,
},
'&:after': {
borderColor: selectColor,
},
'&:hover:not(.Mui-disabled):before': {
borderColor: selectColor,
}
}),
select_icon: css({
fill: selectColor,
})
});
function supportsFullScreen(): boolean {
let doc: any = window.document;
let docEl: any = doc.documentElement;
var requestFullScreen = docEl.requestFullscreen || docEl.mozRequestFullScreen || docEl.webkitRequestFullScreen || docEl.msRequestFullscreen;
return (!!requestFullScreen);
}
function setFullScreen(value: boolean) {
let doc: any = window.document;
let docEl: any = doc.documentElement;
var requestFullScreen = docEl.requestFullscreen || docEl.mozRequestFullScreen || docEl.webkitRequestFullScreen || docEl.msRequestFullscreen;
var cancelFullScreen = doc.exitFullscreen || doc.mozCancelFullScreen || doc.webkitExitFullscreen || doc.msExitFullscreen;
if (!doc.fullscreenElement && !doc.mozFullScreenElement && !doc.webkitFullscreenElement && !doc.msFullscreenElement) {
requestFullScreen.call(docEl);
}
else {
cancelFullScreen.call(doc);
}
}
type SnapshotEditorState = {
zoomedControlInfo: ZoomedControlInfo | undefined;
zoomedControlOpen: boolean;
canFullScreen: boolean;
isFullScreen: boolean;
isDebug: boolean;
collapseLabel: boolean;
showStatusMonitor: boolean;
name: string,
color: string
};
interface SnapshotEditorProps extends WithStyles<typeof appStyles> {
onClose: () => void;
onOk: (snapshotIndex: number, name: string, color: string, newSnapshots: (Snapshot | null)[]) => void;
snapshotIndex: number;
}
const SnapshotEditor = withStyles(
class extends ResizeResponsiveComponent<SnapshotEditorProps, SnapshotEditorState> {
// Before the component mounts, we initialise our state
model_: PiPedalModel;
getCollapseLabel() {
return this.windowSize.width < 500;
}
constructor(props: SnapshotEditorProps) {
super(props);
this.model_ = PiPedalModelFactory.getInstance();
this.model_.zoomedUiControl.addOnChangedHandler(
() => {
this.setState({
zoomedControlOpen: this.model_.zoomedUiControl.get() !== undefined,
zoomedControlInfo: this.model_.zoomedUiControl.get()
});
}
);
let snapshot = this.model_.pedalboard.get().snapshots[this.props.snapshotIndex] ?? new Snapshot();
this.state = {
zoomedControlOpen: false,
zoomedControlInfo: this.model_.zoomedUiControl.get(),
canFullScreen: supportsFullScreen() && !this.model_.isAndroidHosted(),
isFullScreen: !!document.fullscreenElement,
showStatusMonitor: this.model_.showStatusMonitor.get(),
isDebug: true,
name: snapshot.name,
color: snapshot.color,
collapseLabel: this.getCollapseLabel()
};
this.showStatusMonitorHandler = this.showStatusMonitorHandler.bind(this);
}
showStatusMonitorHandler() {
this.setState({
showStatusMonitor: this.model_.showStatusMonitor.get()
});
}
toggleFullScreen(): void {
setFullScreen(this.state.isFullScreen);
this.setState({ isFullScreen: !this.state.isFullScreen });
}
componentDidMount() {
super.componentDidMount();
this.model_.showStatusMonitor.addOnChangedHandler(this.showStatusMonitorHandler);
}
updateOverscroll(): void {
if (this.model_.serverVersion) {
// no pull-down refresh on android devices once we're ready (unless we're debug)
let preventOverscroll =
this.model_.state.get() === State.Ready
&& !this.model_.debug;
let overscrollBehavior = preventOverscroll ? "none" : "auto";
document.body.style.overscrollBehavior = overscrollBehavior;
}
}
componentDidUpdate() {
}
componentWillUnmount() {
super.componentWillUnmount();
this.model_.banks.removeOnChangedHandler(this.showStatusMonitorHandler);
}
onWindowSizeChanged(width: number, height: number): void {
super.onWindowSizeChanged(width, height);
this.setState({
collapseLabel: this.getCollapseLabel()
});
}
handleOk() {
let currentPedalboard = this.model_.pedalboard.get();
let selectedSnapshot = this.props.snapshotIndex;
let currentSnapshot: Snapshot | null = currentPedalboard.snapshots[selectedSnapshot];
if (!currentSnapshot) {
this.props.onClose();
return;
}
let changed = this.state.name !== currentSnapshot.name
|| this.state.color !== currentSnapshot.color
|| currentSnapshot.isModified;
if (!changed) {
this.props.onClose();
return;
}
let newSnapshots = Snapshot.cloneSnapshots(currentPedalboard.snapshots);
let newSnapshot = this.model_.pedalboard.get().makeSnapshot();
newSnapshot.name = this.state.name;
newSnapshot.color = this.state.color;
newSnapshots[selectedSnapshot] = newSnapshot;
this.model_.setSnapshots(newSnapshots, selectedSnapshot);
this.props.onOk(selectedSnapshot, this.state.name, this.state.color, newSnapshots);
}
render() {
const classes = withStyles.getClasses(this.props);
return (
<div
className={classes.shadowCatcher}
style={{
position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
colorScheme: isDarkMode() ? "dark" : "light", // affects scrollbar color
minHeight: 345, minWidth: 390,
userSelect: "none",
display: "flex", flexDirection: "column", flexWrap: "nowrap",
overscrollBehavior: this.state.isDebug ? "auto" : "none"
}}
onContextMenu={(e) => {
if (!this.model_.debug) {
e.preventDefault(); e.stopPropagation();
}
}}
>
<CssBaseline />
<AppBar position="static" sx={{ bgcolor: isDarkMode() ? getBackgroundColor("purple") : "#200040" }} >
<Toolbar variant="dense" >
<IconButton
edge="start"
aria-label="menu"
color="inherit"
onClick={() => { this.handleOk(); }}
size="large">
<ArrowBackIcon />
</IconButton>
{!this.state.collapseLabel && (
<Typography style={{ flex: "0 1 auto", opacity: 0.66 }}
variant="body1" noWrap
>
{'Snapshot ' + (this.props.snapshotIndex + 1)}
</Typography>
)}
<div style={{ flex: "1 1 auto", display: "flex", flexFlow: "column nowrap", marginRight: 8 }}>
<TextField
sx={{
input: { color: 'white' },
'& .MuiInput-underline:before': { borderBottomColor: '#FFFFFFC0' },
'& .MuiInput-underline:hover:before': { borderBottomColor: '#FFFFFFE0' },
'& .MuiInput-underline:after': { borderBottomColor: '#FFFFFF' }
}}
variant="standard"
style={{ flex: "1 1 auto", marginLeft: 16, maxWidth: 500, color: "white" }}
value={this.state.name}
onChange={(ev) => {
this.setState({ name: ev.target.value });
}}
/>
</div>
<ColorDropdownButton
currentColor={this.state.color}
dropdownAlignment={DropdownAlignment.SW}
onColorChange={(newColor) => {
this.setState({ color: newColor });
}} />
{this.state.canFullScreen &&
<IconButton
aria-label="full-screen"
onClick={() => { this.toggleFullScreen(); }}
color="inherit"
size="medium">
{this.state.isFullScreen ? (
<FullscreenExitIcon style={{ opacity: 0.75 }} />
) : (
<FullscreenIcon style={{ opacity: 0.75 }} />
)}
</IconButton>
}
<IconButton
aria-label="cancel"
onClick={() => { this.props.onClose(); }}
color="inherit"
size="medium">
<CloseIcon />
</IconButton>
</Toolbar>
</AppBar>
<main className={classes.mainFrame} >
<div style={{
overflow: "hidden",
flex: "1 1 auto"
}} >
<div className={classes.heroContent}>
<MainPage hasTinyToolBar={false} enableStructureEditing={false} />
</div>
</div>
</main>
<ZoomedUiControl
dialogOpen={this.state.zoomedControlOpen}
controlInfo={this.state.zoomedControlInfo}
onDialogClose={() => { this.setState({ zoomedControlOpen: false }); }}
onDialogClosed={() => { this.model_.zoomedUiControl.set(undefined); }
}
/>
{this.state.showStatusMonitor && (<JackStatusView />)}
</div >
);
}
},
appStyles
);
export default SnapshotEditor;
+299
View File
@@ -0,0 +1,299 @@
/*
* MIT License
*
* Copyright (c) 2024 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import SnapshotButton from './SnapshotButton';
import { Pedalboard, Snapshot } from './Pedalboard';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import SnapshotPropertiesDialog from './SnapshotPropertiesDialog';
export interface SnapshotPanelProps {
panelHeight?: string,
onEdit?: (snapshotIndex: number) => boolean
};
export interface SnapshotPanelState {
snapshots: (Snapshot | null)[],
portraitOrientation: boolean,
collapseButtons: boolean,
largeText: boolean,
snapshotPropertiesDialogOpen: boolean,
editTitle: string,
editColor: string,
editing: boolean,
editId: number,
selectedSnapshot: number
};
export default class SnapshotPanel extends ResizeResponsiveComponent<SnapshotPanelProps, SnapshotPanelState> {
private model: PiPedalModel;
constructor(props: SnapshotPanelProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
snapshots: this.getCurrentSnapshots(),
selectedSnapshot: this.getSelectedSnapshot(),
portraitOrientation: this.getPortraitOrientation(),
collapseButtons: this.getCollapseButtons(),
largeText: this.getLargeText(),
snapshotPropertiesDialogOpen: false,
editTitle: "",
editColor: "",
editing: false,
editId: 0,
};
this.onPedalboardChanged = this.onPedalboardChanged.bind(this);
this.onSelectedSnapshotChanged = this.onSelectedSnapshotChanged.bind(this);
}
getCurrentSnapshots() {
let result: (Snapshot | null)[] = [];
let currentSnapshots = this.model.pedalboard.get().snapshots;
for (let i = 0; i < 6; ++i) {
let mySnapshot = null;
if (i < currentSnapshots.length) {
if (currentSnapshots[i] !== null) {
mySnapshot = new Snapshot().deserialize(currentSnapshots[i]); // a deep clone.
}
}
result.push(mySnapshot);
}
return result;
}
getSelectedSnapshot() {
return this.model.selectedSnapshot.get();
}
onSelectedSnapshotChanged(value: number) {
this.setState({ selectedSnapshot: value })
}
onPedalboardChanged(value: Pedalboard) {
// boofs our current edit, oh well. we can't track property across a structural change.
this.setState({
snapshots: this.getCurrentSnapshots(),
selectedSnapshot: this.getSelectedSnapshot()
});
}
componentDidMount(): void {
super.componentDidMount();
this.model.pedalboard.addOnChangedHandler(this.onPedalboardChanged);
this.model.selectedSnapshot.addOnChangedHandler(this.onSelectedSnapshotChanged);
this.setState({
snapshots: this.getCurrentSnapshots(),
selectedSnapshot: this.getSelectedSnapshot()
});
}
componentWillUnmount(): void {
super.componentWillUnmount();
this.model.selectedSnapshot.removeOnChangedHandler(this.onSelectedSnapshotChanged);
this.model.pedalboard.removeOnChangedHandler(this.onPedalboardChanged)
}
getCollapseButtons() {
if (this.getPortraitOrientation())
{
return window.innerWidth < 550;
} else {
return window.innerWidth < 800;
}
}
getPortraitOrientation() {
return window.innerWidth * 2 < window.innerHeight * 3;
}
getLargeText() {
return window.innerHeight > 700 && window.innerWidth > 1000;
}
onSaveSnapshot(index: number) {
let snapshot = this.state.snapshots[index];
if (snapshot) {
// no dialog required. just save it.
this.handleSnapshotPropertyOk(
index,
snapshot.name,
snapshot.color,
false);
} else {
this.setState({
editTitle: "",
editColor: "",
editId: index,
snapshotPropertiesDialogOpen: true,
editing: false
});
}
}
onEditSnapshot(index: number) {
if (this.props.onEdit) {
if (this.props.onEdit(index)) {
return;
}
}
let snapshot = this.state.snapshots[index];
if (snapshot) {
this.setState({
editTitle: snapshot.name,
editColor: snapshot.color,
editId: index,
snapshotPropertiesDialogOpen: true,
editing: true
});
}
}
onWindowSizeChanged(width: number, height: number): void {
this.setState(
{
portraitOrientation: this.getPortraitOrientation(),
collapseButtons: this.getCollapseButtons(),
largeText: this.getLargeText()
}
);
}
selectSnapshot(index: number) {
this.model.selectSnapshot(index);
}
renderSnapshot(snapshot: Snapshot | null, index: number) {
return (
<SnapshotButton key={"s"+index}
snapshot={snapshot}
snapshotIndex={index}
collapseButtons={this.state.collapseButtons}
selected={this.state.selectedSnapshot === index}
largeText={this.state.largeText}
onEditSnapshot={(index)=>{this.onEditSnapshot(index)}}
onSaveSnapshot={(index)=>{ this.onSaveSnapshot(index); }}
onSelectSnapshot={(index)=>{this.model.selectSnapshot(index);}}
/>
);
}
handleSnapshotPropertyOk(index: number, name: string, color: string, editing: boolean) {
if (editing) {
let snapshots = Snapshot.cloneSnapshots(this.state.snapshots);
let snapshot = snapshots[index];
if (snapshot) {
snapshot.name = name;
snapshot.color = color;
}
this.setState({
snapshots: snapshots,
snapshotPropertiesDialogOpen: false
});
this.model.setSnapshots(snapshots, -1);
} else {
let pedalboard = this.model.pedalboard.get();
let newSnapshot = pedalboard.makeSnapshot();
newSnapshot.name = name;
newSnapshot.color = color;
let snapshots = Snapshot.cloneSnapshots(this.state.snapshots);
snapshots[index] = newSnapshot;
this.setState({
snapshots: snapshots,
snapshotPropertiesDialogOpen: false
});
this.model.setSnapshots(snapshots, index);
}
}
render() {
let snapshots = this.state.snapshots;
return (
<div style={{
flex: "1 1 auto",
height: this.props.panelHeight,
overflow: "hidden", margin: 0,
paddingLeft: 16, paddingRight: 16, paddingTop: 0, paddingBottom: 24,
display: "flex", flexFlow: "column nowrap", alignContent: "stretch", justifyContent: "stretch"
}}>
<div style={{
flexGrow: 1, flexShrink: 1, flexBasis: 1,
display: "flex",
flexFlow: this.state.portraitOrientation ?
"row nowrap" : "column nowrap",
alignItems: "stretch", gap: 8
}}>
<div style={{
flexGrow: 1, flexShrink: 1, flexBasis: 1,
display: "flex",
flexFlow: this.state.portraitOrientation ?
"column nowrap"
: "row nowrap",
alignItems: "stretch", gap: 8
}}>
{this.renderSnapshot(snapshots[0], 0)}
{this.renderSnapshot(snapshots[1], 1)}
{this.renderSnapshot(snapshots[2], 2)}
</div>
<div style={{
flexGrow: 1, flexShrink: 1, flexBasis: 1,
display: "flex",
flexFlow: this.state.portraitOrientation ? "column nowrap" : "row nowrap", alignItems: "stretch", gap: 8
}}>
{this.renderSnapshot(snapshots[3], 3)}
{this.renderSnapshot(snapshots[4], 4)}
{this.renderSnapshot(snapshots[5], 5)}
</div>
</div>
{
this.state.snapshotPropertiesDialogOpen && (
<SnapshotPropertiesDialog
open={this.state.snapshotPropertiesDialogOpen}
name={this.state.editTitle}
color={this.state.editColor}
editing={this.state.editing}
snapshotIndex={this.state.editId}
onClose={() => { this.setState({ snapshotPropertiesDialogOpen: false }); }}
onOk={(snapshotId, name, color, editing) => {
this.handleSnapshotPropertyOk(snapshotId, name, color, editing);
}}
/>
)
}
</div>
);
}
}
@@ -0,0 +1,165 @@
/*
* MIT License
*
* Copyright (c) 2024 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import DialogEx from './DialogEx';
import DialogTitle from '@mui/material/DialogTitle';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import IconButton from '@mui/material/IconButton';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import { colorKeys } from "./MaterialColors";
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import ColorDropdownButton, { DropdownAlignment } from './ColorDropdownButton';
const snapshotColors = colorKeys.slice(0,100);
const NBSP = "\u00A0";
export interface SnapshotPropertiesDialogProps {
open: boolean,
name: string,
editing: boolean,
snapshotIndex: number,
color: string,
onOk: (snapshotId: number, name: string, color: string, editing: boolean) => void,
onClose: () => void
};
export interface SnapshotPropertiesDialogState {
name: string,
color: string,
nameErrorMessage: string,
compactVertical: boolean,
};
export default class SnapshotPropertiesDialog extends ResizeResponsiveComponent<SnapshotPropertiesDialogProps, SnapshotPropertiesDialogState> {
constructor(props: SnapshotPropertiesDialogProps) {
super(props);
this.state = this.stateFromProps();
}
getCompactVertical() {
return window.innerHeight < 450;
}
stateFromProps() {
let color = this.props.color;
if (color.length === 0) {
color = snapshotColors[0];
}
let name = this.props.name;
if (name.length === 0 && !this.props.editing)
{
name = "Snapshot " + (this.props.snapshotIndex+1);
}
return {
name: name,
color: color,
nameErrorMessage: "",
compactVertical: this.getCompactVertical()
};
}
onWindowSizeChanged(width: number, height: number): void {
super.onWindowSizeChanged(width,height);
this.setState({compactVertical: this.getCompactVertical() })
}
handleOk() {
if (this.state.name === "") {
this.setState({ nameErrorMessage: "* Required" });
} else {
this.props.onOk(
this.props.snapshotIndex,
this.state.name,
this.state.color,
this.props.editing
);
}
}
render() {
let props = this.props;
const handleClose = () => {
props.onClose();
};
let okButtonText = this.props.editing ? "OK" : "Save";
return (
<DialogEx maxWidth="sm" fullWidth={true} tag="snapshotProps" open={this.props.open} onClose={handleClose}
style={{ userSelect: "none" }} fullScreen={this.state.compactVertical}
onEnterKey={()=> { this.handleOk(); }}
>
<DialogTitle>
<div>
<IconButton edge="start" color="inherit" onClick={() => { this.props.onClose(); }} aria-label="back"
>
<ArrowBackIcon fontSize="small" style={{ opacity: "0.6" }} />
</IconButton>
<Typography display="inline" variant="body1" color="textSecondary" >
{this.props.editing ? "Edit snapshot" : "Save snapshot"}
</Typography>
</div>
</DialogTitle>
<DialogContent style={{paddingBottom: 0}}>
<TextField variant="standard" style={{ flexGrow: 1, flexBasis: 1 }}
autoComplete="off"
spellCheck="false"
error={this.state.nameErrorMessage.length !== 0}
id="name"
label="Name"
type="text"
fullWidth
helperText={this.state.nameErrorMessage.length === 0 ? NBSP : this.state.nameErrorMessage}
value={this.state.name}
onChange={(e) => this.setState({ name: e.target.value, nameErrorMessage: "" })}
InputLabelProps={{
shrink: true
}}
/>
<Typography variant="caption" color="textSecondary">Color</Typography>
<ColorDropdownButton aria-label="color" currentColor={this.state.color} dropdownAlignment={DropdownAlignment.SE}
onColorChange={(newcolor) => {
this.setState({color: newcolor});
}} />
</DialogContent>
<DialogActions>
<Button variant="dialogSecondary" onClick={handleClose} >
Cancel
</Button>
<Button variant="dialogPrimary" onClick={()=> {this.handleOk();}} >
{okButtonText}
</Button>
</DialogActions>
</DialogEx>
);
}
}
+286
View File
@@ -0,0 +1,286 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { ReactNode } from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { UiPlugin } from './Lv2Plugin';
import {
Pedalboard, PedalboardSplitItem, ControlValue,
} from './Pedalboard';
import PluginControl from './PluginControl';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import VuMeter from './VuMeter';
import { SplitAbControl, SplitTypeControl, SplitMixControl, SplitPanLeftControl,SplitVolLeftControl,SplitPanRightControl,SplitVolRightControl } from './SplitUiControls';
import { css } from '@emotion/react';
const LANDSCAPE_HEIGHT_BREAK = 500;
const styles = (theme: Theme) => createStyles({
frame: css({
display: "block",
position: "relative",
flexDirection: "row",
flexWrap: "nowrap",
paddingTop: "8px",
paddingBottom: "0px",
height: "100%",
overflowX: "auto",
overflowY: "auto"
}),
vuMeterL: css({
position: "fixed",
paddingLeft: 12,
paddingRight: 12,
left: 0,
background: theme.mainBackground,
zIndex: 3
}),
vuMeterR: css({
position: "fixed",
right: 0,
marginRight: 22,
paddingLeft: 12,
background: theme.mainBackground,
zIndex: 3
}),
vuMeterRLandscape: css({
position: "fixed",
right: 0,
paddingRight: 22,
paddingLeft: 12,
background: theme.mainBackground,
zIndex: 3
}),
normalGrid: css({
position: "relative",
paddingLeft: 40,
paddingRight: 40,
flex: "1 1 auto",
display: "flex", flexDirection: "row", flexWrap: "wrap",
justifyContent: "flex-start", alignItems: "flex_start"
}),
landscapeGrid: css({
paddingLeft: 40,
marginRight: 40,
display: "inline-flex", flexDirection: "row", flexWrap: "nowrap",
justifyContent: "flex-start", alignItems: "flex_start",
height: "100%"
})
});
interface SplitControlViewProps extends WithStyles<typeof styles> {
instanceId: number;
item: PedalboardSplitItem;
}
type SplitControlViewState = {
landscapeGrid: boolean;
};
const SplitControlView =
withStyles(
class extends ResizeResponsiveComponent<SplitControlViewProps, SplitControlViewState>
{
model: PiPedalModel;
constructor(props: SplitControlViewProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
landscapeGrid: false
}
this.onPedalboardChanged = this.onPedalboardChanged.bind(this);
this.onValueChanged = this.onValueChanged.bind(this);
this.onPreviewChange = this.onPreviewChange.bind(this);
}
onPreviewChange(key: string, value: number): void {
this.model.previewPedalboardValue(this.props.instanceId, key, value);
}
onValueChanged(key: string, value: number): void {
this.model.setPedalboardControl(this.props.instanceId, key, value);
}
onPedalboardChanged(value?: Pedalboard) {
//let item = this.model.pedalboard.get().maybeGetItem(this.props.instanceId);
//this.setState({ pedalboardItem: item });
}
componentDidMount() {
super.componentDidMount();
this.model.pedalboard.addOnChangedHandler(this.onPedalboardChanged);
}
componentWillUnmount() {
this.model.pedalboard.removeOnChangedHandler(this.onPedalboardChanged);
super.componentWillUnmount();
}
renderControl(plugin: UiPlugin, controlValue: ControlValue): ReactNode {
return (
<div style={{ display: "flex", flexDirection: "row", alignContent: "center", justifyContent: "flex-start" }}>
</div>
)
}
onWindowSizeChanged(width: number, height: number): void {
this.setState({ landscapeGrid: height < LANDSCAPE_HEIGHT_BREAK });
}
render(): ReactNode {
const classes = withStyles.getClasses(this.props);
let pedalboardItem = this.model.pedalboard.get().getItem(this.props.instanceId);
if (!pedalboardItem)
return (<div className={classes.frame}></div>);
let gridClass = this.state.landscapeGrid ? classes.landscapeGrid : classes.normalGrid;
let vuMeterRClass = this.state.landscapeGrid ? classes.vuMeterRLandscape : classes.vuMeterR;
let typeValue = pedalboardItem.getControl(PedalboardSplitItem.TYPE_KEY);
let mixValue = pedalboardItem.getControl(PedalboardSplitItem.MIX_KEY);
let selectValue = pedalboardItem.getControl(PedalboardSplitItem.SELECT_KEY);
let panLValue = pedalboardItem.getControl(PedalboardSplitItem.PANL_KEY);
let volumeLValue = pedalboardItem.getControl(PedalboardSplitItem.VOLL_KEY);
let panRValue = pedalboardItem.getControl(PedalboardSplitItem.PANR_KEY);
let volumeRValue = pedalboardItem.getControl(PedalboardSplitItem.VOLR_KEY);
return (
<div className={classes.frame}>
<div className={classes.vuMeterL}>
<VuMeter display="input" instanceId={pedalboardItem.instanceId} />
</div>
<div className={vuMeterRClass}>
<VuMeter display="output" instanceId={pedalboardItem.instanceId} />
</div>
<div className={gridClass} >
<div style={{ flex: "0 0 auto" }}>
<PluginControl instanceId={this.props.instanceId} uiControl={SplitTypeControl} value={typeValue.value}
onChange={(value: number) => { this.onValueChanged(typeValue.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(typeValue.key, value) }}
requestIMEEdit={(uiControl, value) => { }}
/>
</div>
{
typeValue.value === 0 && (
<div style={{ flex: "0 0 auto" }}>
<PluginControl instanceId={this.props.instanceId} uiControl={SplitAbControl} value={selectValue.value}
onChange={(value: number) => { this.onValueChanged(selectValue.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(selectValue.key, value) }}
requestIMEEdit={(uiControl, value) => { }}
/>
</div>
)
}
{
typeValue.value === 1 && (
<div style={{ flex: "0 0 auto" }}>
<PluginControl instanceId={this.props.instanceId} uiControl={SplitMixControl} value={mixValue.value}
onChange={(value: number) => { this.onValueChanged(mixValue.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(mixValue.key, value) }}
requestIMEEdit={(uiControl, value) => { }}
/>
</div>
)
}
{
typeValue.value === 2 && (
<div style={{ flex: "0 0 auto" }}>
<PluginControl instanceId={this.props.instanceId} uiControl={SplitPanLeftControl} value={panLValue.value}
onChange={(value: number) => { this.onValueChanged(panLValue.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(panLValue.key, value) }}
requestIMEEdit={(uiControl, value) => { }}
/>
</div>
)
}
{
typeValue.value === 2 && (
<div style={{ flex: "0 0 auto" }}>
<PluginControl instanceId={this.props.instanceId} uiControl={SplitVolLeftControl} value={volumeLValue.value}
onChange={(value: number) => { this.onValueChanged(volumeLValue.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(volumeLValue.key, value) }}
requestIMEEdit={(uiControl, value) => { }}
/>
</div>
)
}
{
typeValue.value === 2 && (
<div style={{ flex: "0 0 auto" }}>
<PluginControl instanceId={this.props.instanceId} uiControl={SplitPanRightControl} value={panRValue.value}
onChange={(value: number) => { this.onValueChanged(panRValue.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(panRValue.key, value) }}
requestIMEEdit={(uiControl, value) => { }}
/>
</div>
)
}
{
typeValue.value === 2 && (
<div style={{ flex: "0 0 auto" }}>
<PluginControl instanceId={this.props.instanceId} uiControl={SplitVolRightControl} value={volumeRValue.value}
onChange={(value: number) => { this.onValueChanged(volumeRValue.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(volumeRValue.key, value) }}
requestIMEEdit={(uiControl, value) => { }}
/>
</div>
)
}
</div>
</div >
);
}
},
styles
);
export default SplitControlView;
+202
View File
@@ -0,0 +1,202 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import {UiControl,ScalePoint} from './Lv2Plugin';
import {PedalboardSplitItem} from './Pedalboard';
import Units from './Units'
export const SplitTypeControl: UiControl = new UiControl().deserialize({
symbol: PedalboardSplitItem.TYPE_KEY,
name: "Type",
index: 0,
min_value: 0,
max_value: 2,
default_value: 0,
range_steps: 0,
is_logarithmic: false,
display_priority: -1,
integer_property: false,
enumeration_property: true,
not_on_gui: false,
toggled_property: false,
trigger_property: false,
pipedal_ledColor: "",
scale_points: ScalePoint.deserialize_array(
[
{ value: 0, label: "A/B" },
{ value: 1, label: "Mix" },
{ value: 2, label: "L/R" }
]
)
});
export const SplitAbControl: UiControl = new UiControl().deserialize({
symbol: PedalboardSplitItem.SELECT_KEY,
name: "Select",
index: 1,
min_value: 0,
max_value: 1,
default_value: 0,
range_steps: 0,
is_logarithmic: false,
display_priority: -1,
integer_property: false,
enumeration_property: true,
not_on_gui: false,
toggled_property: false,
trigger_property: false,
pipedal_ledColor: "",
scale_points: ScalePoint.deserialize_array(
[
{ value: 0, label: "A" },
{ value: 1, label: "B" },
]
)
});
export const SplitMixControl: UiControl = new UiControl().deserialize({
symbol: PedalboardSplitItem.MIX_KEY,
name: "Mix",
index: 2,
min_value: -1,
max_value: 1,
default_value: 0,
range_steps: 0,
is_logarithmic: false,
display_priority: -1,
integer_property: false,
enumeration_property: false,
not_on_gui: false,
toggled_property: false,
trigger_property: false,
pipedal_ledColor: "",
scale_points: []
});
export const SplitPanLeftControl: UiControl = new UiControl().deserialize({
symbol: PedalboardSplitItem.PANL_KEY,
name: "Pan Top",
index: 3,
min_value: -1,
max_value: 1,
default_value: 0,
range_steps: 0,
is_logarithmic: false,
display_priority: -1,
integer_property: false,
enumeration_property: false,
not_on_gui: false,
toggled_property: false,
trigger_property: false,
pipedal_ledColor: "",
scale_points: []
});
export const SplitVolLeftControl: UiControl = new UiControl().deserialize({
symbol: PedalboardSplitItem.VOLL_KEY,
name: "Vol Top",
index: 4,
min_value: -60,
max_value: 12,
default_value: -3,
range_steps: 0,
is_logarithmic: false,
display_priority: -1,
integer_property: false,
enumeration_property: false,
not_on_gui: false,
toggled_property: false,
trigger_property: false,
pipedal_ledColor: "",
units: Units.db,
scale_points: [
new ScalePoint().deserialize({
value: -60,
label: "-INF"
})
]
});
export const SplitPanRightControl: UiControl = new UiControl().deserialize({
symbol: PedalboardSplitItem.PANR_KEY,
name: "Pan Bottom",
index: 5,
min_value: -1,
max_value: 1,
default_value: 0,
range_steps: 0,
is_logarithmic: false,
display_priority: -1,
integer_property: false,
enumeration_property: false,
not_on_gui: false,
toggled_property: false,
trigger_property: false,
pipedal_ledColor: "",
scale_points: []
});
export const SplitVolRightControl: UiControl = new UiControl().deserialize({
symbol: PedalboardSplitItem.VOLR_KEY,
name: "Vol Bottom",
index: 6,
min_value: -60,
max_value: 12,
default_value: -3,
range_steps: 0,
is_logarithmic: false,
display_priority: -1,
integer_property: false,
enumeration_property: false,
not_on_gui: false,
toggled_property: false,
trigger_property: false,
pipedal_ledColor: "",
units: Units.db,
scale_points: [
new ScalePoint().deserialize({
value: -60,
label: "-INF"
})
]
});
export const SplitControls: UiControl[] =
[
SplitTypeControl,
SplitAbControl,
SplitMixControl,
SplitPanLeftControl,
SplitVolLeftControl,
SplitPanRightControl,
SplitVolRightControl,
];
+40
View File
@@ -0,0 +1,40 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class StringBuilder
{
_array: string[] = [];
append(value: any) {
if (typeof(value) === "string")
{
this._array.push(value);
} else {
this._array.push(value+"");
}
return this;
}
toString(): string {
return this._array.join('');
}
}
export default StringBuilder;
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import StringBuilder from './StringBuilder';
class SvgPathBuilder {
private _sb: StringBuilder = new StringBuilder();
moveTo(x: number, y: number): SvgPathBuilder
{
this._sb.append('M').append(x).append(',').append(y).append(' ');
return this;
}
lineTo(x: number, y: number): SvgPathBuilder
{
this._sb.append('L').append(x).append(',').append(y).append(' ');
return this;
}
bezierTo(x1: number, y1: number, x2: number, y2: number, x: number, y: number)
{
this._sb.append('C').append(x1).append(',').append(y1)
.append(' ').append(x2).append(',').append(y2)
.append(' ').append(x).append(',').append(y).append(' ');
return this;
}
closePath(): SvgPathBuilder {
this._sb.append("Z ");
return this;
}
toString(): string { return this._sb.toString(); }
}
export default SvgPathBuilder;
+221
View File
@@ -0,0 +1,221 @@
/*
* MIT License
*
* Copyright (c) 2022-2023 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Component } from 'react';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import {createStyles} from './WithStyles';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import MidiBinding from './MidiBinding';
import Utility from './Utility';
import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined';
import MicOutlinedIcon from '@mui/icons-material/MicOutlined';
import IconButton from '@mui/material/IconButton';
const styles = (theme: Theme) => createStyles({
controlDiv: { flex: "0 0 auto", marginRight: 12, verticalAlign: "center", height: 48, paddingTop: 8, paddingBottom: 8 },
controlDiv2: {
flex: "0 0 auto", marginRight: 12, verticalAlign: "center",
height: 48, paddingTop: 0, paddingBottom: 0, whiteSpace: "nowrap"
}
});
interface SystemMidiBindingViewProps extends WithStyles<typeof styles> {
instanceId: number;
listen: boolean;
midiBinding: MidiBinding;
onChange: (instanceId: number, newBinding: MidiBinding) => void;
onListen: (instanceId: number, key: string, listenForControl: boolean) => void;
}
interface SystemMidiBindingViewState {
}
const SystemMidiBindingView =
withStyles(
class extends Component<SystemMidiBindingViewProps, SystemMidiBindingViewState> {
model: PiPedalModel;
constructor(props: SystemMidiBindingViewProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
};
}
handleTypeChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.bindingType = newValue;
this.props.onChange(this.props.instanceId, newBinding);
}
handleNoteChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.note = newValue;
this.props.onChange(this.props.instanceId, newBinding);
}
handleControlChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.control = newValue;
this.props.onChange(this.props.instanceId, newBinding);
}
handleLatchControlTypeChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.switchControlType = newValue;
this.props.onChange(this.props.instanceId, newBinding);
}
handleLinearControlTypeChange(e: any, extra: any) {
let newValue = parseInt(e.target.value);
let newBinding = this.props.midiBinding.clone();
newBinding.linearControlType = newValue;
this.props.onChange(this.props.instanceId, newBinding);
}
handleMinChange(value: number): void {
let newBinding = this.props.midiBinding.clone();
newBinding.minValue = value;
this.props.onChange(this.props.instanceId, newBinding);
}
handleMaxChange(value: number): void {
let newBinding = this.props.midiBinding.clone();
newBinding.maxValue = value;
this.props.onChange(this.props.instanceId, newBinding);
}
handleScaleChange(value: number): void {
let newBinding = this.props.midiBinding.clone();
newBinding.rotaryScale = value;
this.props.onChange(this.props.instanceId, newBinding);
}
generateMidiSelects(): React.ReactNode[] {
let result: React.ReactNode[] = [];
for (let i = 0; i < 127; ++i) {
result.push(
<MenuItem value={i}>{Utility.midiNoteName(i)}</MenuItem>
)
}
return result;
}
generateControlSelects(): React.ReactNode[] {
return Utility.validMidiControllers.map((control) => (
<MenuItem key={control.value} value={control.value}>{control.displayName}</MenuItem>
)
);
}
render() {
const classes = withStyles.getClasses(this.props);
let midiBinding = this.props.midiBinding;
return (
<div style={{ display: "flex", flexDirection: "row", flexWrap: "wrap", justifyContent: "left", alignItems: "center", minHeight: 48 }}>
<div className={classes.controlDiv} >
<Select variant="standard"
style={{ width: 80, }}
onChange={(e, extra) => this.handleTypeChange(e, extra)}
value={midiBinding.bindingType}
>
<MenuItem value={0}>None</MenuItem>
<MenuItem value={1}>Note</MenuItem>
<MenuItem value={2}>Control</MenuItem>
</Select>
</div>
{
(midiBinding.bindingType === MidiBinding.BINDING_TYPE_NOTE) &&
(
<div className={classes.controlDiv} >
<Select variant="standard"
style={{ width: 80, verticalAlign: 'center' }}
onChange={(e, extra) => this.handleNoteChange(e, extra)}
value={midiBinding.note}
>
{
this.generateMidiSelects()
}
</Select>
</div>
)
}
{
(midiBinding.bindingType === MidiBinding.BINDING_TYPE_CONTROL) &&
(
<div className={classes.controlDiv} >
<Select variant="standard"
style={{ width: 80 }}
onChange={(e, extra) => this.handleControlChange(e, extra)}
value={midiBinding.control}
renderValue={(value) => { return "CC-" + value }}
>
{
this.generateControlSelects()
}
</Select>
</div>
)
}
<IconButton
onClick={() => {
if (this.props.listen) {
this.props.onListen(-2, "", false)
} else {
this.props.onListen(this.props.instanceId, this.props.midiBinding.symbol, false)
}
}}
size="large">
{this.props.listen ? (
<MicOutlinedIcon />
) : (
<MicNoneOutlinedIcon />
)}
</IconButton>
</div>
);
}
},
styles
);
export default SystemMidiBindingView;
@@ -0,0 +1,387 @@
/*
* MIT License
*
* Copyright (c) 2022-2023 Robin E. R. Davies
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { SyntheticEvent } from 'react';
import { css } from '@emotion/react';
import DialogEx from './DialogEx';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { PiPedalModel, PiPedalModelFactory, ListenHandle } from './PiPedalModel';
import Typography from '@mui/material/Typography';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import IconButton from '@mui/material/IconButton';
import MidiBinding from './MidiBinding';
import SystemMidiBindingView from './SystemMidiBindingView';
import Snackbar from '@mui/material/Snackbar';
const styles = (theme: Theme) => createStyles({
dialogAppBar: css({
position: 'relative',
top: 0, left: 0
}),
dialogTitle: css({
marginLeft: theme.spacing(2),
flex: "1 1 auto",
}),
pluginTable: css({
borderCollapse: "collapse",
width: "100%",
}),
pluginHead: css({
borderTop: "1pt #DDD solid", paddingLeft: 22, paddingRight: 22
}),
bindingTd: css({
verticalAlign: "top",
paddingLeft: 12, paddingBottom: 8
}),
nameTd: css({
paddingLeft: 16,
verticalAlign: "top",
paddingTop: 12
}),
plainRow: css({
borderWidth: "1px 0px 0px 0px", borderStyle: "solid", borderColor: "transparent"
}),
dividerRow: css({
borderWidth: "1px 0px 0px 0px", borderStyle: "solid", borderColor: theme.palette.divider
})
});
export interface SystemMidiBindingDialogProps extends WithStyles<typeof styles> {
open: boolean,
onClose: () => void
}
class BindingEntry {
constructor(
displayName: string,
instanceId: number,
midiBinding: MidiBinding,
) {
this.displayName = displayName;
this.instanceId = instanceId;
this.midiBinding = midiBinding;
}
displayName: string;
instanceId: number;
midiBinding: MidiBinding;
}
export interface SystemMidiBindingDialogState {
listenInstanceId: number;
listenSymbol: string;
listenSnackbarOpen: boolean;
systemMidiBindings: BindingEntry[];
}
export const SystemMidiBindingDialog =
withStyles(
class extends ResizeResponsiveComponent<SystemMidiBindingDialogProps, SystemMidiBindingDialogState> {
model: PiPedalModel;
constructor(props: SystemMidiBindingDialogProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
listenInstanceId: -2,
listenSymbol: "",
listenSnackbarOpen: false,
systemMidiBindings: this.createBindings()
};
this.handleClose = this.handleClose.bind(this);
this.onMidiBindingsChanged = this.onMidiBindingsChanged.bind(this);
}
createBindings(): BindingEntry[] {
let result: BindingEntry[] = [];
let listenInstanceId = 0;
for (var item of this.model.systemMidiBindings.get()) {
let displayName = "";
let found = true;
if (item.symbol === "nextBank") {
displayName = "Next Bank";
}
else if (item.symbol === "prevBank") {
displayName = "Previous Bank";
}
else if (item.symbol === "nextProgram") {
displayName = "Next Preset";
}else if (item.symbol === "prevProgram") {
displayName = "Previous Preset";
}
else if (item.symbol === "snapshot1") {
displayName = "Snapshot 1";
}
else if (item.symbol === "snapshot2") {
displayName = "Snapshot 2";
}
else if (item.symbol === "snapshot3") {
displayName = "Snapshot 3";
}
else if (item.symbol === "snapshot4") {
displayName = "Snapshot 4";
}
else if (item.symbol === "snapshot5") {
displayName = "Snapshot 5";
}
else if (item.symbol === "snapshot6") {
displayName = "Snapshot 6";
} else if (item.symbol === "startHotspot") {
displayName = "Enable Hotspot";
} else if (item.symbol === "stopHotspot") {
displayName = "Disable Hotspot";
} else if (item.symbol === "shutdown") {
displayName = "Shutdown";
} else if (item.symbol === "reboot") {
displayName = "Reboot";
} else {
found = false;
}
if (found)
{
result.push(new BindingEntry(displayName, listenInstanceId, item));
++listenInstanceId;
}
}
return result;
}
mounted: boolean = false;
hasHooks: boolean = false;
handleClose() {
this.cancelListenForControl();
this.props.onClose();
}
listenTimeoutHandle?: number;
listenHandle?: ListenHandle;
cancelListenForControl() {
if (this.listenTimeoutHandle) {
clearTimeout(this.listenTimeoutHandle);
this.listenTimeoutHandle = undefined;
}
if (this.listenHandle) {
this.model.cancelListenForMidiEvent(this.listenHandle)
this.listenHandle = undefined;
}
this.setState({ listenInstanceId: -2, listenSymbol: "" });
}
handleListenSucceeded(instanceId: number, symbol: string, isNote: boolean, noteOrControl: number) {
this.cancelListenForControl();
for (var binding of this.state.systemMidiBindings) {
if (binding.instanceId === instanceId) {
let newBinding = binding.midiBinding.clone();
if (isNote) {
newBinding.bindingType = MidiBinding.BINDING_TYPE_NOTE;
newBinding.note = noteOrControl;
} else {
newBinding.bindingType = MidiBinding.BINDING_TYPE_CONTROL;
newBinding.control = noteOrControl;
}
this.model.setSystemMidiBinding(instanceId, newBinding);
return;
}
}
}
handleListenForControl(instanceId: number, symbol: string, listenForControl: boolean): void {
this.cancelListenForControl();
this.setState({ listenInstanceId: instanceId, listenSymbol: symbol, listenSnackbarOpen: true });
this.listenTimeoutHandle = setTimeout(() => {
this.cancelListenForControl();
}, 8000);
this.listenHandle = this.model.listenForMidiEvent(listenForControl,
(isNote: boolean, noteOrControl: number) => {
this.handleListenSucceeded(instanceId, symbol, isNote, noteOrControl);
});
}
onWindowSizeChanged(width: number, height: number): void {
}
onMidiBindingsChanged() {
this.setState({ systemMidiBindings: this.createBindings() });
}
componentDidMount() {
super.componentDidMount();
this.mounted = true;
this.model.systemMidiBindings.addOnChangedHandler(this.onMidiBindingsChanged);
this.onMidiBindingsChanged();
}
componentWillUnmount() {
super.componentWillUnmount();
this.mounted = false;
this.model.systemMidiBindings.removeOnChangedHandler(this.onMidiBindingsChanged);
}
componentDidUpdate() {
}
handleItemChanged(instanceId: number, newBinding: MidiBinding) {
this.model.setSystemMidiBinding(instanceId, newBinding);
}
generateTable(): React.ReactNode[] {
const classes = withStyles.getClasses(this.props);
let result: React.ReactNode[] = [];
let items = this.state.systemMidiBindings;
for (var item of items) {
let symbol = item.midiBinding.symbol;
let hasDivider = symbol === "snapshot1" || symbol === "stopHotspot" || symbol === "shotdown";
if (hasDivider)
{
result.push(
<tr>
<td colSpan={2} className={classes.dividerRow}><div style={{height: 1}} /></td>
</tr>
);
}
result.push(
<tr key={item.instanceId} >
<td className={classes.nameTd}>
<Typography noWrap style={{ verticalAlign: "center", height: 48 }}>
{item.displayName}
</Typography>
</td>
<td className={classes.bindingTd}>
<SystemMidiBindingView instanceId={item.instanceId} midiBinding={item.midiBinding}
onListen={(instanceId: number, symbol: string, listenForControl: boolean) => {
if (instanceId === -2) {
this.cancelListenForControl();
} else {
this.handleListenForControl(instanceId, symbol, listenForControl);
}
}}
listen={item.instanceId === this.state.listenInstanceId && this.state.listenSymbol === item.midiBinding.symbol}
onChange={(instanceId: number, newItem: MidiBinding) => this.handleItemChanged(instanceId, newItem)}
/>
</td>
</tr>
);
}
return result;
}
supressDefault(e: SyntheticEvent) {
//e.preventDefault();
//e.stopPropagation();
}
render() {
let props = this.props;
let { open} = props;
const classes = withStyles.getClasses(this.props);
if (!open) {
return (<div />);
}
return (
<DialogEx tag="systemMidiBindings" open={open} fullWidth onClose={this.handleClose} aria-labelledby="Rename-dialog-title"
fullScreen={true}
style={{ userSelect: "none" }}
onEnterKey={()=>{}}
>
<div style={{ display: "flex", flexDirection: "column", flexWrap: "nowrap", width: "100%", height: "100%", overflow: "hidden" }}>
<div style={{ flex: "0 0 auto" }}>
<AppBar className={classes.dialogAppBar} >
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={this.handleClose}
aria-label="back"
size="large">
<ArrowBackIcon />
</IconButton>
<Typography variant="h6" className={classes.dialogTitle}>
System MIDI Bindings
</Typography>
</Toolbar>
</AppBar>
</div>
<div style={{ overflow: "auto", flex: "1 1 auto", width: "100%" }}>
<table className={classes.pluginTable} >
<colgroup>
<col style={{ width: "auto" }} />
<col style={{ width: "100%" }} />
</colgroup>
<tbody>
{this.generateTable()}
</tbody>
</table>
</div>
</div>
<Snackbar
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
open={this.state.listenSnackbarOpen}
autoHideDuration={1500}
onClose={() => this.setState({ listenSnackbarOpen: false })}
message="Listening for MIDI input"
/>
</DialogEx >
);
}
},
styles
);
export default SystemMidiBindingDialog;
+117
View File
@@ -0,0 +1,117 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React, { Component } from 'react';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import {createStyles} from './WithStyles';
import IconButton from '@mui/material/Toolbar';
import Drawer from '@mui/material/Drawer';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Theme } from '@mui/material/styles';
import {isDarkMode} from './DarkMode';
const drawerStyles = (theme: Theme) => {
return createStyles({
list: {
width: 250,
},
fullList: {
width: 'auto',
},
drawer_header: {
color: theme.palette.primary.main,
background: (isDarkMode()? theme.palette.background.default: 'white'),
}
})};
type Anchor = 'top' | 'left' | 'bottom' | 'right';
type CloseEventHandler = () => void;
interface DrawerProps extends WithStyles<typeof drawerStyles> {
title?: string;
position: Anchor;
is_open: boolean;
onClose?: CloseEventHandler;
children?: React.ReactNode;
}
type DrawerState = {
is_open: boolean;
}
export const TemporaryDrawer = withStyles(
class extends Component<DrawerProps, DrawerState>
{
constructor(props: DrawerProps) {
super(props);
this.state = { is_open: props.is_open };
}
toggleDrawer(anchor: Anchor, open: boolean): void {
this.setState({ is_open: open });
};
fireClose() {
let handler = this.props.onClose;
if (handler != null) {
handler();
}
}
render() {
const classes = withStyles.getClasses(this.props);
return (
<div>
<React.Fragment>
<Drawer anchor={this.props.position} open={this.props.is_open} onClose={() => { this.fireClose(); }} >
<div
className={this.props.position === 'top' || this.props.position === 'bottom' ? classes.fullList : classes.list}
role="presentation"
onClick={() => { this.fireClose(); }}
onKeyDown={() => { this.fireClose(); }}
>
<div style={{ display: "flex", flexFlow: "row nowrap", justifyContent: "flex-start", alignItems: "center", width: "100%"}}>
<IconButton style={{ flex: "0 0 auto" }} >
<ArrowBackIcon style={{ fill: '#666' }} />
</IconButton>
<img src="img/Pi-Logo-3.png" alt="" style={{height: 36}} />
</div>
{this.props.children}
</div>
</Drawer>
</React.Fragment>
</div >
);
}
},
drawerStyles
);
+185
View File
@@ -0,0 +1,185 @@
import React from 'react';
import TextField, { TextFieldProps } from '@mui/material/TextField';
import { withStyles, WithStyles } from '@mui/styles';
import {createStyles} from './WithStyles';
import { Theme } from '@mui/material/styles';
import Modal from '@mui/material/Modal';
const styles = (theme: Theme) => createStyles({
});
export interface TextFieldExPropsBase extends WithStyles<typeof styles> {
label?: string;
containerStyle?: React.CSSProperties;
autoFocus?: boolean;
inputRef?: React.Ref<any>;
theme: Theme;
};
export type TextFieldExProps = TextFieldExPropsBase & TextFieldProps;
export interface TextFieldExState {
androidHosted: boolean;
composingText: string;
displayText: string;
showIme: boolean;
imeValue: string;
};
const TextFieldEx = withStyles(styles, { withTheme: true })(
class extends React.Component<TextFieldExProps, TextFieldExState> {
constructor(props: TextFieldExProps) {
super(props);
this.state = {
composingText: "",
displayText: "",
androidHosted: true,
showIme: false,
imeValue: ""
};
this.handleClick = this.handleClick.bind(this);
this.handleImeCompositionStart = this.handleImeCompositionStart.bind(this);
this.handleCompositionEnd = this.handleCompositionEnd.bind(this);
this.handleImeCompositionUpdate = this.handleImeCompositionUpdate.bind(this);
this.handleImeCompositionEnd = this.handleImeCompositionEnd.bind(this);
}
private getUseImeMask() {
return this.state.androidHosted && window.innerHeight < 4500;
}
private originalInputHref: HTMLInputElement | undefined = undefined;
private imeInputHref: HTMLInputElement | undefined = undefined;
handleInputRef(input: HTMLInputElement) {
if (input) {
this.originalInputHref = input;
if (this.getUseImeMask()) {
input.addEventListener('click', this.handleClick);
input.addEventListener('input', this.handleInput);
}
} else {
if (this.originalInputHref) {
this.originalInputHref.removeEventListener('click', this.handleClick);
this.originalInputHref.removeEventListener('input', this.handleInput);
this.originalInputHref = undefined;
}
}
}
handleClick(event: FocusEvent) {
(event.target as any).blur();
event.preventDefault();
event.stopPropagation();
this.setState({
imeValue: (event.target as any).value as string,
showIme: true
});
return false;
}
handleImeCompositionStart(event: any) {
// this.setState({
// composingText: '' ,
// imeValue: '', //event.target.value as string,
// showIme: true
// });
}
handleImeCompositionUpdate(event: CompositionEvent) {
this.setState({ composingText: event.data });
};
handleCompositionEnd = (event: CompositionEvent) => {
this.setState(prevState => ({
displayText: prevState.displayText + event.data,
composingText: '',
}));
};
dismissIme() {
if (this.originalInputHref && this.imeInputHref)
{
let value = this.imeInputHref.value;
this.originalInputHref.value = this.imeInputHref.value;
this.originalInputHref.blur();
this.imeInputHref.blur();
this.setState({
showIme: false, imeValue: value
});
}
}
handleImeCompositionEnd()
{
this.dismissIme();
}
handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
if (!this.state.composingText) {
this.setState({ displayText: target.value });
}
};
render() {
let { autoFocus, containerStyle, label, ...extra } = this.props;
return (
<div style={this.props.containerStyle}>
<TextField {...extra} autoFocus={autoFocus} tabIndex={-1} inputRef={(input) => this.handleInputRef(input)} />
<p>Display Text: {this.state.imeValue}</p>
<p>Composing Text: {this.state.composingText}</p>
{this.state.showIme && (
<Modal open={this.state.showIme}>
<div style={{
position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
background: this.props.theme.palette.background.default,
display: "flex", flexFlow: "row nowrap",justifyContent: "center"
}}>
<TextField variant="standard"
inputRef={(element: HTMLInputElement)=>{
if (this.imeInputHref) {
this.imeInputHref.removeEventListener("compositionend",this.handleImeCompositionEnd);
this.imeInputHref.removeEventListener("compositionupdate",this.handleImeCompositionUpdate);
this.imeInputHref.removeEventListener("compositionstart",this.handleImeCompositionStart);
}
this.imeInputHref = element;
if (this.imeInputHref)
{
this.imeInputHref.addEventListener("compositionend",this.handleImeCompositionEnd);
this.imeInputHref.addEventListener("compositionupdate",this.handleImeCompositionUpdate);
this.imeInputHref.addEventListener("compositionstart",this.handleImeCompositionStart);
}
}}
style={{width: 200,marginTop: 16}}
autoFocus
value={this.state.imeValue}
onChange={(event: any) => {
this.setState({ imeValue: event.target.value as string });
}}
onBlur={() => {
this.dismissIme();
}}
onKeyDown={(event)=> {
if (event.key === "Enter")
{
this.setState({showIme: false});
}
}}
/>
</div>
</Modal>
)}
</div>
);
}
});
export default TextFieldEx;
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import IControlViewFactory from './IControlViewFactory';
import { PiPedalModelFactory, PiPedalModel } from "./PiPedalModel";
import { PedalboardItem } from './Pedalboard';
import PluginControlView, { ControlGroup,ControlViewCustomization } from './PluginControlView';
import ToobFrequencyResponseView from './ToobFrequencyResponseView';
const styles = (theme: Theme) => createStyles({
});
interface ToobCabSimProps extends WithStyles<typeof styles> {
instanceId: number;
item: PedalboardItem;
}
interface ToobCabSimState {
}
const ToobCabSimView =
withStyles(
class extends React.Component<ToobCabSimProps, ToobCabSimState>
implements ControlViewCustomization
{
model: PiPedalModel;
customizationId: number = 1;
constructor(props: ToobCabSimProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
}
}
ModifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[]
{
controls.splice(0,0,
( <ToobFrequencyResponseView instanceId={this.props.instanceId} />)
);
return controls;
}
render() {
return (<PluginControlView
instanceId={this.props.instanceId}
item={this.props.item}
customization={this}
customizationId={this.customizationId}
/>);
}
},
styles
);
class ToobCabSimViewFactory implements IControlViewFactory {
uri: string = "http://two-play.com/plugins/toob-cab-sim";
Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode {
return (<ToobCabSimView instanceId={pedalboardItem.instanceId} item={pedalboardItem} />);
}
}
export default ToobCabSimViewFactory;
@@ -0,0 +1,306 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import { PiPedalModelFactory, PiPedalModel, State } from "./PiPedalModel";
import Utility from './Utility';
import SvgPathBuilder from './SvgPathBuilder';
const StandardItemSize = { width: 80, height: 110 };
const FREQUENCY_RESPONSE_VECTOR_URI = "http://two-play.com/plugins/toob#frequencyResponseVector";
const PLOT_WIDTH = StandardItemSize.width * 2 - 16;
const PLOT_HEIGHT = StandardItemSize.height - 12;
const styles = (theme: Theme) => createStyles({
frame: {
width: PLOT_WIDTH, height: PLOT_HEIGHT, background: "#444",
borderRadius: 6,
marginTop: 12, marginLeft: 8, marginRight: 8,
boxShadow: "1px 4px 8px #000 inset"
},
});
interface ToobFrequencyResponseProps extends WithStyles<typeof styles> {
instanceId: number;
propertyName?: string;
width?: number;
}
interface ToobFrequencyResponseState {
minDb: number;
maxDb: number;
minF: number;
maxF: number;
path: string;
}
const ToobFrequencyResponseView =
withStyles(
class extends React.Component<ToobFrequencyResponseProps, ToobFrequencyResponseState>
{
model: PiPedalModel;
customizationId: number = 1;
pathRef: React.RefObject<SVGPathElement|null>;
constructor(props: ToobFrequencyResponseProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
path: "",
minDb: -30,
maxDb: 5,
minF: 30,
maxF: 20000
};
this.pathRef = React.createRef();
this.onPedalboardChanged = this.onPedalboardChanged.bind(this);
this.onStateChanged = this.onStateChanged.bind(this);
}
onStateChanged() {
if (this.model.state.get() === State.Ready) {
this.requestDeferred = false;
this.requestOutstanding = false;
this.updateAllWaveShapes(); // after a reconnect.
}
}
onPedalboardChanged() {
this.updateAllWaveShapes();
}
private mounted: boolean = false;
componentDidMount() {
this.mounted = true;
this.model.state.addOnChangedHandler(this.onStateChanged);
this.model.pedalboard.addOnChangedHandler(this.onPedalboardChanged);
this.updateAllWaveShapes();
}
componentWillUnmount() {
this.mounted = false;
this.model.state.removeOnChangedHandler(this.onStateChanged);
this.model.pedalboard.removeOnChangedHandler(this.onPedalboardChanged);
}
componentDidUpdate() {
//this.updateFrequencyResponse();
}
requestOutstanding: boolean = false;
requestDeferred: boolean = false;
dbMinDefault: number = -35;
dbMaxDefault: number = 5;
dbTickSpacingOpt: number = 10;
MIN_DB_AF: number = Math.pow(10, -192 / 20);
fMin: number = 30;
fMax: number = 20000;
logMin: number = Math.log(this.fMin);
logMax: number = Math.log(this.fMax);
// Size of the SVG element.
xMin: number = 0;
xMax: number = PLOT_WIDTH + 4;
yMin: number = 0;
yMax: number = PLOT_HEIGHT;
dbTickSpacing: number = this.dbTickSpacingOpt;
toX(frequency: number): number {
var logV = Math.log(frequency);
return (this.xMax - this.xMin) * (logV - this.logMin) / (this.logMax - this.logMin) + this.xMin;
}
toY(value: number): number {
value = Math.abs(value);
var db;
if (value < this.MIN_DB_AF) {
db = -192.0;
} else {
db = 20 * Math.log10(value);
}
var y = (db - this.dbMin) / (this.dbMax - this.dbMin) * (this.yMax - this.yMin) + this.yMin;
return y;
}
onFrequencyResponseUpdated(data: number[]) {
if (!this.mounted) return;
let pathBuilder = new SvgPathBuilder();
let dbMin = data[2];
let dbMax = data[3];
let n = data.length-4;
let toX_ = (bin: number): number => {
return (this.xMax - this.xMin) * bin/n;
};
let toY_ = (value: number): number => {
var db;
if (value < this.MIN_DB_AF) {
db = -192.0;
} else {
db = 20 * Math.log10(value);
}
var y = (db - dbMin) / (dbMax - dbMin) * (this.yMax - this.yMin) + this.yMin;
return y;
};
if (n >= 2) {
pathBuilder.moveTo(toX_(0), toY_(data[4]))
for (let i = 1; i < n; i++) {
pathBuilder.lineTo(toX_(i), toY_(data[i+4]));
}
}
this.currentPath = pathBuilder.toString();
this.setState({
minF: data[0],
maxF: data[1],
maxDb: data[2],
minDb: data[3],
path: this.currentPath
});
}
private getPropertyUri()
{
return this.props.propertyName? this.props.propertyName: FREQUENCY_RESPONSE_VECTOR_URI;
}
updateAllWaveShapes() {
if (this.requestOutstanding) { // throttling.
this.requestDeferred = true;
return;
}
this.requestOutstanding = true;
this.model.getPatchProperty<any>(this.props.instanceId, this.getPropertyUri())
.then((json) => {
if (json && json.otype_ === "Vector" && json.vtype_ === "Float") {
this.onFrequencyResponseUpdated(json.value as number[]);
if (this.requestDeferred) {
Utility.delay(10) // take breath
.then(
() => {
this.requestOutstanding = false;
this.requestDeferred = false;
this.updateAllWaveShapes();
}
);
} else {
this.requestOutstanding = false;
}
}
}).catch(error => {
// assume the connection was lost. We'll get saved by a reconnect.
this.requestOutstanding = false;
this.requestDeferred = false;
});
}
currentPath: string = "";
majorGridLine(x0: number, y0: number, x1: number, y1: number): React.ReactNode {
return (<line key={"l" + this.nextKey++} x1={x0} y1={y0} x2={x1} y2={y1} fill='none' stroke="#FFF" strokeWidth="0.75" opacity="1" />);
}
gridLine(x0: number, y0: number, x1: number, y1: number): React.ReactNode {
return (<line key={"l" + this.nextKey++} x1={x0} y1={y0} x2={x1} y2={y1} fill='none' stroke="#FFF" strokeWidth="0.25" opacity="1" />);
}
grid(): React.ReactNode[] {
let result: React.ReactNode[] = [];
for (var db = Math.ceil(this.dbMax / this.dbTickSpacing) * this.dbTickSpacing; db < this.dbMin; db += this.dbTickSpacing) {
var y = (db - this.dbMin) / (this.dbMax - this.dbMin) * (this.yMax - this.yMin);
if (db === 0) {
result.push(
this.majorGridLine(this.xMin, y, this.xMax, y)
);
} else {
result.push(
this.gridLine(this.xMin, y, this.xMax, y)
);
}
}
let decade0 = Math.pow(10, Math.floor(Math.log10(this.fMin)));
for (var decade = decade0; decade < this.fMax; decade *= 10) {
for (var i = 1; i <= 10; ++i) {
var f = decade * i;
if (f > this.fMin && f < this.fMax) {
var x = this.toX(f);
if (i === 10) {
result.push(this.majorGridLine(x, this.yMin, x, this.yMax));
} else {
result.push(this.gridLine(x, this.yMin, x, this.yMax));
}
}
}
}
return result;
}
dbMin: number = 5;
dbMax: number = -35;
private nextKey: number = 0;
render() {
// deliberately reversed to flip up and down.
this.nextKey = 0;
this.dbMax = this.state.minDb;
this.dbMin = this.state.maxDb;
this.fMin = this.state.minF;
this.fMax = this.state.maxF;
this.logMin = Math.log(this.fMin);
this.logMax = Math.log(this.fMax);
const classes = withStyles.getClasses(this.props);
return (
<div className={classes.frame} >
<svg width={PLOT_WIDTH} height={PLOT_HEIGHT} viewBox={"0 0 " + PLOT_WIDTH + " " + PLOT_HEIGHT} stroke="#0F8" fill="none" strokeWidth="2.5" opacity="0.6">
{this.grid()}
<path d={this.state.path} ref={this.pathRef} />
</svg>
</div>);
}
},
styles
);
export default ToobFrequencyResponseView;
+96
View File
@@ -0,0 +1,96 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import IControlViewFactory from './IControlViewFactory';
import { PiPedalModelFactory, PiPedalModel } from "./PiPedalModel";
import { PedalboardItem } from './Pedalboard';
import PluginControlView, { ControlGroup,ControlViewCustomization } from './PluginControlView';
//import ToobFrequencyResponseView from './ToobFrequencyResponseView';
const styles = (theme: Theme) => createStyles({
});
interface ToobInputStageProps extends WithStyles<typeof styles> {
instanceId: number;
item: PedalboardItem;
}
interface ToobInputStageState {
}
const ToobInputStageView =
withStyles(
class extends React.Component<ToobInputStageProps, ToobInputStageState>
implements ControlViewCustomization
{
model: PiPedalModel;
customizationId: number = 1;
constructor(props: ToobInputStageProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
}
}
ModifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[]
{
return controls;
// let group = controls[1] as ControlGroup;
// group.controls.splice(0,0,
// ( <ToobFrequencyResponseView instanceId={this.props.instanceId} />)
// );
// return controls;
}
render() {
return (<PluginControlView
instanceId={this.props.instanceId}
item={this.props.item}
customization={this}
customizationId={this.customizationId}
/>);
}
},
styles
);
class ToobInputStageViewFactory implements IControlViewFactory {
uri: string = "http://two-play.com/plugins/toob-input_stage";
Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode {
return (<ToobInputStageView instanceId={pedalboardItem.instanceId} item={pedalboardItem} />);
}
}
export default ToobInputStageViewFactory;
+161
View File
@@ -0,0 +1,161 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import IControlViewFactory from './IControlViewFactory';
import { PiPedalModelFactory, PiPedalModel, MonitorPortHandle } from "./PiPedalModel";
import { PedalboardItem } from './Pedalboard';
import PluginControlView, { ControlGroup, ControlViewCustomization } from './PluginControlView';
// import ToobFrequencyResponseView from './ToobFrequencyResponseView';
const styles = (theme: Theme) => createStyles({
});
interface ToobMLProps extends WithStyles<typeof styles> {
instanceId: number;
item: PedalboardItem;
}
interface ToobMLState {
gainEnabled: boolean;
}
const ToobMLView =
withStyles(
class extends React.Component<ToobMLProps, ToobMLState>
implements ControlViewCustomization {
model: PiPedalModel;
gainRef: React.RefObject<HTMLDivElement|null>;
customizationId: number = 1;
constructor(props: ToobMLProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.gainRef = React.createRef();
this.state = {
gainEnabled: true
}
}
subscribedId?: number = undefined;
monitorPortHandle?: MonitorPortHandle = undefined;
removeGainEnabledSubscription() {
if (this.monitorPortHandle) {
this.model.unmonitorPort(this.monitorPortHandle);
this.monitorPortHandle = undefined;
}
}
addGainEnabledSubscription(instanceId: number) {
this.removeGainEnabledSubscription();
this.subscribedId = instanceId;
this.monitorPortHandle = this.model.monitorPort(instanceId, "gainEnable", 0.1,
(value: number) => {
if (this.gainRef.current) {
this.gainRef.current.style.opacity = value !== 0.0 ? "1.0" : "0.4";
if (value !== 0.0) {
this.gainRef.current.style.pointerEvents = 'auto';
} else {
this.gainRef.current.style.pointerEvents = 'none';
}
}
this.setState({ gainEnabled: value !== 0.0 });
});
}
// componentDidUpdate() {
// if (this.props.instanceId !== this.subscribedId) {
// this.removeGainEnabledSubscription();
// this.addGainEnabledSubscription(this.props.instanceId);
// }
// }
componentDidMount() {
this.addGainEnabledSubscription(this.props.instanceId);
if (this.gainRef.current) {
this.gainRef.current.style.opacity = '0.4';
this.gainRef.current.style.pointerEvents = 'none';
}
}
componentWillUnmount() {
this.removeGainEnabledSubscription();
}
ModifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] {
// Find EQ group
// let group = controls.find((control) => typeof control !== 'string' && (control as ControlGroup).name === "EQ") as ControlGroup;
// if (group) {
// group.controls.splice(0,0,
// ( <ToobFrequencyResponseView
// instanceId={this.props.instanceId}
// />)
// );
// }
let controlIndex: number = -1;
let controlValues = this.props.item.controlValues
for (let i:number = 0; i < controlValues.length; ++i)
{
let ctl: any = controls[i];
let key: any = ctl?.props?.uiControl?.symbol??"";
if (key === "gain")
{
controlIndex = i;
break;
}
}
if (controlIndex !== -1 ) {
let gainControl: React.ReactElement = controls[controlIndex] as React.ReactElement;
controls[controlIndex] = (<div ref={this.gainRef}> {gainControl} </div>);
}
return controls;
}
render() {
return (<PluginControlView
instanceId={this.props.instanceId}
item={this.props.item}
customization={this}
customizationId={this.customizationId}
/>);
}
},
styles
);
class ToobMLViewFactory implements IControlViewFactory {
uri: string = "http://two-play.com/plugins/toob-ml";
Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode {
return (<ToobMLView instanceId={pedalboardItem.instanceId} item={pedalboardItem} />);
}
}
export default ToobMLViewFactory;
+196
View File
@@ -0,0 +1,196 @@
// Copyright (c) 2022 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import React from 'react';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import {createStyles} from './WithStyles';
import { withStyles } from "tss-react/mui";
import IControlViewFactory from './IControlViewFactory';
import { PiPedalModelFactory, PiPedalModel, State,ListenHandle } from "./PiPedalModel";
import { PedalboardItem } from './Pedalboard';
import PluginControlView, { ControlGroup,ControlViewCustomization } from './PluginControlView';
import ToobWaveShapeView, {BidirectionalVuPeak} from './ToobWaveShapeView';
let POWERSTAGE_UI_URI = "http://two-play.com/plugins/toob-power-stage-2#uiState";
const styles = (theme: Theme) => createStyles({
});
interface ToobPowerstage2Props extends WithStyles<typeof styles> {
instanceId: number;
item: PedalboardItem;
}
interface ToobPowerstage2State {
uiState: number[];
}
const ToobPowerstage2View =
withStyles(
class extends React.Component<ToobPowerstage2Props, ToobPowerstage2State>
implements ControlViewCustomization
{
model: PiPedalModel;
customizationId: number = 5;
constructor(props: ToobPowerstage2Props) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
uiState: [0,0,0,0,0,0,0,0]
}
this.onStateChanged = this.onStateChanged.bind(this);
}
listeningForOutput: boolean = false;
atomOutputHandle?: ListenHandle;
maybeListenForAtomOutput(): void {
let listenForOutput = this.isReady && this.isControlMounted;
if (listenForOutput !== this.listeningForOutput)
{
this.listeningForOutput = listenForOutput;
if (listenForOutput)
{
this.atomOutputHandle = this.model.monitorPatchProperty(
this.props.instanceId,
POWERSTAGE_UI_URI,
(instanceId: number, propertyUri: string, json: any) => {
if (json.otype_ === "Vector")
{
this.setState({uiState: json.value as number[]});
}
});
// discard the output as we will be getting it through monitorPatchProperty as well.
this.model.getPatchProperty(this.props.instanceId,POWERSTAGE_UI_URI);
} else {
if (this.atomOutputHandle)
{
this.model.cancelMonitorPatchProperty(this.atomOutputHandle);
this.atomOutputHandle = undefined;
}
}
}
}
isReady: boolean = false;
gain1Peak: BidirectionalVuPeak = new BidirectionalVuPeak();
gain2Peak: BidirectionalVuPeak = new BidirectionalVuPeak();
gain3Peak: BidirectionalVuPeak = new BidirectionalVuPeak();
gain1OutPeak: BidirectionalVuPeak = new BidirectionalVuPeak();
gain2OutPeak: BidirectionalVuPeak = new BidirectionalVuPeak();
gain3OutPeak: BidirectionalVuPeak = new BidirectionalVuPeak();
onStateChanged() {
this.isReady = this.model.state.get() === State.Ready;
this.maybeListenForAtomOutput();
}
isControlMounted: boolean = false;
componentDidMount() {
this.model.state.addOnChangedHandler(
this.onStateChanged);
this.isControlMounted = true;
this.isReady = this.model.state.get() === State.Ready;
this.maybeListenForAtomOutput();
}
componentWillUnmount()
{
this.isControlMounted = false;
this.maybeListenForAtomOutput();
}
ModifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[]
{
let time = Date.now()*0.001;
this.gain1Peak.update(time,this.state.uiState[0],this.state.uiState[1]);
this.gain1OutPeak.update(time,this.state.uiState[2],this.state.uiState[3]);
this.gain2Peak.update(time,this.state.uiState[4],this.state.uiState[5]);
this.gain2OutPeak.update(time,this.state.uiState[6],this.state.uiState[7]);
this.gain3Peak.update(time,this.state.uiState[8],this.state.uiState[9]);
this.gain3OutPeak.update(time,this.state.uiState[10],this.state.uiState[11]);
var gain1 = (
<ToobWaveShapeView instanceId={this.props.instanceId} controlNumber={1} controlKeys={["gain1","shape1","bias1"]}
vuMin={this.gain1Peak.minValue} vuMax={this.gain1Peak.maxValue}
vuMinPeak={this.gain1Peak.minPeak} vuMaxPeak={this.gain1Peak.maxPeak}
vuOutMin={this.gain1OutPeak.minValue} vuOutMax={this.gain1OutPeak.maxValue}
vuOutMinPeak={this.gain1OutPeak.minPeak} vuOutMaxPeak={this.gain1OutPeak.maxPeak}
/>
);
var gain2 = (
<ToobWaveShapeView instanceId={this.props.instanceId} controlNumber={2} controlKeys={["gain2","shape2","bias2"]}
vuMin={this.gain2Peak.minValue} vuMax={this.gain2Peak.maxValue}
vuMinPeak={this.gain2Peak.minPeak} vuMaxPeak={this.gain2Peak.maxPeak}
vuOutMin={this.gain2OutPeak.minValue} vuOutMax={this.gain2OutPeak.maxValue}
vuOutMinPeak={this.gain2OutPeak.minPeak} vuOutMaxPeak={this.gain2OutPeak.maxPeak}
/>
);
var gain3 = (
<ToobWaveShapeView instanceId={this.props.instanceId} controlNumber={3} controlKeys={["gain3","shape3","bias3"]}
vuMin={this.gain3Peak.minValue} vuMax={this.gain3Peak.maxValue}
vuMinPeak={this.gain3Peak.minPeak} vuMaxPeak={this.gain3Peak.maxPeak}
vuOutMin={this.gain3OutPeak.minValue} vuOutMax={this.gain3OutPeak.maxValue}
vuOutMinPeak={this.gain3OutPeak.minPeak} vuOutMaxPeak={this.gain3OutPeak.maxPeak}
/>
);
((controls[0]) as ControlGroup).controls.splice(3,0,gain1);
((controls[1]) as ControlGroup).controls.splice(3,0,gain2);
((controls[2]) as ControlGroup).controls.splice(3,0,gain3);
return controls;
}
render() {
return (<PluginControlView
instanceId={this.props.instanceId}
item={this.props.item}
customization={this}
customizationId={this.customizationId}
/>);
}
},
styles
);
class ToobPowerstage2ViewFactory implements IControlViewFactory {
uri: string = "http://two-play.com/plugins/toob-power-stage-2";
Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode {
return (<ToobPowerstage2View instanceId={pedalboardItem.instanceId} item={pedalboardItem} />);
}
}
export default ToobPowerstage2ViewFactory;

Some files were not shown because too many files have changed in this diff Show More