First commit.

This commit is contained in:
Robin Davies
2021-08-15 12:42:46 -04:00
commit d8a6022947
264 changed files with 95068 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
import React, { SyntheticEvent, Component } from 'react';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import { PiPedalModel, PiPedalModelFactory, State } from './PiPedalModel';
import { TransitionProps } from '@material-ui/core/transitions/transition';
import Slide from '@material-ui/core/Slide';
import Dialog from '@material-ui/core/Dialog';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import { Theme, withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import Divider from '@material-ui/core/Divider';
import JackHostStatus from './JackHostStatus';
interface AboutDialogProps extends WithStyles<typeof styles> {
open: boolean;
onClose: () => void;
};
interface AboutDialogState {
jackStatus?: JackHostStatus;
};
const styles = (theme: Theme) => createStyles({
dialogAppBar: {
position: 'relative',
top: 0, left: 0
},
dialogTitle: {
marginLeft: theme.spacing(2),
flex: 1,
},
sectionHead: {
marginLeft: 24,
marginRight: 24,
marginTop: 16,
paddingBottom: 12
},
textBlock: {
marginLeft: 24,
marginRight: 24,
marginTop: 16,
paddingBottom: 0,
opacity: 0.95
},
textBlockIndented: {
marginLeft: 40,
marginRight: 24,
marginTop: 16,
paddingBottom: 0,
opacity: 0.95
},
setting: {
minHeight: 64,
width: "100%",
textAlign: "left",
paddingLeft: 22,
paddingRight: 22
},
primaryItem: {
},
secondaryItem: {
}
});
const Transition = React.forwardRef(function Transition(
props: TransitionProps & { children?: React.ReactElement },
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
const AboutDialog = withStyles(styles, { withTheme: true })(
class extends Component<AboutDialogProps, AboutDialogState> {
model: PiPedalModel;
constructor(props: AboutDialogProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.handleDialogClose = this.handleDialogClose.bind(this);
this.state = {
jackStatus: undefined
};
}
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?: NodeJS.Timeout;
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;
}
}
componentDidMount() {
this.mounted = true;
this.updateNotifications();
}
componentWillUnmount() {
this.mounted = false;
this.updateNotifications();
}
componentDidUpdate() {
this.updateNotifications();
}
handleDialogClose(e: SyntheticEvent) {
this.props.onClose();
}
render() {
let classes = this.props.classes;
return (
<Dialog fullScreen open={this.props.open}
onClose={() => { this.props.onClose() }} TransitionComponent={Transition}>
<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.handleDialogClose} aria-label="back"
>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6" className={classes.dialogTitle}>
About
</Typography>
</Toolbar>
</AppBar>
</div>
<div style={{
flex: "1 1 auto", position: "relative", overflow: "hidden",
overflowX: "hidden", overflowY: "auto", margin: 22
}}
>
<div>
<Typography display="block" variant="h6" color="textPrimary">
PiPedal <span style={{fontSize: "0.7em"}}>
{(this.model.serverVersion? this.model.serverVersion.serverVersion : "")
+ (this.model.serverVersion?.debug ? " (Debug)": "")}
</span>
</Typography>
<Typography display="block" variant="body2" style={{marginBottom: 12}} >
Copyright &#169; 2021 Robin Davies. All rights reserved.
</Typography>
<Divider/>
<Typography display="block" variant="caption" >
JACK AUDIO STATUS
</Typography>
<div style={{paddingTop: 8, paddingBottom: 24}}>
{
JackHostStatus.getDisplayView("",this.state.jackStatus)
}
</div>
<Divider/>
<Typography display="block" variant="caption" >
LEGAL NOTICES
</Typography>
<Typography display="block" variant="caption" style={{paddingTop: 12, paddingBottom: 12}} >
Pi Pedal uses the following open-source components.
</Typography>
<Typography display="block" variant="caption" style={{paddingTop: 12, paddingBottom: 12}} >
TBD
</Typography>
</div>
</div>
</div>
</Dialog >
);
}
}
);
export default AboutDialog;
+1
View File
@@ -0,0 +1 @@
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+830
View File
@@ -0,0 +1,830 @@
import { SyntheticEvent } from 'react';
import { ThemeProvider } from '@material-ui/styles';
import { createMuiTheme } from '@material-ui/core/styles';
import './App.css';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import CssBaseline from '@material-ui/core/CssBaseline';
import Typography from '@material-ui/core/Typography';
import { createStyles, withStyles, WithStyles } from '@material-ui/core/styles';
import IconButton from '@material-ui/core/IconButton';
import MenuButton from '@material-ui/icons/Menu';
import { TemporaryDrawer } from './TemporaryDrawer';
import { Theme } from '@material-ui/core/styles/createMuiTheme';
import FullscreenIcon from '@material-ui/icons/Fullscreen';
import FullscreenExitIcon from '@material-ui/icons/FullscreenExit';
import List from '@material-ui/core/List';
import Divider from '@material-ui/core/Divider';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import CircularProgress from '@material-ui/core/CircularProgress';
import { OnChangedHandler } from './ObservableProperty';
import ErrorOutlineIcon from '@material-ui/icons/Error';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import Button from '@material-ui/core/Button';
import PresetSelector from './PresetSelector';
import SettingsDialog from './SettingsDialog';
import AboutDialog from './AboutDialog';
import BankDialog from './BankDialog';
import { PiPedalModelFactory, PiPedalModel, State } from './PiPedalModel';
import MainPage from './MainPage';
import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import ListSubheader from '@material-ui/core/ListSubheader';
import { BankIndex, BankIndexEntry } from './Banks';
import RenameDialog from './RenameDialog';
import JackStatusView from './JackStatusView';
const theme = createMuiTheme({
palette: {
primary: {
main: "#324c6c"
}
// secondary: {
// main: "#00E676"
// }
}
});
const appStyles = ({ palette, spacing, mixins }: Theme) => createStyles({
loadingContent: {
display: "block",
position: "absolute",
minHeight: "10em",
left: "0px",
top: "0px",
width: "100%",
height: "100%",
background: "#DDD",
opacity: "0.95",
justifyContent: "center",
textAlign: "center",
zIndex: 2010
},
errorContent: {
display: "flex",
flexDirection: "column",
justifyContent: "center",
flexWrap: "nowrap",
alignItems: "center",
position: "fixed",
minHeight: "10em",
left: "0px",
top: "0px",
width: "100%",
height: "100%",
color: "#444",
zIndex: 2000
},
errorContentMask: {
position: "absolute", left: 0, top: 0,
width: "100%", height: "100%",
background: "#BBB",
opacity: 0.95,
zIndex: 1999
},
errorText: {
marginTop: 0,
padding: 0,
marginBottom: 12,
fontWeight: 500,
fontSize: "13pt",
maxWidth: 250,
opacity: 1,
zIndex: 2010,
},
progressText: {
marginTop: 0,
fontWeight: 500,
fontSize: "13pt",
maxWidth: 250,
opacity: 1,
zIndex: 2010,
paddingTop: 12,
},
errorMessageBox: {
flex: "0 0 auto",
width: 300,
marginLeft: "auto",
marginRight: "auto",
zIndex: 2010
},
errorMessage: {
color: '#000',
textAlign: "left",
zIndex: 2010
},
loadingBox: {
position: "relative",
top: "20%",
width: "120px",
color: "#888",
marginLeft: "auto",
marginRight: "auto",
// border: "3px solid #888",
borderRadius: "12px",
padding: "12px",
justifyContent: "center",
textAlign: "center",
opacity: 0.8,
zIndex: 2010
},
loadingBoxItem: {
justifyContent: "center",
textAlign: "center",
zIndex: 2010
},
toolBarContent:
{
position: "absolute", top: 0, width: "100%"
},
toolBarSpacer:
{
position: "relative", flex: "0 0 auto"
},
mainFrame: {
overflow: "hidden",
display: "flex",
flexFlow: "column",
flex: "1 1 100%"
},
toolbarSizingPosition: {
flexGrow: 0,
flexShrink: 1,
width: "100%",
flexBasis: "auto"
},
mainSizingPosition: {
overflow: "hidden",
flex: "1 1 auto"
},
heroContent: {
backgroundColor: palette.background.paper,
position: "relative",
height: "100%",
width: "100%"
},
drawerItem: {
width: 350,
background: "#FF8080"
},
drawerItemFullWidth: {
width: 'auto',
},
icon: {
marginRight: spacing(2),
},
heroButtons: {
marginTop: spacing(4),
},
cardGrid: {
paddingTop: spacing(8),
paddingBottom: spacing(8),
},
card: {
height: '100%',
display: 'flex',
flexDirection: 'column',
},
cardMedia: {
paddingTop: '56.25%', // 16:9
},
cardContent: {
flexGrow: 1,
},
footer: {
backgroundColor: palette.background.paper,
padding: spacing(6),
},
});
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);
}
}
function preventDefault(e: SyntheticEvent): void {
e.stopPropagation();
e.preventDefault();
}
type AppState = {
isDrawerOpen: boolean;
errorMessage: string;
displayState: State;
canFullScreen: boolean;
isFullScreen: boolean;
tinyToolBar: boolean;
alertDialogOpen: boolean;
alertDialogMessage: string;
isSettingsDialogOpen: boolean;
isDebug: boolean;
renameBankDialogOpen: boolean;
saveBankAsDialogOpen: boolean;
aboutDialogOpen: boolean;
bankDialogOpen: boolean;
editBankDialogOpen: boolean;
presetName: string;
presetChanged: boolean;
banks: BankIndex;
bankDisplayItems: number;
};
interface AppProps extends WithStyles<typeof appStyles> {
}
const App = withStyles(appStyles)(class extends ResizeResponsiveComponent<AppProps, AppState> {
// Before the component mounts, we initialise our state
model_: PiPedalModel;
errorChangeHandler_: OnChangedHandler<string>;
stateChangeHandler_: OnChangedHandler<State>;
constructor(props: AppProps) {
super(props);
this.model_ = PiPedalModelFactory.getInstance();
this.state = {
isDrawerOpen: false,
errorMessage: this.model_.errorMessage.get(),
displayState: this.model_.state.get(),
canFullScreen: supportsFullScreen(),
isFullScreen: !!document.fullscreenElement,
tinyToolBar: false,
alertDialogOpen: false,
alertDialogMessage: "",
presetName: this.model_.presets.get().getSelectedText(),
isSettingsDialogOpen: false,
isDebug: true,
presetChanged: this.model_.presets.get().presetChanged,
banks: this.model_.banks.get(),
renameBankDialogOpen: false,
saveBankAsDialogOpen: false,
aboutDialogOpen: false,
bankDialogOpen: false,
editBankDialogOpen: false,
bankDisplayItems: 5
};
this.errorChangeHandler_ = this.setErrorMessage.bind(this);
this.stateChangeHandler_ = this.setDisplayState.bind(this);
this.presetChangedHandler = this.presetChangedHandler.bind(this);
this.alertMessageChangedHandler = this.alertMessageChangedHandler.bind(this);
this.handleCloseAlert = this.handleCloseAlert.bind(this);
this.banksChangedHandler = this.banksChangedHandler.bind(this);
}
onOpenBank(bankId: number) {
this.model_.openBank(bankId)
.catch((error) => this.model_.showAlert(error));
}
handleSaveBankAsOk(newName: string) {
let currentName = this.model_.banks.get().getSelectedEntryName();
if (currentName === newName) {
this.setState({
renameBankDialogOpen: false,
saveBankAsDialogOpen: false
});
return;
}
if (this.model_.banks.get().nameExists(newName)) {
this.model_.showAlert("A bank by that name already exists.");
return;
}
this.setState({
renameBankDialogOpen: false,
saveBankAsDialogOpen: false
});
this.model_.saveBankAs(this.model_.banks.get().selectedBank, newName)
.catch((error) => {
this.model_.showAlert(error);
});
}
handleBankRenameOk(newName: string) {
let currentName = this.model_.banks.get().getSelectedEntryName();
if (currentName === newName) {
this.setState({
renameBankDialogOpen: false,
saveBankAsDialogOpen: false
});
return;
}
if (this.model_.banks.get().nameExists(newName)) {
this.model_.showAlert("A bank by that name already exists.");
return;
}
this.setState({
renameBankDialogOpen: false
});
this.model_.renameBank(this.model_.banks.get().selectedBank, newName)
.catch((error) => {
this.model_.showAlert(error);
});
}
handleSettingsDialogClose() {
this.setState({
isSettingsDialogOpen: false
});
}
handleDrawerSettingsClick() {
this.setState({
isDrawerOpen: false,
isSettingsDialogOpen: true
});
}
handleDrawerManageBanks() {
this.setState({
isDrawerOpen: false,
bankDialogOpen: true,
editBankDialogOpen: true
});
}
handleDrawerSelectBank() {
this.setState({
isDrawerOpen: false,
bankDialogOpen: true,
editBankDialogOpen: false
});
}
handleDrawerAboutClick() {
this.setState({
aboutDialogOpen: true
});
}
handleDrawerRenameBank() {
this.setState({
isDrawerOpen: false,
renameBankDialogOpen: true,
saveBankAsDialogOpen: false
});
}
handleDrawerSaveBankAs() {
this.setState({
isDrawerOpen: false,
renameBankDialogOpen: false,
saveBankAsDialogOpen: true
});
}
handleCloseAlert(e?: any, reason?: any) {
this.model_.alertMessage.set("");
}
banksChangedHandler() {
this.setState({
banks: this.model_.banks.get()
});
}
presetChangedHandler() {
let presets = this.model_.presets.get();
this.setState({
presetName: presets.getSelectedText(),
presetChanged: presets.presetChanged
});
}
toggleFullScreen(): void {
setFullScreen(this.state.isFullScreen);
this.setState({ isFullScreen: !this.state.isFullScreen });
}
componentDidMount() {
super.componentDidMount();
window.addEventListener("beforeunload", (e) => {
e.preventDefault();
if (this.model_.state.get() === State.Ready)
{
e.returnValue = "Are you sure you want to leave this page?";
return "Are you sure you want to leave this page?";
}
});
this.model_.errorMessage.addOnChangedHandler(this.errorChangeHandler_);
this.model_.state.addOnChangedHandler(this.stateChangeHandler_);
this.model_.pedalBoard.addOnChangedHandler(this.presetChangedHandler);
this.model_.alertMessage.addOnChangedHandler(this.alertMessageChangedHandler);
this.model_.banks.addOnChangedHandler(this.banksChangedHandler);
this.alertMessageChangedHandler();
}
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_.serverVersion.debug;
let overscrollBehavior = preventOverscroll ? "none" : "auto";
document.body.style.overscrollBehavior = overscrollBehavior;
}
}
componentDidUpdate() {
}
componentWillUnmount() {
super.componentWillUnmount();
this.model_.errorMessage.removeOnChangedHandler(this.errorChangeHandler_);
this.model_.state.removeOnChangedHandler(this.stateChangeHandler_);
this.model_.pedalBoard.removeOnChangedHandler(this.presetChangedHandler);
this.model_.banks.addOnChangedHandler(this.banksChangedHandler);
}
alertMessageChangedHandler() {
let message = this.model_.alertMessage.get();
if (message === "") {
this.setState({ alertDialogOpen: false });
// leave the message intact so the dialog can fade.
} else {
this.setState({
alertDialogOpen: true,
alertDialogMessage: message
});
}
}
updateResponsive() {
// functional, but disabled.
// let tinyToolBar_ = this.windowSize.height < 600;
// this.setState({ tinyToolBar: tinyToolBar_ });
let height = this.windowSize.height;
const ENTRY_HEIGHT = 48;
// ENTRY_HEIGHT*6 +K = 727 from observation.
const K = 450;
let bankEntries = Math.floor((height-K)/ENTRY_HEIGHT);
if (bankEntries < 1) bankEntries = 1;
if (bankEntries > 7) bankEntries = 7;
this.setState({ bankDisplayItems: bankEntries });
}
onWindowSizeChanged(width: number, height: number): void {
super.onWindowSizeChanged(width, height);
this.updateResponsive();
}
setErrorMessage(message: string): void {
this.setState({ errorMessage: message });
}
setDisplayState(newState: State): void {
this.updateOverscroll();
this.setState({ displayState: newState });
}
showDrawer() {
this.setState({ isDrawerOpen: true })
}
hideDrawer() {
this.setState({ isDrawerOpen: false })
}
shortBankList(banks: BankIndex): BankIndexEntry[] {
let n = this.state.bankDisplayItems;
let entries = banks.entries;
if (entries.length < n + 1) { // +1 for the .... entry.
return entries;
}
let result: BankIndexEntry[] = [];
let selectedIndex = -1;
for (let i = 0; i < entries.length; ++i) {
if (entries[i].instanceId === banks.selectedBank) {
selectedIndex = i;
break;
}
}
if (n > entries.length) n = entries.length;
if (selectedIndex > n) {
for (let i = 0; i < n - 1; ++i) {
result.push(entries[i]);
}
result.push(entries[selectedIndex]);
} else {
for (let i = 0; i < n; ++i) {
result.push(entries[i]);
}
}
return result;
}
render() {
const { classes } = this.props;
let shortBankList = this.shortBankList(this.state.banks);
let showBankSelectDialog = shortBankList.length !== this.state.banks.entries.length;
return (
<ThemeProvider theme={theme}>
<div style={{
minHeight: 345, minWidth: 390,
position: "absolute", width: "100%", height: "100%", background: "#F88", userSelect: "none",
display: "flex", flexDirection: "column", flexWrap: "nowrap",
overscrollBehavior: this.state.isDebug ? "auto" : "none"
}}
onContextMenu={(e) => {
if (!this.model_.serverVersion?.debug??false)
{
e.preventDefault(); e.stopPropagation();
}
}}
>
<CssBaseline />
{(!this.state.tinyToolBar) ?
(
<AppBar position="absolute">
<Toolbar variant="dense" >
<IconButton edge="start"
color="inherit" aria-label="menu" onClick={() => { this.showDrawer() }}
>
<MenuButton />
</IconButton>
<div style={{ flex: "1 1 1px" }} />
<div style={{ flex: "0 1 400px", minWidth: 100 }}>
<PresetSelector />
</div>
<div style={{ flex: "2 2 30px" }} />
{this.state.canFullScreen &&
<IconButton
color="inherit" aria-label="menu" onClick={() => { this.toggleFullScreen(); }}>
{this.state.isFullScreen ? (
<FullscreenExitIcon />
) : (
<FullscreenIcon />
)}
</IconButton>
}
</Toolbar>
</AppBar>
) : (
<div className={classes.toolBarContent} >
<IconButton style={{ position: "absolute", left: 12, top: 8, zIndex: 2 }}
color="inherit" aria-label="menu" onClick={() => { this.showDrawer() }}
>
<MenuButton />
</IconButton>
{this.state.canFullScreen && (
<IconButton style={{ position: "absolute", right: 8, top: 8, zIndex: 2 }}
color="inherit" aria-label="menu" onClick={() => { this.toggleFullScreen(); }}>
{this.state.isFullScreen ? (
<FullscreenExitIcon />
) : (
<FullscreenIcon />
)}
</IconButton>
)}
</div>
)}
<TemporaryDrawer position='left' title="piddle"
is_open={this.state.isDrawerOpen} onClose={() => { this.hideDrawer(); }} >
<List subheader={
<ListSubheader component="div" id="nested-list-subheader">Banks</ListSubheader>
}>
{
shortBankList.map((bank) => {
return (
<ListItem button key={'bank' + bank.instanceId} selected={bank.instanceId === this.state.banks.selectedBank}
onClick={() => this.onOpenBank(bank.instanceId)}
>
<ListItemText primary={bank.name} />
</ListItem>
);
})
}
{
showBankSelectDialog && (
<ListItem button key={'bankDOTDOTDOT'} selected={false}
onClick={() => this.handleDrawerSelectBank()}
>
<ListItemText primary={"..."} />
</ListItem>
)
}
</List>
<Divider />
<List>
<ListItem button key='RenameBank' onClick={() => { this.handleDrawerRenameBank() }}>
<ListItemIcon><img src="img/drive_file_rename_outline_black_24dp.svg" alt="" style={{ opacity: 0.6 }} /></ListItemIcon>
<ListItemText primary='Rename Bank' />
</ListItem>
<ListItem button key='SaveBank' onClick={() => { this.handleDrawerSaveBankAs() }} >
<ListItemIcon>
<img src="img/save_bank_as.svg" alt="" style={{ opacity: 0.6 }} />
</ListItemIcon>
<ListItemText primary='Save As New Bank' />
</ListItem>
<ListItem button key='CreateBank' onClick={() => { this.handleDrawerManageBanks(); }}>
<ListItemIcon>
<img src="img/edit_banks.svg" alt="" style={{ opacity: 0.6 }} />
</ListItemIcon>
<ListItemText primary='Manage Banks...' />
</ListItem>
</List>
<Divider />
<List>
<ListItem button key='Settings' onClick={() => { this.handleDrawerSettingsClick() }}>
<ListItemIcon>
<img src="img/settings_black_24dp.svg" alt="" style={{ opacity: 0.6 }} />
</ListItemIcon>
<ListItemText primary='Settings' />
</ListItem>
<ListItem button key='Settings' onClick={() => { this.handleDrawerAboutClick() }}>
<ListItemIcon>
<img src="img/help_outline_black_24dp.svg" alt="" style={{ opacity: 0.6 }} />
</ListItemIcon>
<ListItemText primary='About' />
</ListItem>
</List>
</TemporaryDrawer>
{!this.state.tinyToolBar && (
<Toolbar className={classes.toolBarSpacer} variant="dense" />
)}
<main className={classes.mainFrame} >
<div className={classes.mainSizingPosition}>
<div className={classes.heroContent}>
{(this.state.displayState !== State.Loading) && (
<MainPage hasTinyToolBar={this.state.tinyToolBar} />
)}
</div>
</div>
</main>
<BankDialog show={this.state.bankDialogOpen} isEditDialog={this.state.editBankDialogOpen} onDialogClose={() => this.setState({ bankDialogOpen: false })} />
<AboutDialog open={this.state.aboutDialogOpen} onClose={() => this.setState({ aboutDialogOpen: false })} />
<SettingsDialog open={this.state.isSettingsDialogOpen} onClose={() => this.handleSettingsDialogClose()} />
<RenameDialog
open={this.state.renameBankDialogOpen || this.state.saveBankAsDialogOpen}
defaultName={this.model_.banks.get().getSelectedEntryName()}
acceptActionName={"Rename"}
onClose={() => {
this.setState({
renameBankDialogOpen: false,
saveBankAsDialogOpen: false
})
}}
onOk={(text: string) => {
if (this.state.renameBankDialogOpen) {
this.handleBankRenameOk(text);
} else if (this.state.saveBankAsDialogOpen) {
this.handleSaveBankAsOk(text);
}
}
}
/>
<Dialog
open={this.state.alertDialogOpen}
onClose={this.handleCloseAlert}
aria-describedby="alert-dialog-description"
>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{
this.model_.alertMessage.get()
}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={this.handleCloseAlert} color="primary" autoFocus>
OK
</Button>
</DialogActions>
</Dialog>
<JackStatusView />
<div className={classes.errorContent} style={{ display: this.state.displayState === State.Reconnecting ? "block" : "none" }}
>
<div className={classes.errorContentMask} />
<div className={classes.loadingBox}>
<div className={classes.loadingBoxItem}>
<CircularProgress color="inherit" className={classes.loadingBoxItem} />
</div>
<Typography variant="body2" className={classes.progressText}>
Reconnecting...
</Typography>
</div>
</div>
<div className={classes.errorContent} style={{ display: this.state.displayState === State.Error ? "flex" : "none" }}
onMouseDown={preventDefault} onKeyDown={preventDefault}
>
<div className={classes.errorContentMask} />
<div style={{ flex: "2 2 3px", height: 20 }} >&nbsp;</div>
<div className={classes.errorMessageBox} style={{ position: "relative" }} >
<div style={{ fontSize: "30px", position: "absolute", left: 0, top: 0, color: "#A00" }}>
<ErrorOutlineIcon color="inherit" fontSize="inherit" style={{ float: "left", marginRight: "12px" }} />
</div>
<div style={{ marginLeft: 40, marginTop: 3 }}>
<p className={classes.errorText}>
Error: {this.state.errorMessage}
</p>
</div>
<div style={{ paddingTop: 50, paddingLeft: 36, textAlign: "left" }}>
<Button variant='contained' color="primary" component='button'
onClick={() => window.location.reload()} >
Reload
</Button>
</div>
</div>
<div style={{ flex: "5 5 auto", height: 20 }} >&nbsp;</div>
</div>
<div className={classes.errorContent} style={{ display: this.state.displayState === State.Loading ? "block" : "none" }}>
<div className={classes.errorContentMask} />
<div className={classes.loadingBox}>
<div className={classes.loadingBoxItem}>
<CircularProgress color="inherit" className={classes.loadingBoxItem} />
</div>
<Typography variant="body2" className={classes.loadingBoxItem}>
Loading...
</Typography>
</div>
</div>
</div >
</ThemeProvider>
);
}
}
);
export default App;
+470
View File
@@ -0,0 +1,470 @@
import React, { Component } from 'react';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { BankIndexEntry, BankIndex } from './Banks';
import Button from "@material-ui/core/Button";
import ButtonBase from "@material-ui/core/ButtonBase";
import { TransitionProps } from '@material-ui/core/transitions/transition';
import Slide from '@material-ui/core/Slide';
import Dialog from '@material-ui/core/Dialog';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import { Theme, withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import DraggableGrid, { ScrollDirection } from './DraggableGrid';
import SelectHoverBackground from './SelectHoverBackground';
import CloseIcon from '@material-ui/icons/Close';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import EditIcon from '@material-ui/icons/Edit';
import RenameDialog from './RenameDialog';
interface BankDialogProps extends WithStyles<typeof styles> {
show: boolean;
isEditDialog: boolean;
onDialogClose: () => void;
};
interface BankDialogState {
banks: BankIndex;
showActionBar: boolean;
selectedItem: number;
filenameDialogOpen: boolean;
filenameSaveAs: boolean;
};
const styles = (theme: Theme) => createStyles({
dialogAppBar: {
position: 'relative',
top: 0, left: 0
},
dialogActionBar: {
position: 'relative',
top: 0, left: 0,
background: "black"
},
dialogTitle: {
marginLeft: theme.spacing(2),
flex: 1,
},
itemFrame: {
display: "flex",
flexDirection: "row",
flexWrap: "nowrap",
width: "100%",
height: "56px",
alignItems: "center",
textAlign: "left",
justifyContent: "center",
paddingLeft: 8
},
iconFrame: {
flex: "0 0 auto",
},
itemIcon: {
width: 24, height: 24, margin: 12, opacity: 0.6
},
itemLabel: {
flex: "1 1 1px",
marginLeft: 8
}
});
const Transition = React.forwardRef(function Transition(
props: TransitionProps & { children?: React.ReactElement },
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ActionBarTransition = React.forwardRef(function Transition(
props: TransitionProps & { children?: React.ReactElement },
ref: React.Ref<unknown>,
) {
return <Slide direction="right" ref={ref} {...props} />;
});
const BankDialog = withStyles(styles, { withTheme: true })(
class extends Component<BankDialogProps, BankDialogState> {
model: PiPedalModel;
constructor(props: BankDialogProps) {
super(props);
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
};
this.handleBanksChanged = this.handleBanksChanged.bind(this);
this.handlePopState = this.handlePopState.bind(this);
}
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 preset.
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;
stateWasPopped: boolean = false;
handlePopState(e: any): any {
let state: any = e.state;
if (!state || !state.bankDialog)
{
this.stateWasPopped = true;
this.props.onDialogClose();
}
}
updateBackButtonHooks() : void {
let wantHooks = this.mounted && this.props.show;
if (wantHooks !== this.hasHooks)
{
this.hasHooks = wantHooks;
if (this.hasHooks)
{
this.stateWasPopped = false;
window.addEventListener("popstate",this.handlePopState);
// eslint-disable-next-line no-restricted-globals
let newState: any = history.state;
if (!newState)
{
newState = {};
}
newState.bankDialog = true;
// eslint-disable-next-line no-restricted-globals
history.pushState(
newState,
"PiPedal - Banks",
"#Banks"
);
} else {
window.removeEventListener("popstate",this.handlePopState);
if (!this.stateWasPopped)
{
// eslint-disable-next-line no-restricted-globals
history.back();
}
// eslint-disable-next-line no-restricted-globals
history.replaceState({},"PiPedal","#");
}
}
}
componentDidUpdate()
{
this.updateBackButtonHooks();
}
componentDidMount() {
this.model.banks.addOnChangedHandler(this.handleBanksChanged);
this.handleBanksChanged();
// scroll selected item into view.
this.mounted = true;
this.updateBackButtonHooks();
}
componentWillUnmount() {
this.model.banks.removeOnChangedHandler(this.handleBanksChanged);
this.mounted = false;
this.updateBackButtonHooks();
}
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;
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);
});
}
}
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 presetEntry = el as BankIndexEntry;
let classes = this.props.classes;
let selectedItem = this.isEditMode() ? this.state.selectedItem : this.state.banks.selectedBank;
return (
<div key={presetEntry.instanceId} style={{ background: "white" }} >
<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="img/ic_bank.svg" className={classes.itemIcon} alt="" />
</div>
<div className={classes.itemLabel}>
<Typography>
{presetEntry.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);
});
}
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);
}
render() {
let classes = this.props.classes;
let actionBarClass = this.props.isEditDialog ? classes.dialogAppBar : classes.dialogActionBar;
let defaultSelectedIndex = this.getSelectedIndex();
return (
<Dialog fullScreen open={this.props.show}
onClose={() => { this.handleDialogClose() }} TransitionComponent={Transition}>
<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}>
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 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>
</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>
</Dialog >
);
}
}
);
export default BankDialog;
+95
View File
@@ -0,0 +1,95 @@
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;
}
}
+58
View File
@@ -0,0 +1,58 @@
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 '@material-ui/core/Typography';
import IControlViewFactory from './IControlViewFactory';
import ToobInputStageViewFactory from './ToobInputStageView';
import ToobToneStackViewFactory from './ToobToneStackView';
import ToobCabSimViewFactory from './ToobCabSimView';
let pluginFactories: IControlViewFactory[] = [
new ToobInputStageViewFactory(),
new ToobToneStackViewFactory(),
new ToobCabSimViewFactory()
];
export function GetControlView(pedalBoardItem?: PedalBoardItem| null): React.ReactNode
{
let model: PiPedalModel = PiPedalModelFactory.getInstance();
if (!pedalBoardItem) {
return (<div/>);
}
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} />
)
}
}
}
+479
View File
@@ -0,0 +1,479 @@
import { MouseEvent, PointerEvent, ReactNode, Component } from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import { PiPedalStateError } from './PiPedalError';
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: {
position: "relative",
margin: "12px"
}
});
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(styles, { withTheme: true })(
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?: NodeJS.Timeout = 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() {
return (
<div 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>
)
}
}
);
export default Draggable;
+922
View File
@@ -0,0 +1,922 @@
import React, { MouseEvent, PointerEvent, Component } from 'react';
import { createStyles, Theme } from '@material-ui/core/styles';
import { Property } from "csstype";
import { nullCast} from './Utility'
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;
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const styles = (theme: Theme) => createStyles({
frame: {
position: "relative",
margin: "12px",
background: theme.palette.background.paper
}
});
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>;
refGrid: React.RefObject<HTMLDivElement>;
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?: NodeJS.Timeout = 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 = "#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?: NodeJS.Timeout;
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;
+212
View File
@@ -0,0 +1,212 @@
import React, { ReactNode } from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { UiControl } from './Lv2Plugin';
import Dialog from '@material-ui/core/Dialog';
import Input from '@material-ui/core/Input';
import { nullCast } from './Utility';
import Typography from '@material-ui/core/Typography';
const styles = (theme: Theme) => createStyles({
frame: {
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 =
withStyles(styles, { withTheme: true })(
class extends ResizeResponsiveComponent<FullScreenIMEProps, FullScreenIMEState>
{
model: PiPedalModel;
inputRef: React.RefObject<HTMLInputElement>;
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);
}
}
onInputKeyPress(e: any): void {
if (e.charCode === 13) {
if (this.inputChanged) {
this.inputChanged = false;
this.validateInput(true);
}
else {
this.props.onClose();
}
}
}
uiControl?: UiControl;
currentWidth?: number;
render(): ReactNode {
//let classes = this.props.classes;
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 (
<Dialog fullScreen open={!!(this.props.uiControl)} onClose={(e, r) => this.handleClose(e, r)} >
<div style={{
width: "100%", height: "100%", position: "relative",
display: "flex", flexDirection: "column", flexWrap: "nowrap",
justifyContent: "center", alignItems: "center"
}}
>
<Typography variant="body2">{this.props.caption}</Typography>
<Input key={value}
type="number"
defaultValue={control.formatValue(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>
</Dialog>
);
}
}
);
export default FullScreenIME;
+338
View File
@@ -0,0 +1,338 @@
import React, {Component} from 'react';
import { createStyles, Theme, withStyles, WithStyles } from '@material-ui/core/styles';
import { MonitorPortHandle, PiPedalModel, PiPedalModelFactory } from "./PiPedalModel";
import SvgPathBuilder from './SvgPathBuilder'
//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 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> {
theme: Theme;
instanceId: number;
};
type GxTunerControlState = {
pitchInfo: PitchInfo
tet: number;
refFrequency: number;
};
const GxTunerControl =
withStyles(styles, { withTheme: true })(
class extends Component<GxTunerControlProps, GxTunerControlState>
{
model: PiPedalModel;
refRoot: React.RefObject<HTMLDivElement>;
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,
refFrequency: 440
};
this.onFrequencyUpdated = this.onFrequencyUpdated.bind(this);
}
noteToPitchInfo(hz: number) : PitchInfo
{
if (hz < 65) // ground hum. Ignore it.
{
hz = 0;
}
let tet = this.state.tet;
let refFrequency = this.state.refFrequency;
let aOffset: number;
let names: string[];
let semitoneCents = 100;
let valid = false;
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 (hz !== 0)
{
let note = Math.log2(hz/refFrequency)*tet + aOffset;
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
});
}
subscribedInstanceId: number = -1;
monitorHandle?: MonitorPortHandle;
addSubscription() {
this.subscribedInstanceId = this.props.instanceId;
this.monitorHandle = this.model.monitorPort(this.props.instanceId,FREQUENCY_PORT_NAME,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();
}
}
componentDidMount()
{
this.addSubscription();
}
componentDidUpdate() {
this.updateSubscription();
}
componentWillUnmount()
{
this.removeSubscription();
}
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);
}
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 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 = -25/100;
// 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;
} 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>
)
}
render() {
return (<div ref={this.refRoot} style={{width: DIAL_WIDTH, height: DIAL_HEIGHT, fontSize: "2em", fontWeight: 700, position: "relative",
boxShadow: "1px 5px 6px #888 inset",
fontFamily: "arial,roboto,helvetica,sans"}}>
<div style={{position: "absolute", left: 0, bottom: 5, width: "50%",textAlign: "right"}}>
<span style={{ marginRight: 20, color: "#444", 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: "#444", textAlign: "left" }}>{this.state.pitchInfo.fractionText}</span>
</div>
{ this.renderDial(this.state.pitchInfo) }
</div>);
}
}
);
export default GxTunerControl;
+13
View File
@@ -0,0 +1,13 @@
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;
+100
View File
@@ -0,0 +1,100 @@
import {PiPedalArgumentError} from './PiPedalError';
export class JackChannelSelection {
deserialize(input: any): JackChannelSelection
{
this.inputAudioPorts = input.inputAudioPorts.slice();
this.outputAudioPorts = input.outputAudioPorts.slice();
this.inputMidiPorts = input.inputMidiPorts.slice();
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[] = [];
inputMidiPorts: string[] = [];
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);
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 "Invalid selection";
}
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 {
throw new PiPedalArgumentError("Invalid channel selection."); // should be subset of jackConfiguration.
}
} else {
if (selectedChannels.length === 2) return "Stereo (custom selection)";
if (selectedChannels.length === 1) return "Mono (custom selection)";
throw new PiPedalArgumentError("Invalid channel selection."); // should be subset of jackConfiguration.
}
}
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.inputMidiPorts = input.inputMidiPorts;
this.outputMidiPorts = input.outputMidiPorts;
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[] = [];
inputMidiPorts: string[] = [];
outputMidiPorts: string[] = [];
};
export default JackConfiguration;
+112
View File
@@ -0,0 +1,112 @@
import React from 'react';
import Typography from '@material-ui/core/Typography';
const RED_COLOR = "#C00";
const GREEN_COLOR = "#666";
function tempDisplay(mC: number): string
{
return (mC/1000).toFixed(1) + "\u00B0C"; // degrees C.
}
function cpuDisplay(cpu: number): string
{
return cpu.toFixed(1)+"%";
}
export default class JackHostStatus {
deserialize(input: any): JackHostStatus
{
this.active = input.active;
this.restarting = input.restarting;
this.underruns = input.underruns;
this.cpuUsage = input.cpuUsage;
this.msSinceLastUnderrun = input.msSinceLastUnderrun;
this.temperaturemC = input.temperaturemC;
return this;
}
hasTemperature() : boolean {
return this.temperaturemC >= -100000;
}
active: boolean = false;
restarting: boolean = false;
underruns: number = 0;
cpuUsage: number = 0;
msSinceLastUnderrun: number = -5000*1000;
temperaturemC: number = -1000000;
static getDisplayView(label: string,status?: JackHostStatus): React.ReactNode {
if (!status) {
return (<div style={{whiteSpace: "nowrap"}}>
<Typography variant="caption" color="textSecondary">{label}</Typography>
<Typography variant="caption">&nbsp;</Typography>
</div>);
}
if (status.restarting)
{
return (
<div style={{whiteSpace: "nowrap"}}>
<Typography variant="caption" color="textSecondary">{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="textSecondary">{label}</Typography>
<span style={{color: RED_COLOR}}>
<Typography variant="caption" color="inherit">Stopped&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="textSecondary">{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>
);
}
}
};
+35
View File
@@ -0,0 +1,35 @@
export default class JackServerSettings {
deserialize(input: any) : JackServerSettings{
this.valid = input.valid;
this.rebootRequired = input.rebootRequired;
this.sampleRate = input.sampleRate;
this.bufferSize = input.bufferSize;
this.numberOfBuffers = input.numberOfBuffers;
return this;
}
constructor(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;
}
}
valid: boolean = false;
rebootRequired = false;
sampleRate = 48000;
bufferSize = 64;
numberOfBuffers = 3;
getSummaryText() {
if (this.valid) {
return "Sample Rate: " + this.sampleRate + " BufferSize: " + this.bufferSize + " Number of Buffers: " + this.numberOfBuffers;
} else {
return "";
}
}
}
+169
View File
@@ -0,0 +1,169 @@
import React, { Component } from 'react';
import { createStyles, WithStyles, withStyles, Theme } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import DialogActions from '@material-ui/core/DialogActions';
import Dialog from '@material-ui/core/Dialog';
import JackServerSettings from './JackServerSettings';
import InputLabel from '@material-ui/core/InputLabel';
import FormControl from '@material-ui/core/FormControl';
import Select from '@material-ui/core/Select';
import DialogContent from '@material-ui/core/DialogContent';
import MenuItem from '@material-ui/core/MenuItem';
import { nullCast } from './Utility';
import Typography from '@material-ui/core/Typography';
interface JackServerSettingsDialogState {
latencyText: string;
};
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: (sampleRate: number, bufferSize: number, numberOfBuffers: number) => void;
}
function getLatencyText(sampleRate: number, bufferSize: number, numberOfBuffers: number): string {
let ms = bufferSize * numberOfBuffers / sampleRate * 1000;
return ms.toFixed(1) + "ms";
}
const JackServerSettingsDialog = withStyles(styles)(
class extends Component<JackServerSettingsDialogProps, JackServerSettingsDialogState> {
constructor(props: JackServerSettingsDialogProps) {
super(props);
let jackServerSettings = props.jackServerSettings;
this.state = {
latencyText:
getLatencyText(
jackServerSettings.sampleRate,
jackServerSettings.bufferSize,
jackServerSettings.numberOfBuffers)
};
}
updateLatency(e: any): void {
setTimeout(()=> {
let sampleRate: number = parseInt(nullCast<any>(document.getElementById('jsd_sampleRate')).value);
let bufferSize: number = parseInt(nullCast<any>(document.getElementById('jsd_bufferSize')).value);
let bufferCount: number = parseInt(nullCast<any>(document.getElementById('jsd_bufferCount')).value);
this.setState({
latencyText: getLatencyText(sampleRate, bufferSize, bufferCount)
});
},0);
}
handleApply() {
let sampleRate = parseInt(nullCast<any>(document.getElementById('jsd_sampleRate')).value);
let bufferSize = parseInt(nullCast<any>(document.getElementById('jsd_bufferSize')).value);
let bufferCount = parseInt(nullCast<any>(document.getElementById('jsd_bufferCount')).value);
this.props.onApply(sampleRate,bufferSize,bufferCount);
};
render() {
const classes = this.props.classes;
const { onClose, jackServerSettings, open } = this.props;
const handleClose = () => {
onClose();
};
return (
<Dialog onClose={handleClose} aria-labelledby="select-channels-title" open={open}>
<DialogContent>
<FormControl className={classes.formControl}>
<InputLabel htmlFor="sampleRate">Sample rate</InputLabel>
<Select
onChange={(e) => this.updateLatency(e)}
defaultValue={jackServerSettings.sampleRate}
inputProps={{
name: 'Sample Rate',
id: 'jsd_sampleRate',
style: {
textAlign: "right"
}
}}
>
<MenuItem value={44100}>44100</MenuItem>
<MenuItem value={48000}>48000</MenuItem>
<MenuItem value={88200}>88200</MenuItem>
<MenuItem value={96000}>96000</MenuItem>
<MenuItem value={176400}>176400</MenuItem>
<MenuItem value={192000}>192000</MenuItem>
</Select>
</FormControl>
<div style={{ display: "inline", whiteSpace: "nowrap" }}>
<FormControl className={classes.formControl}>
<InputLabel htmlFor="bufferSize">Buffer size</InputLabel>
<Select
onChange={(e) => this.updateLatency(e)}
defaultValue={jackServerSettings.bufferSize}
inputProps={{
name: 'Buffer size',
id: 'jsd_bufferSize',
}}
>
<MenuItem value={16}>16</MenuItem>
<MenuItem value={32}>32</MenuItem>
<MenuItem value={64}>64</MenuItem>
<MenuItem value={128}>128</MenuItem>
<MenuItem value={256}>256</MenuItem>
<MenuItem value={512}>512</MenuItem>
<MenuItem value={1024}>1024</MenuItem>
</Select>
</FormControl>
<FormControl className={classes.formControl}>
<InputLabel htmlFor="numberofBuffers">Buffers</InputLabel>
<Select
onChange={(e) => this.updateLatency(e)}
defaultValue={jackServerSettings.numberOfBuffers}
inputProps={{
name: 'Number of buffers',
id: 'jsd_bufferCount',
}}
>
<MenuItem value={2}>2</MenuItem>
<MenuItem value={3}>3</MenuItem>
<MenuItem value={4}>4</MenuItem>
</Select>
</FormControl>
</div>
<Typography display="block" variant="caption" style={{ textAlign: "left", marginTop: 8, marginLeft: 24 }}
color="textSecondary">
Latency: {this.state.latencyText}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
<Button onClick={()=> this.handleApply()} color="secondary">
OK
</Button>
</DialogActions>
</Dialog>
);
}
});
export default JackServerSettingsDialog;
+63
View File
@@ -0,0 +1,63 @@
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?: NodeJS.Timeout;
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>
);
}
}
+453
View File
@@ -0,0 +1,453 @@
import React, { ReactNode, SyntheticEvent } from 'react';
import Grid from '@material-ui/core/Grid';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { UiPlugin, PluginType } from './Lv2Plugin';
import ButtonBase from '@material-ui/core/ButtonBase';
import Button from '@material-ui/core/Button';
import MenuItem from '@material-ui/core/MenuItem';
import Typography from '@material-ui/core/Typography';
import PluginInfoDialog from './PluginInfoDialog'
import PluginIcon from './PluginIcon'
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import SelectHoverBackground from './SelectHoverBackground';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import IconButton from '@material-ui/core/IconButton';
import Select from '@material-ui/core/Select';
import PluginClass from './PluginClass'
import ClearIcon from '@material-ui/icons/Clear';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { TransitionProps } from '@material-ui/core/transitions/transition';
import Slide from '@material-ui/core/Slide';
export type CloseEventHandler = () => void;
export type OkEventHandler = (pluginUri: string) => void;
const NARROW_DISPLAY_THRESHOLD = 600;
const FILTER_STORAGE_KEY = "com.twoplay.piddle.load_dlg.filter";
const pluginGridStyles = (theme: Theme) => createStyles({
frame: {
position: "relative",
width: "100%",
height: "100%"
},
top: {
top: "0px",
right: "0px",
left: "0px",
bottom: "64px",
position: "relative",
flexGrow: 1,
overflowX: "hidden",
overflowY: "visible"
},
bottom: {
position: "relative",
bottom: "0px",
height: "64px",
width: "100%",
display: "flex",
flexDirection: "row",
flexWrap: "nowrap",
justifyContent: "space-between",
paddingLeft: "24px",
paddingRight: "48px",
background: theme.palette.background.paper,
},
paper: {
position: "relative",
overflow: "hidden",
height: "56px",
width: "100%",
background: theme.palette.background.paper
},
buttonBase: {
width: "100%",
height: "100%'"
},
content: {
marginTop: "8px",
marginBottom: "8px",
marginLeft: "12px",
marginRight: "12px",
width: "100%",
overflow: "hidden",
textAlign: "left",
display: "flex",
flexDirection: "row",
justifyContent: "flex-start",
height: 48,
alignItems: "start",
flexWrap: "nowrap"
},
content2: {
display: "flex", flexDirection: "column", flex: "1 1 auto", width: "100%",
paddingLeft: 16, whiteSpace: "nowrap"
},
table: {
borderCollapse: "collapse",
},
icon: {
width: "24px",
height: "24px",
margin: "0px",
opacity: "0.6"
},
iconBorder: {
flex: "0 0 auto"
},
label: {
width: "100%"
},
label2: {
marginTop: 8,
color: theme.palette.text.secondary
},
control: {
padding: theme.spacing(1),
},
tdText: {
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,
hover_uri?: string,
filterType: PluginType,
client_width: number,
client_height: number,
minimumItemWidth: number,
}
const Transition = React.forwardRef(function Transition(
props: TransitionProps & { children?: React.ReactElement<any, any> },
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
export const LoadPluginDialog =
withStyles(pluginGridStyles, { withTheme: true })(
class extends ResizeResponsiveComponent<PluginGridProps, PluginGridState>
{
model: PiPedalModel;
constructor(props: PluginGridProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
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,
hover_uri: "",
filterType: filterType_,
client_width: window.innerWidth,
client_height: window.innerHeight,
minimumItemWidth: props.minimumItemWidth ? props.minimumItemWidth : 220
};
this.updateWindowSize = this.updateWindowSize.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleOk = this.handleOk.bind(this);
}
updateWindowSize() {
this.setState({
client_width: window.innerWidth,
client_height: window.innerHeight
});
}
componentDidMount() {
super.componentDidMount();
this.updateWindowSize();
window.addEventListener('resize', this.updateWindowSize);
}
componentWillUnmount() {
super.componentWillUnmount();
window.removeEventListener('resize', this.updateWindowSize);
}
onWindowSizeChanged(width: number, height: number): void {
super.onWindowSizeChanged(width, height);
}
onFilterChange(e: any) {
let value = e.target.value as PluginType;
window.localStorage.setItem(FILTER_STORAGE_KEY, value as string);
this.setState({ filterType: value });
}
onClearFilter(): void {
let value = PluginType.Plugin;
window.localStorage.setItem(FILTER_STORAGE_KEY, value as string);
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 {
e.preventDefault();
this.cancel();
}
handleOk(e: SyntheticEvent): void {
e.preventDefault();
if (this.state.selected_uri) {
this.props.onOk(this.state.selected_uri);
}
}
onDoubleClick(e: SyntheticEvent, uri: string): void {
this.props.onOk(uri);
}
cancel(): void {
this.props.onCancel();
}
fireSelected(item?: UiPlugin) {
if (item) {
this.setState({ selected_uri: item.uri });
}
}
setHoverUri(uri: string) {
this.setState({ hover_uri: uri });
}
onClick(e: SyntheticEvent, uri: string): void {
this.setState({ selected_uri: 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;
}
stereo_indicator(uiPlugin?: UiPlugin): string {
if (!uiPlugin) return "";
if (uiPlugin.audio_inputs === 2 || uiPlugin.audio_outputs === 2) {
return " (Stereo)";
}
return "";
}
info_string(uiPlugin?: UiPlugin): string {
if (uiPlugin === undefined) return "";
let result = uiPlugin.name;
if (uiPlugin.author_name !== "") {
result += ", " + uiPlugin.author_name;
}
result += this.stereo_indicator(uiPlugin);
return result;
}
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 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 value={PluginType.Plugin}>&nbsp;All</MenuItem>));
this.createFilterChildren(result, classes, 1);
return result;
}
filterPlugins(plugins: UiPlugin[]): ReactNode[] {
let result: ReactNode[] = [];
let filterType = this.state.filterType;
let classes = this.props.classes;
let rootClass = this.model.plugin_classes.get();
for (let i = 0; i < plugins.length; ++i) {
let value = plugins[i];
if (filterType === PluginType.Plugin || rootClass.is_type_of(filterType, value.plugin_type)) {
result.push(
(
<Grid key={value.uri} xs={12} sm={6} md={4} lg={3} xl={3} item
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} pluginUri={value.uri} size={24} />
</div>
<div className={classes.content2}>
<Typography color="textPrimary" noWrap className={classes.label} >
{value.name}
</Typography>
<Typography color="textSecondary" noWrap>
{value.plugin_display_type} {this.stereo_indicator(value)}
</Typography>
</div>
</div>
</ButtonBase>
</Grid>
)
);
}
}
return result;
}
render() {
const { classes } = this.props;
let model = this.model;
let plugins = model.ui_plugins.get();
let selectedPlugin: UiPlugin | undefined = undefined;
if (this.state.selected_uri) {
let t = this.model.getUiPlugin(this.state.selected_uri);
if (t) selectedPlugin = t;
}
return (
<React.Fragment>
<Dialog
fullScreen={true}
TransitionComponent={Transition}
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">
<DialogTitle id="select-plugin-dialog-title" style={{ flex: "0 0 auto" }}>
<div style={{ display: "flex", flexDirection: "row", flexWrap: "nowrap", width: "100%", alignItems: "center" }}>
<IconButton onClick={() => { this.cancel(); }} style={{ flex: "0 0 auto" }} >
<ArrowBackIcon />
</IconButton>
<Typography display="block" variant="h6" style={{ flex: "0 1 auto" }}>
{this.state.client_width > 520 ? "Select Plugin" : ""}
</Typography>
<div style={{ flex: "1 1 auto" }} />
<Select defaultValue={this.state.filterType} key={this.state.filterType} onChange={(e) => { this.onFilterChange(e); }}
style={{ flex: "0 0 160px" }}
>
{this.createFilterOptions()}
</Select>
<div style={{ flex: "0 0 auto", marginRight: 24, visibility: this.state.filterType === PluginType.Plugin ? "hidden" : "visible" }} >
<IconButton onClick={() => { this.onClearFilter(); }}>
<ClearIcon fontSize='small' style={{ opacity: 0.6 }} />
</IconButton>
</div>
</div>
</DialogTitle>
<DialogContent dividers style={{
height: this.state.client_height - (this.state.client_width < NARROW_DISPLAY_THRESHOLD ? 150 + 64 : 160),
padding: 8,
display: "1 1 flex"
}} >
<Grid container justify="flex-start" >
{
this.filterPlugins(plugins)
}
</Grid>
</DialogContent>
{(this.state.client_width >= NARROW_DISPLAY_THRESHOLD) ? (
<DialogActions style={{ flex: "0 0 auto" }} >
<div className={classes.bottom}>
<div style={{ display: "flex", justifyContent: "start", alignItems: "center", flex: "1 1 auto", height: "100%", overflow: "hidden" }} >
<PluginInfoDialog plugin_uri={this.state.selected_uri ?? ""} />
<Typography display='block' variant='body2' color="textPrimary" noWrap >
{this.info_string(selectedPlugin)}
</Typography>
</div>
<div style={{ position: "relative", float: "right", flex: "0 1 200px", width: 200, display: "flex", alignItems: "center" }}>
<Button onClick={this.handleCancel} style={{ width: 120 }} >Cancel</Button>
<Button onClick={this.handleOk} color="secondary" 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 }} />
<Typography display='block' variant='body2' color="textPrimary" noWrap >
{this.info_string(selectedPlugin)}
</Typography>
</div>
<div className={classes.bottom}>
<div style={{ flex: "1 1 1px" }} />
<div style={{ position: "relative", flex: "0 1 auto", display: "flex", alignItems: "center" }}>
<Button onClick={this.handleCancel} style={{ width: 120, height: 48 }} >Cancel</Button>
<Button onClick={this.handleOk} color="secondary" disabled={selectedPlugin === null} style={{ width: 120, height: 48 }} >SELECT</Button>
</div>
</div>
</DialogActions>
)}
</Dialog>
</React.Fragment >
);
}
}
);
export default LoadPluginDialog;
+441
View File
@@ -0,0 +1,441 @@
import React, { ReactNode, SyntheticEvent } from 'react';
import Grid from '@material-ui/core/Grid';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { UiPlugin, PluginType } from './Lv2Plugin';
import Paper from '@material-ui/core/Paper';
import ButtonBase from '@material-ui/core/ButtonBase';
import Button from '@material-ui/core/Button';
import MenuItem from '@material-ui/core/MenuItem';
import Typography from '@material-ui/core/Typography';
import PluginInfoDialog from './PluginInfoDialog'
import PluginIcon from './PluginIcon'
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import SelectHoverBackground from './SelectHoverBackground';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import IconButton from '@material-ui/core/IconButton';
import Select from '@material-ui/core/Select';
import PluginClass from './PluginClass'
import ClearIcon from '@material-ui/icons/Clear';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { TransitionProps } from '@material-ui/core/transitions/transition';
import Slide from '@material-ui/core/Slide';
export type CloseEventHandler = () => void;
export type OkEventHandler = (pluginUri: string) => void;
const NARROW_DISPLAY_THRESHOLD = 600;
const FILTER_STORAGE_KEY = "com.twoplay.piddle.load_dlg.filter";
const Transition = React.forwardRef(function Transition(
props: TransitionProps & { children?: React.ReactElement },
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
const pluginGridStyles = (theme: Theme) => createStyles({
frame: {
position: "relative",
width: "100%",
height: "100%"
},
top: {
top: "0px",
right: "0px",
left: "0px",
bottom: "64px",
position: "relative",
flexGrow: 1,
background: "#eee",
overflowX: "hidden",
overflowY: "visible"
},
bottom: {
position: "relative",
bottom: "0px",
height: "64px",
width: "100%",
display: "flex",
flexDirection: "row",
flexWrap: "nowrap",
justifyContent: "space-between",
paddingLeft: "24px",
paddingRight: "48px",
background: theme.palette.background.paper,
},
paper: {
position: "relative",
overflow: "hidden",
height: "56px",
width: "100%",
background: theme.palette.background.paper
},
buttonBase: {
width: "100%",
height: "100%'"
},
content: {
marginTop: "8px",
marginBottom: "8px",
marginLeft: "12px",
marginRight: "12px",
width: "100%",
overflow: "hidden"
},
table: {
borderCollapse: "collapse",
},
icon: {
width: "24px",
height: "24px",
margin: "0px",
opacity: "0.6"
},
label: {
marginLeft: "8px",
},
control: {
padding: theme.spacing(1),
},
tdText: {
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,
hover_uri?: string,
filterType: PluginType,
client_width: number,
client_height: number,
minimumItemWidth: number,
}
export const LoadPluginDialog =
withStyles(pluginGridStyles, { withTheme: true })(
class extends ResizeResponsiveComponent<PluginGridProps, PluginGridState>
{
model: PiPedalModel;
constructor(props: PluginGridProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
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,
hover_uri: "",
filterType: filterType_,
client_width: window.innerWidth,
client_height: window.innerHeight,
minimumItemWidth: props.minimumItemWidth ? props.minimumItemWidth : 220
};
this.updateWindowSize = this.updateWindowSize.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleOk = this.handleOk.bind(this);
}
updateWindowSize() {
this.setState({
client_width: window.innerWidth,
client_height: window.innerHeight
});
}
componentDidMount() {
super.componentDidMount();
this.updateWindowSize();
window.addEventListener('resize', this.updateWindowSize);
}
componentWillUnmount() {
super.componentWillUnmount();
window.removeEventListener('resize', this.updateWindowSize);
}
onWindowSizeChanged(width: number,height: number): void {
super.onWindowSizeChanged(width,height);
}
onFilterChange(e: any) {
let value = e.target.value as PluginType;
window.localStorage.setItem(FILTER_STORAGE_KEY, value as string);
this.setState({ filterType: value });
}
onClearFilter(): void {
let value = PluginType.Plugin;
window.localStorage.setItem(FILTER_STORAGE_KEY, value as string);
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 {
e.preventDefault();
this.cancel();
}
handleOk(e: SyntheticEvent): void {
e.preventDefault();
if (this.state.selected_uri) {
this.props.onOk(this.state.selected_uri);
}
}
onDoubleClick(e: SyntheticEvent, uri: string): void {
this.props.onOk(uri);
}
cancel(): void {
this.props.onCancel();
}
fireSelected(item?: UiPlugin) {
if (item) {
this.setState({ selected_uri: item.uri });
}
}
setHoverUri(uri: string) {
this.setState({ hover_uri: uri });
}
onClick(e: SyntheticEvent, uri: string): void {
this.setState({ selected_uri: 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;
}
stereo_indicator(uiPlugin?: UiPlugin): string {
if (!uiPlugin) return "";
if (uiPlugin.audio_inputs === 2 || uiPlugin.audio_outputs === 2) {
return " (Stereo)";
}
return "";
}
info_string(uiPlugin?: UiPlugin): string {
if (uiPlugin === undefined) return "";
let result = uiPlugin.name;
if (uiPlugin.author_name !== "") {
result += ", " + uiPlugin.author_name;
}
result += this.stereo_indicator(uiPlugin);
return result;
}
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 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 value={PluginType.Plugin}>&nbsp;All</MenuItem>));
this.createFilterChildren(result, classes, 1);
return result;
}
filterPlugins(plugins: UiPlugin[]): ReactNode[] {
let result: ReactNode[] = [];
let filterType = this.state.filterType;
let classes = this.props.classes;
let rootClass = this.model.plugin_classes.get();
for (let i = 0; i < plugins.length; ++i) {
let value = plugins[i];
if (filterType === PluginType.Plugin || rootClass.is_type_of(filterType, value.plugin_type)) {
result.push(
(
<Grid key={value.uri} xs={6} sm={6} md={4} lg={3} xl={3} item
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) }}
>
<Paper className={classes.paper} >
<ButtonBase className={classes.buttonBase} focusRipple>
<SelectHoverBackground selected={value.uri === this.state.selected_uri} showHover={true} />
<div className={classes.content}>
<div style={{ display: "flex", flexDirection: "row", justifyContent: "flex-start", alignItems: "start", flexWrap: "nowrap" }} >
<div style={{ flex: "0 1 auto" }}>
<PluginIcon pluginType={value.plugin_type} pluginUri={value.uri} size={24} />
</div>
<div style={{ display: "flex", flexDirection: "column", flex: "1 1 auto", width: "100%" }}>
<Typography className={classes.label}
display='block' variant='body2' align="left" color="textPrimary" noWrap
>{value.name}
</Typography>
<Typography className={classes.label}
display='block' variant='caption' align="left" noWrap
>
{value.plugin_display_type} {this.stereo_indicator(value)}
</Typography>
</div>
</div>
</div>
</ButtonBase>
</Paper>
</Grid>
)
);
}
}
return result;
}
render() {
const { classes } = this.props;
let model = this.model;
let plugins = model.ui_plugins.get();
let selectedPlugin: UiPlugin | undefined = undefined;
if (this.state.selected_uri) {
let t = this.model.getUiPlugin(this.state.selected_uri);
if (t) selectedPlugin = t;
}
return (
<React.Fragment>
<Dialog
TransitionComponent={Transition}
fullScreen={true}
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">
<DialogTitle id="select-plugin-dialog-title" style={{ flex: "0 0 auto"}}>
<div style={{ display: "flex", flexDirection: "row", flexWrap: "nowrap", width: "100%", alignItems: "center" }}>
<IconButton onClick={() => { this.cancel(); }} style={{ flex: "0 0 auto" }} >
<ArrowBackIcon />
</IconButton>
<Typography display="block" variant="h6" style={{ flex: "0 1 auto" }}>
{ this.state.client_width > 520 ? "Select Plugin" : "" }
</Typography>
<div style={{ flex: "1 1 auto" }} />
<Select defaultValue={this.state.filterType} key={this.state.filterType} onChange={(e) => { this.onFilterChange(e); }}
style={{ flex: "0 0 160px" }}
>
{this.createFilterOptions()}
</Select>
<div style={{ flex: "0 0 auto", marginRight: 24, visibility: this.state.filterType === PluginType.Plugin? "hidden": "visible" }} >
<IconButton onClick={() => { this.onClearFilter(); }}>
<ClearIcon fontSize='small' style={{ opacity: 0.6 }} />
</IconButton>
</div>
</div>
</DialogTitle>
<DialogContent dividers style={{ height: this.state.client_height - (this.state.client_width < NARROW_DISPLAY_THRESHOLD ? 288: 220),
background: "#eee", display: "1 1 flex"}} >
<Grid container justify="flex-start" spacing={1} style={{ background: "#EEE" }}>
{
this.filterPlugins(plugins)
}
</Grid>
</DialogContent>
{ (this.state.client_width >= NARROW_DISPLAY_THRESHOLD)? (
<DialogActions style={{flex: "0 0 auto"}} >
<div className={classes.bottom}>
<div style={{ display: "flex", justifyContent: "start", alignItems: "center", flex: "1 1 auto", height: "100%", overflow: "hidden" }} >
<PluginInfoDialog plugin_uri={this.state.selected_uri ?? ""} />
<Typography display='block' variant='body2' color="textPrimary" noWrap >
{this.info_string(selectedPlugin)}
</Typography>
</div>
<div style={{ position: "relative", float: "right", flex: "0 1 200px", width: 200, display: "flex", alignItems: "center" }}>
<Button onClick={this.handleCancel} style={{ width: 120 }} >Cancel</Button>
<Button onClick={this.handleOk} color="secondary" 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}} />
<Typography display='block' variant='body2' color="textPrimary" noWrap >
{this.info_string(selectedPlugin)}
</Typography>
</div>
<div className={classes.bottom}>
<div style={{flex: "1 1 1px"}}/>
<div style={{ position: "relative", flex: "0 1 auto", display: "flex", alignItems: "center" }}>
<Button onClick={this.handleCancel} style={{ width: 120, height: 48 }} >Cancel</Button>
<Button onClick={this.handleOk} color="secondary" disabled={selectedPlugin === null} style={{ width: 120, height: 48 }} >SELECT</Button>
</div>
</div>
</DialogActions>
)}
</Dialog>
</React.Fragment >
);
}
}
);
export default LoadPluginDialog;
+371
View File
@@ -0,0 +1,371 @@
// Generated by https://quicktype.io
//
// To change quicktype's target language, run command:
//
// "Set quicktype target language"
import {PiPedalArgumentError} from "./PiPedalError";
import Units from './Units';
interface Deserializable<T> {
deserialize(input: any): T;
}
export class Port implements Deserializable<Port> {
deserialize(input: any): Port {
this.port_index = input.port_index;
this.symbol = input.symbol;
this.name = input.name;
this.min_value = input.min_value;
this.max_value = input.max_value;
this.default_value = input.default_value;
this.scale_points = ScalePoint.deserialize_array(input.scale_points);
this.is_input = input.is_input;
this.is_output = input.is_output;
this.is_control_port = input.is_control_port;
this.is_audio_port = input.is_audio_port;
this.is_atom_port = input.is_atom_port;
this.is_valid = input.is_valid;
this.supports_midi = input.supports_midi;
this.port_group = input.port_group;
return this;
}
static EmptyPorts: Port[] = [];
static deserialize_array(input: any): Port[] {
let result: Port[] = [];
for (let i = 0; i < input.length; ++i)
{
result[i] = new Port().deserialize(input[i]);
}
return result;
}
port_index: number = -1;
symbol: string = "";
name: string = "";
min_value: number = 0;
max_value: number = 1;
default_value: number = 0.5;
scale_points: ScalePoint[] = [];
is_input: boolean = false;
is_output: boolean = false
is_control_port: boolean = false;
is_audio_port: boolean = false;
is_atom_port: boolean = false;
is_valid: boolean = false;
supports_midi: boolean = false;
port_group: string = "";
}
export class PortGroup {
deserialize(input: any): PortGroup {
this.symbol = input.symbol;
this.name = input.name;
return this;
}
static deserialize_array(input: any) : PortGroup[] {
let result: PortGroup[] = [];
for (let i = 0; i < input.length; ++i)
{
result.push(new PortGroup().deserialize(input[i]));
}
return result;
}
symbol: string = "";
name: string = "";
};
export class Lv2Plugin implements Deserializable<Lv2Plugin> {
deserialize(input: any): Lv2Plugin
{
this.uri = input.uri;
this.name = input.name;
this.plugin_class = input.plugin_class;
this.supported_features = input.supported_features;
this.required_features = input.required_features;
this.optional_features = input.optional_features;
this.author_name = input.author_name;
this.author_homepage = input.author_homepage;
this.comment = input.comment;
this.ports= Port.deserialize_array(input.ports);
this.port_groups = PortGroup.deserialize_array(input.port_groups);
return this;
}
static EmptyFeatures: string[] = [];
uri: string = "";
name: string = "";
plugin_class: string = "";
supported_features: string[] = Lv2Plugin.EmptyFeatures;
required_features: string[] = Lv2Plugin.EmptyFeatures;
optional_features: string[] = Lv2Plugin.EmptyFeatures;
author_name: string = "";
author_homepage: string = "";
comment: string = "";
ports: Port[] = Port.EmptyPorts;
port_groups: PortGroup[] = [];
}
export class ScalePoint implements Deserializable<ScalePoint> {
deserialize(input: any): ScalePoint {
this.value = input.value;
this.label = input.label;
return this;
}
static deserialize_array(input: any): ScalePoint[]
{
let result: ScalePoint[] = [];
for (let i = 0; i < input.length; ++i)
{
result[i] = new ScalePoint().deserialize(input[i]);
}
return result;
}
value: number = 0;
label: string = "";
}
export enum PluginType {
// Reserved types used in pedalboards.
None="",
InvalidPlugin= "InvalidPlugin",
Plugin = "Plugin",
AllpassPlugin = "AllpassPlugin",
AmplifierPlugin = "AmplifierPlugin",
AnalyserPlugin = "AnalyserPlugin",
BandpassPlugin = "BandpassPlugin",
ChorusPlugin = "ChorusPlugin",
CombPlugin = "CombPlugin",
CompressorPlugin = "CompressorPlugin",
ConstantPlugin = "ConstantPlugin",
ConverterPlugin = "ConverterPlugin",
DelayPlugin = "DelayPlugin",
DistortionPlugin = "DistortionPlugin",
DynamicsPlugin = "DynamicsPlugin",
EQPlugin = "EQPlugin",
EnvelopePlugin = "EnvelopePlugin",
ExpanderPlugin = "ExpanderPlugin",
FilterPlugin = "FilterPlugin",
FlangerPlugin = "FlangerPlugin",
FunctionPlugin = "FunctionPlugin",
GatePlugin = "GatePlugin",
GeneratorPlugin = "GeneratorPlugin",
HighpassPlugin = "HighpassPlugin",
InstrumentPlugin = "InstrumentPlugin",
LimiterPlugin = "LimiterPlugin",
LowpassPlugin = "LowpassPlugin",
MixerPlugin = "MixerPlugin",
ModulatorPlugin = "ModulatorPlugin",
MultiEQPlugin = "MultiEQPlugin",
OscillatorPlugin = "OscillatorPlugin",
ParaEQPlugin = "ParaEQPlugin",
PhaserPlugin = "PhaserPlugin",
PitchPlugin = "PitchPlugin",
ReverbPlugin = "ReverbPlugin",
SimulatorPlugin = "SimulatorPlugin",
SpatialPlugin = "SpatialPlugin",
SpectralPlugin = "SpectralPlugin",
UtilityPlugin = "UtilityPlugin",
WaveshaperPlugin = "WaveshaperPlugin"
}
export class UiControl implements Deserializable<UiControl> {
deserialize(input: any): UiControl
{
this.symbol = input.symbol;
this.name = input.name;
this.index = input.index;
this.min_value = input.min_value;
this.max_value = input.max_value;
this.default_value = input.default_value;
this.is_logarithmic = input.is_logarithmic;
this.display_priority = input.display_priority;
this.range_steps = input.range_steps;
this.integer_property = input.integer_property;
this.enumeration_property = input.enumeration_property;
this.toggled_property = input.toggled_property;
this.trigger = input.trigger;
this.not_on_gui = input.not_on_gui;
this.scale_points = ScalePoint.deserialize_array(input.scale_points);
this.port_group = input.port_group;
this.units = input.units as Units;
return this;
}
static deserialize_array(input: any): UiControl[] {
let result: UiControl[] = [];
for (let i = 0; i < input.length; ++i)
{
result[i] = new UiControl().deserialize(input[i]);
}
return result;
}
symbol: string = "";
name: string = "";
index: number = -1;
min_value: number = 0;
max_value: number = 1;
default_value:number = 0.5;
is_logarithmic: boolean = false;
display_priority: number = -1;
range_steps: number = 0;
integer_property:boolean = false;
enumeration_property: boolean = false;
trigger: boolean = false;
not_on_gui: boolean = false;
toggled_property: boolean = false;
scale_points: ScalePoint[] = [];
port_group: string = "";
units: Units = Units.none;
isOnOffSwitch() : boolean {
if (this.isAbToggle()) return false;
if (this.min_value !== 0 || this.max_value !== 1) return false;
return (this.toggled_property || this.enumeration_property || this.integer_property);
;
}
isAbToggle(): boolean {
if (this.min_value !== 0 || this.max_value !== 1) return false;
return this.enumeration_property && this.scale_points.length === 2;
}
isSelect() : boolean {
return this.enumeration_property && !this.isOnOffSwitch() && !this.isAbToggle();
}
valueToRange(value: number): number {
if (this.toggled_property) return value === 0 ? 0: 1;
if (this.integer_property || this.enumeration_property) {
value = Math.round(value);
}
let range = (value - this.min_value) / (this.max_value - this.min_value);
if (range > 1) range = 1;
if (range < 0) range = 0;
if (this.range_steps !== 0) {
range = Math.round(range*this.range_steps)/this.range_steps;
}
return range;
}
rangeToValue(range: number) : number {
if (range < 0) range = 0;
if (range > 1) range = 1;
if (this.toggled_property) return range === 0? 0: 1;
if (this.range_steps !== 0) {
range = Math.round(range * this.range_steps) / this.range_steps;
}
let value = range * (this.max_value - this.min_value) + this.min_value;
if (this.integer_property || this.enumeration_property) {
value = Math.round(value);
}
return value;
}
clampValue(value: number): number {
return this.rangeToValue(this.valueToRange(value));
}
formatValue(value: number): string {
if (this.integer_property || this.enumeration_property) {
value = Math.round(value);
}
if (this.enumeration_property) {
for (let i = 0; i < this.scale_points.length; ++i) {
let scale_point = this.scale_points[i];
if (scale_point.value === value) {
return scale_point.label;
}
}
return "#invalid";
} else if (this.integer_property) {
return value.toFixed(0);
} else {
if (value >= 100 || value <= -100) {
return value.toFixed(0);
}
if (value >= 10 || value <= -10) {
return value.toFixed(1);
}
return value.toFixed(2);
}
}
}
export class UiPlugin implements Deserializable<UiPlugin> {
deserialize(input: any): UiPlugin
{
this.uri = input.uri;
this.name = input.name;
this.plugin_type = input.plugin_type as PluginType;
this.plugin_display_type = input.plugin_display_type;
this.author_name = input.author_name;
this.author_homepage = input.author_homepage;
this.audio_inputs = input.audio_inputs;
this.audio_outputs = input.audio_outputs;
this.has_midi_input = input.has_midi_input;
this.has_midi_output = input.has_midi_output;
this.description = input.description;
this.controls = UiControl.deserialize_array(input.controls);
this.port_groups = PortGroup.deserialize_array(input.port_groups);
return this;
}
static deserialize_array(input: any): UiPlugin[] {
let result: UiPlugin[] = [];
for (let i = 0; i < input.length; ++i)
{
result[i] = new UiPlugin().deserialize(input[i]);
}
return result;
}
getControl(key: string): UiControl | undefined {
for (let i = 0; i < this.controls.length; ++i)
{
let control = this.controls[i];
if (control.symbol === key)
{
return control;
}
}
return undefined;
}
getPortGroup(symbol: string): PortGroup
{
for (let i = 0; i < this.port_groups.length; ++i)
{
let port_group = this.port_groups[i];
if (port_group.symbol === symbol)
{
return port_group;
}
}
throw new PiPedalArgumentError("Port group not found.");
}
uri: string = "";
name: string = "";
plugin_type: PluginType = PluginType.InvalidPlugin;
plugin_display_type: string = "";
author_name: string = "";
author_homepage: string = "";
audio_inputs: number = 0;
audio_outputs: number = 0;
has_midi_input: number = 0;
has_midi_output: number = 0;
description: string = "";
controls: UiControl[] = [];
port_groups: PortGroup[] = [];
}
+452
View File
@@ -0,0 +1,452 @@
import { SyntheticEvent } from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import {
PedalBoard, PedalBoardItem, PedalBoardSplitItem, SplitType
} from './PedalBoard';
import Button from '@material-ui/core/Button';
import InputIcon from '@material-ui/icons/Input';
import LoadPluginDialog from './LoadPluginDialog';
import Switch from '@material-ui/core/Switch';
import PedalBoardView from './PedalBoardView';
import { PiPedalStateError } from './PiPedalError';
import IconButton from '@material-ui/core/IconButton';
import AddIcon from '@material-ui/icons/Add';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import Fade from '@material-ui/core/Fade';
import Divider from '@material-ui/core/Divider';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import PluginInfoDialog from './PluginInfoDialog';
import { GetControlView } from './ControlViewFactory';
import MidiBindingsDialog from './MidiBindingsDialog';
import PluginPresetSelector from './PluginPresetSelector';
const SPLIT_CONTROLBAR_THRESHHOLD = 650;
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) => createStyles({
frame: {
position: "absolute", display: "flex", flexDirection: "column", flexWrap: "nowrap",
justifyContent: "flex-start", left: "0px", top: "0px", bottom: "0px", right: "0px", overflow: "hidden"
},
pedalBoardScroll: {
position: "relative", width: "100%",
flex: "0 0 auto", overflow: "auto", maxHeight: 220
},
pedalBoardScrollSmall: {
position: "relative", width: "100%",
flex: "1 1 1px", overflow: "auto"
},
separator: {
width: "100%", height: "1px", background: "#888", opacity: "0.5",
flex: "0 0 1px"
},
controlToolBar: {
flex: "0 0 auto", width: "100%", height: 48
},
splitControlBar: {
flex: "0 0 48px", width: "100%", paddingLeft: 24, paddingRight: 16, paddingBottom: 16
},
controlContent: {
flex: "1 1 auto", width: "100%", overflowY: "auto", minHeight: 240
},
controlContentSmall: {
flex: "0 0 162px", width: "100%", height: 162, overflowY: "hidden",
},
title: { fontSize: "1.3em", fontWeight: 700, marginRight: 8 },
author: { fontWeight: 500, marginRight: 8 }
});
interface MainProps extends WithStyles<typeof styles> {
hasTinyToolBar: boolean;
theme: Theme;
};
interface MainState {
selectedPedal: number;
loadDialogOpen: boolean;
pedalBoard: PedalBoard;
addMenuAnchorEl: HTMLElement | null;
splitControlBar: boolean;
horizontalScrollLayout: boolean;
showMidiBindingsDialog: boolean;
screenHeight: number;
};
export const MainPage =
withStyles(styles, { withTheme: true })(
class extends ResizeResponsiveComponent<MainProps, MainState>
{
model: PiPedalModel;
constructor(props: MainProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
selectedPedal: -1,
loadDialogOpen: false,
pedalBoard: this.model.pedalBoard.get(),
addMenuAnchorEl: null,
splitControlBar: this.windowSize.width < SPLIT_CONTROLBAR_THRESHHOLD,
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.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, presetName: string) {
this.model.loadPluginPreset(instanceId, presetName);
}
onPedalBoardChanged(value: PedalBoard) {
this.setState({ pedalBoard: value });
}
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);
}
componentWillUnmount() {
this.model.pedalBoard.removeOnChangedHandler(this.onPedalBoardChanged);
super.componentWillUnmount();
}
updateResponsive() {
this.setState({
splitControlBar: this.windowSize.width < SPLIT_CONTROLBAR_THRESHHOLD,
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.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.setPedalBoardControlValue(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 });
}
onLoadClick(e: SyntheticEvent) {
this.setState({ loadDialogOpen: true });
}
getPedalBoardItem(selectedId?: number): PedalBoardItem | null {
if (selectedId === undefined) return null;
let pedalBoard = this.model.pedalBoard.get();
if (!pedalBoard) return null;
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.setPedalBoardControlValue(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 pluginUri = "";
let presetsUri = "";
if (pedalBoardItem) {
if (pedalBoardItem.isEmpty()) {
title = "";
} else if (pedalBoardItem.isSplit()) {
title = "Split";
} else {
let uiPlugin = this.model.getUiPlugin(pedalBoardItem.uri);
if (uiPlugin == null) {
title = "Missing Plugin";
} else {
title = uiPlugin.name;
author = uiPlugin.author_name;
presetsUri = uiPlugin.uri;
if (uiPlugin.description.length > 20) {
pluginUri = uiPlugin.uri;
}
}
}
}
let classes = this.props.classes;
return (
<div style={{
flex: "1 0 auto", overflow: "hidden", marginRight: 8,
display: "flex", flexDirection: "row", flexWrap: "nowrap",
alignItems: "center"
}}>
<div style={{ flex: "0 1 auto" }}>
<span style={{ whiteSpace: "nowrap" }}>
<span className={classes.title}>{title}</span>
</span>
<span style={{ whiteSpace: "nowrap" }}>
<span className={classes.author}>{author}</span>
</span>
</div>
<div style={{ flex: "0 0 auto" }}>
<PluginInfoDialog plugin_uri={pluginUri} />
</div>
<div style={{ flex: "0 0 auto" }}>
<PluginPresetSelector pluginUri={presetsUri}
onSelectPreset={(presetName) => this.handleSelectPluginPreset(pedalBoardItem!.instanceId, presetName)}
/>
</div>
</div>
);
}
render() {
let classes = this.props.classes;
let pedalBoard = this.model.pedalBoard.get();
let pedalBoardItem = this.getSelectedPedalBoardItem();
let uiPlugin = null;
let bypassVisible = false;
let bypassChecked = false;
let canDelete = false;
let canAdd = false;
let instanceId = -1;
if (pedalBoardItem) {
canDelete = pedalBoard.canDeleteItem(pedalBoardItem.instanceId);
instanceId = pedalBoardItem.instanceId;
if (pedalBoardItem.isEmpty()) {
canAdd = true;
} else if (pedalBoardItem.isSplit()) {
canAdd = true;
} else {
uiPlugin = this.model.getUiPlugin(pedalBoardItem.uri);
canAdd = true;
if (uiPlugin) {
bypassVisible = true;
bypassChecked = pedalBoardItem.isEnabled;
}
}
}
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={uiPlugin?.uri ?? "#error"} selectedId={this.state.selectedPedal}
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",
width: "100%", height: 48, paddingLeft: 16, paddingRight: 16
}} >
<div style={{ flex: "0 0 auto", width: 80 }} >
<div style={{ display: bypassVisible ? "block" : "none", width: 80 }} >
<Switch checked={bypassChecked} onChange={this.handleEnableCurrentItemChanged} />
</div>
</div>
{
(!this.state.splitControlBar) && this.titleBar(pedalBoardItem)
}
<div style={{ flex: "1 1 1px" }}>
</div>
<div style={{ flex: "0 0 auto", display: canAdd ? "block" : "none", paddingRight: 8 }}>
<IconButton onClick={(e) => { this.onAddClick(e) }} >
<AddIcon />
</IconButton>
<Menu
id="add-menu"
anchorEl={this.state.addMenuAnchorEl}
keepMounted
open={Boolean(this.state.addMenuAnchorEl)}
onClose={() => this.handleAddClose()}
TransitionComponent={Fade}
>
<MenuItem onClick={() => this.onInsertPedal(instanceId)}>Insert pedal</MenuItem>
<MenuItem onClick={() => this.onAppendPedal(instanceId)}>Append pedal</MenuItem>
<Divider />
<MenuItem onClick={() => this.onInsertSplit(instanceId)}>Insert split</MenuItem>
<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) }} >
<img src="/img/old_delete_outline_black_24dp.svg" alt="Delete" style={{ width: 24, height: 24, 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 || (this.getSelectedPedalBoardItem()?.isSplit() ?? true)}
startIcon={<InputIcon />}
>
Load
</Button>
</div>
<div style={{ flex: "0 0 auto" }}>
<IconButton onClick={(e) => { this.handleMidiConfiguration(instanceId); }}>
<img src="img/ic_midi.svg" style={{ width: 24, height: 24, opacity: 0.6 }} alt="Midi configuration" />
</IconButton>
</div>
</div>
</div>
{
this.state.splitControlBar && (
<div className={classes.splitControlBar}>
{
this.titleBar(pedalBoardItem)
}
</div>
)
}
<div className={horizontalScrollLayout ? classes.controlContentSmall : classes.controlContent}>
{
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}
/>
)
}
</div>
);
}
}
);
export default MainPage
+60
View File
@@ -0,0 +1,60 @@
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 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;
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 LATCH_CONTROL_TYPE: number = 0;
static MOMENTARY_CONTROL_TYPE: number = 1;
switchControlType: number = MidiBinding.LATCH_CONTROL_TYPE;
};
+299
View File
@@ -0,0 +1,299 @@
import { Component } from 'react';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { Theme, withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import MidiBinding from './MidiBinding';
import Utility, { nullCast } from './Utility';
import Typography from '@material-ui/core/Typography';
import MicNoneOutlinedIcon from '@material-ui/icons/MicNoneOutlined';
import MicOutlinedIcon from '@material-ui/icons/MicOutlined';
import IconButton from '@material-ui/core/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" }
});
interface MidiBindingViewProps 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 MidiBindingViewState {
};
const MidiBindingView =
withStyles(styles, { withTheme: true })(
class extends Component<MidiBindingViewProps, MidiBindingViewState> {
model: PiPedalModel;
constructor(props: MidiBindingViewProps) {
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 value={control.value}>{control.displayName}</MenuItem>
)
);
}
render() {
let classes = this.props.classes;
let midiBinding = this.props.midiBinding;
let pedalBoardItem = this.model.pedalBoard.get().getItem(this.props.instanceId);
let uiPlugin = this.model.getUiPlugin(pedalBoardItem.uri);
if (!uiPlugin) {
return (<div />);
}
let canLatch: boolean = false;
let canTrigger: boolean = false;
let showLinearControlTypeSelect: boolean = false;
let isBinaryControl: boolean = false;
let showLinearRange: boolean = false;
let canRotaryScale: boolean = false;
if (this.props.midiBinding.symbol === "__bypass") {
isBinaryControl = true;
canLatch = midiBinding.bindingType !== MidiBinding.BINDING_TYPE_NONE;
} else {
let port = nullCast(uiPlugin!.getControl(this.props.midiBinding.symbol));
isBinaryControl = (port.isAbToggle() || port.isOnOffSwitch());
if (midiBinding.bindingType !== MidiBinding.BINDING_TYPE_NONE) {
canLatch = isBinaryControl;
canTrigger = port.trigger;
showLinearControlTypeSelect = !(canLatch || canTrigger);
showLinearRange = showLinearControlTypeSelect && midiBinding.linearControlType === MidiBinding.LINEAR_CONTROL_TYPE;
canRotaryScale = showLinearControlTypeSelect && 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
style={{ width: 80, }}
onChange={(e, extra) => this.handleTypeChange(e, extra)}
value={midiBinding.bindingType}
>
<MenuItem value={0}>None</MenuItem>
{(isBinaryControl) && (
<MenuItem value={1}>Note</MenuItem>
)}
<MenuItem value={2}>Control</MenuItem>
</Select>
</div>
{
(midiBinding.bindingType === MidiBinding.BINDING_TYPE_NOTE) &&
(
<div className={classes.controlDiv2} >
<Select
style={{ width:80}}
onChange={(e, extra) => this.handleNoteChange(e, extra)}
value={midiBinding.note}
>
{
this.generateMidiSelects()
}
</Select>
<IconButton onClick={()=> {
if (this.props.listen)
{
this.props.onListen(-2, "", false)
} else {
this.props.onListen(this.props.instanceId, this.props.midiBinding.symbol, false)
}
}}>
{ this.props.listen ? (
<MicOutlinedIcon />
) : (
<MicNoneOutlinedIcon />
)}
</IconButton>
</div>
)
}
{
(midiBinding.bindingType === MidiBinding.BINDING_TYPE_CONTROL) &&
(
<div className={classes.controlDiv2} >
<Select
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)
}
}}>
{ this.props.listen ? (
<MicOutlinedIcon />
) : (
<MicNoneOutlinedIcon />
)}
</IconButton>
</div>
)
}
{
((canLatch) &&
(
<div className={classes.controlDiv} >
<Select
style={{ width: 120 }}
onChange={(e, extra) => this.handleLatchControlTypeChange(e, extra)}
value={midiBinding.switchControlType}
>
<MenuItem value={MidiBinding.LATCH_CONTROL_TYPE}>Latch</MenuItem>
<MenuItem value={MidiBinding.MOMENTARY_CONTROL_TYPE}>Momentary</MenuItem>
</Select>
</div>
))
}
{
(canTrigger) &&
(
<div className={classes.controlDiv2} >
<Typography style={{paddingTop: 12, paddingBottom: 12}}>(Trigger)</Typography>
</div>
)
}
{
(showLinearControlTypeSelect) &&
(
<div className={classes.controlDiv} >
<Select
style={{ width: 120 }}
onChange={(e, extra) => this.handleLinearControlTypeChange(e, extra)}
value={midiBinding.linearControlType}
>
<MenuItem value={MidiBinding.LATCH_CONTROL_TYPE}>Linear</MenuItem>
<MenuItem value={MidiBinding.MOMENTARY_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>
)
}
});
export default MidiBindingView;
+343
View File
@@ -0,0 +1,343 @@
import React, { SyntheticEvent } from 'react';
import Dialog from '@material-ui/core/Dialog';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { PiPedalModel, PiPedalModelFactory, ListenHandle } from './PiPedalModel';
import Typography from '@material-ui/core/Typography';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import IconButton from '@material-ui/core/IconButton';
import MidiBinding from './MidiBinding';
import MidiBindingView from './MidiBindingView';
import Snackbar from '@material-ui/core/Snackbar';
const styles = (theme: Theme) => createStyles({
dialogAppBar: {
position: 'relative',
top: 0, left: 0
},
dialogTitle: {
marginLeft: theme.spacing(2),
flex: "1 1 auto",
},
pluginTable: {
border: "collapse",
width: "100%",
},
pluginHead: {
borderTop: "1pt #DDD solid", paddingLeft: 22, paddingRight: 22
},
bindingTd: {
verticalAlign: "top",
paddingLeft: 12, paddingBottom: 8
},
nameTd: {
paddingLeft: 48,
verticalAlign: "top",
paddingTop: 12
},
});
export interface MidiBindingDialogProps extends WithStyles<typeof styles> {
open: boolean,
onClose: () => void
};
export interface MidiBindingDialogState {
listenInstanceId: number;
listenSymbol: string;
listenSnackbarOpen: boolean;
};
export const MidiBindingDialog =
withStyles(styles, { withTheme: true })(
class extends ResizeResponsiveComponent<MidiBindingDialogProps, MidiBindingDialogState> {
model: PiPedalModel;
constructor(props: MidiBindingDialogProps) {
super(props);
this.state = {
listenInstanceId: -2,
listenSymbol: "",
listenSnackbarOpen: false
};
this.model = PiPedalModelFactory.getInstance();
this.handlePopState = this.handlePopState.bind(this);
this.handleClose = this.handleClose.bind(this);
}
mounted: boolean = false;
hasHooks: boolean = false;
stateWasPopped: boolean = false;
handlePopState(e: any): any {
this.stateWasPopped = true;
let shouldClose = (!e.state);
if (shouldClose) {
this.props.onClose();
}
}
handleClose() {
this.props.onClose();
}
listenTimeoutHandle?: NodeJS.Timeout;
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.listenHandle = undefined; // (one-shot event)
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);
});
}
updateHooks(): void {
let wantHooks = this.mounted && this.props.open;
if (wantHooks !== this.hasHooks) {
this.hasHooks = wantHooks;
if (this.hasHooks) {
this.stateWasPopped = false;
window.addEventListener("popstate", this.handlePopState);
// eslint-disable-next-line no-restricted-globals
let state = history.state;
if (!state) {
state = {};
}
state.midiBindingDialog = true;
// eslint-disable-next-line no-restricted-globals
history.pushState(
state,
"Preset MIDI Bindings",
"#MidiBindingDialog"
);
} else {
window.removeEventListener("popstate", this.handlePopState);
if (!this.stateWasPopped) {
// eslint-disable-next-line no-restricted-globals
history.back();
}
this.cancelListenForControl();
}
}
}
onWindowSizeChanged(width: number, height: number): void {
}
componentDidMount() {
super.componentDidMount();
this.mounted = true;
this.updateHooks();
}
componentWillUnmount() {
super.componentDidMount();
this.mounted = false;
this.updateHooks();
}
componentDidUpdate() {
this.updateHooks();
}
handleItemChanged(instanceId: number, newBinding: MidiBinding) {
this.model.setMidiBinding(instanceId, newBinding);
}
generateTable(): React.ReactNode[] {
let classes = this.props.classes;
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 plugin = this.model.getUiPlugin(item.uri);
if (plugin) {
result.push(
<tr>
<td colSpan={2} className={classes.pluginHead}>
<Typography variant="caption" color="textSecondary" noWrap>
{plugin.name}
</Typography>
</td>
</tr>
);
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")}
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];
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}
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, classes } = props;
if (!open) {
return (<div />);
}
return (
<Dialog open={open} fullWidth onClose={this.handleClose} aria-labelledby="Rename-dialog-title"
fullScreen={true}
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} >
<Toolbar>
<IconButton edge="start" color="inherit" onClick={this.handleClose} aria-label="back"
>
<ArrowBackIcon />
</IconButton>
<Typography 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"
/>
</Dialog >
);
}
});
export default MidiBindingDialog;
+131
View File
@@ -0,0 +1,131 @@
import React, { Component } from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import Input from '@material-ui/core/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(styles, { withTheme: true })(
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() {
//let classes = this.props.classes;
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}
/>
);
}
}
);
export default NumericInput;
+50
View File
@@ -0,0 +1,50 @@
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));
}
};
+40
View File
@@ -0,0 +1,40 @@
import React, { Component, ReactNode } from 'react';
import { withStyles, createStyles, WithStyles } from '@material-ui/core';
import { Theme } from '@material-ui/core/styles/createMuiTheme';
import { Lv2Plugin } from './Lv2Plugin'
const pedalStyles = ({ palette }: Theme) => createStyles({
});
interface PedalProps extends WithStyles<typeof pedalStyles> {
plugin: Lv2Plugin;
children?: React.ReactChild | React.ReactChild[];
};
type PedalState = {
};
export const TemporaryDrawer = withStyles(pedalStyles)(
class extends Component<PedalProps, PedalState>
{
state: PedalState;
constructor(props: PedalProps) {
super(props);
this.state = {
};
}
render() : ReactNode {
return (
<div>
</div>
);
}
}
);
+570
View File
@@ -0,0 +1,570 @@
import { PiPedalArgumentError } from './PiPedalError';
import MidiBinding from './MidiBinding';
const SPLIT_PEDALBOARD_ITEM_URI = "uri://two-play/piddle/pedalboard#Split";
const EMPTY_PEDALBOARD_ITEM_URI = "uri://two-play/piddle/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);
this.controlValues = ControlValue.deserializeArray(input.controlValues);
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;
}
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;
}
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;
}
}
throw new PiPedalArgumentError("Control not found.");
}
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[] = [];
};
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> {
deserialize(input: any): PedalBoard {
this.name = input.name;
this.items = PedalBoardItem.deserializeArray(input.items);
this.nextInstanceId = input.nextInstanceId ?? -1;
return this;
}
clone(): PedalBoard {
return new PedalBoard().deserialize(this);
}
name: string = "";
items: PedalBoardItem[] = [];
nextInstanceId: number = -1;
*itemsGenerator(): Generator<PedalBoardItem, void, undefined> {
let it = itemGenerator_(this.items);
while (true)
{
let v = it.next();
if (v.done) break;
yield v.value;
}
}
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;
}
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 = [];
}
_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.");
}
}
_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
+26
View File
@@ -0,0 +1,26 @@
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
+269
View File
@@ -0,0 +1,269 @@
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;
class PiPedalSocket {
onMessageReceived: MessageHandler;
onError: ErrorHandler;
onReconnecting: ReconnectingHandler;
onReconnect: ReconnectHandler;
socket?: WebSocket;
nextResponseCode: number = 0;
url: string;
retrying: boolean = false;
retryCount: number = 0;
retryDelay: number = 0;
totalRetryDelay: number = 0;
constructor(
url: string,
onMessageReceived: MessageHandler,
onError: ErrorHandler,
onReconnecting: ReconnectingHandler,
onReconnect: ReconnectHandler) {
this.url = url;
this.onMessageReceived = onMessageReceived;
this.onError = onError;
this.onReconnecting = onReconnecting;
this.onReconnect = onReconnect;
}
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.onMessageReceived(header, body);
}
} catch (error) {
if (this.onError) {
this.onError("Invalid server response. " + error, error);
} else {
throw new PiPedalStateError("Invalid server response.");
}
}
}
handleError(_event: Event): any {
if (this.onError) {
this.onError("Server connection lost.");
} else {
throw new PiPedalStateError("Server connection lost.");
}
}
handleClose(_event: any): any {
if (this.retrying) {
// treat this as a fatal error.
if (this.onError) {
this.onError("Server connection lost.");
} else {
throw new PiPedalStateError("Server connection closed.");
}
return;
}
this._discardReplyReservations();
this.retrying = true;
this.retryCount = 0;
this.socket = undefined;
this.retryDelay = 10000;
this.totalRetryDelay = 0;
this.reconnect();
}
reconnect() {
if (this.socket)
{
this.close();
}
this.onReconnecting(this.retryCount,MAX_RETRIES);
++this.retryCount;
this.connectInternal_()
.then((socket) => {
this.socket = socket;
this.retrying = false;
this.onReconnect();
})
.catch(error => {
if (this.totalRetryDelay === MAX_RETRY_TIME)
{
this.onError("Server connection lost.");
return;
} else {
this.totalRetryDelay += this.retryDelay;
Utility.delay(this.retryDelay).then(() => this.reconnect());
this.retryDelay *= 2;
if (this.retryDelay > 5000) this.retryDelay = 5000;
}
});
}
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();
}
} catch (ignored)
{
}
this.socket = undefined;
}
connectInternal_(): Promise<WebSocket> {
return new Promise<WebSocket>((resolve, reject) => {
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("Can't connect to server.");
};
ws.onerror = (event: Event) => {
ws.onclose = null;
ws.onerror = null;
reject("Can't connect to server.");
};
ws.onopen = (event: Event) => {
ws.onerror = self.handleError.bind(self);
ws.onclose = self.handleClose.bind(self);
ws.onopen = null;
resolve(ws);
};
});
}
connect(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.connectInternal_()
.then((socket) => {
this.socket = socket;
resolve();
})
.catch((reason) => {
reject(reason);
});
});
}
}
export default PiPedalSocket;
+63
View File
@@ -0,0 +1,63 @@
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;
+743
View File
@@ -0,0 +1,743 @@
import React, { TouchEvent, PointerEvent, ReactNode, Component, SyntheticEvent } from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import { UiControl, ScalePoint } from './Lv2Plugin';
import Typography from '@material-ui/core/Typography';
import Input from '@material-ui/core/Input';
import Select from '@material-ui/core/Select';
import Switch from '@material-ui/core/Switch';
import Units from './Units';
import Utility, {nullCast} from './Utility';
import MenuItem from '@material-ui/core/MenuItem';
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 }
const styles = (theme: Theme) => createStyles({
frame: {
position: "relative",
margin: "12px"
},
switchTrack: {
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.type === 'light' ? 0.38 : 0.3
},
displayValue: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 4,
textAlign: "center",
background: "white",
color: "#666",
// zIndex: -1,
}
});
export interface PluginControlProps extends WithStyles<typeof styles> {
uiControl?: UiControl;
value: number;
onPreviewChange?: (value: number) => void;
onChange: (value: number) => void;
theme: Theme;
requestIMEEdit: (uiControl: UiControl, value: number) => void;
};
type PluginControlState = {
error: boolean;
};
const PluginControl =
withStyles(styles, { withTheme: true })(
class extends Component<PluginControlProps, PluginControlState>
{
imgRef: React.RefObject<HTMLImageElement>;
inputRef: React.RefObject<HTMLInputElement>;
selectRef: React.RefObject<HTMLSelectElement>;
displayValueRef: React.RefObject<HTMLDivElement>;
constructor(props: PluginControlProps) {
super(props);
this.state = {
error: false
};
this.imgRef = React.createRef();
this.inputRef = React.createRef();
this.selectRef = React.createRef();
this.displayValueRef = 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);
}
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);
if (event.currentTarget) {
event.currentTarget.value = this.formatValue(this.props.uiControl, result);
}
this.previewInputValue(result, true);
this.inputRef.current.value = this.formatValue(this.props.uiControl, result); // 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<HTMLImageElement>): 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;
onTouchStart(e: TouchEvent<HTMLImageElement>) {
}
onTouchMove(e: TouchEvent<HTMLImageElement>) {
// e.preventDefault();
e.stopPropagation(); // cancels scroll!!!
}
capturedPointers: number[] = [];
onBodyPointerDownCapture(e_: any): any
{
let e = e_ as PointerEvent;
if (this.isExtraTouch(e))
{
this.captureElement!.setPointerCapture(e.pointerId);
this.capturedPointers.push(e.pointerId);
++this.pointersDown;
}
}
pointerId: number = 0;
pointerType: string = "";
isCapturedPointer(e: PointerEvent<HTMLImageElement>): 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?: HTMLElement = undefined;
onPointerDown(e: PointerEvent<HTMLImageElement>): void {
if (!this.mouseDown && this.isValidPointer(e)) {
++this.pointersDown;
e.preventDefault();
e.stopPropagation();
this.mouseDown = true;
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;
}
}
}
isExtraTouch(e: PointerEvent): boolean {
return (this.mouseDown && this.pointerType === "touch" && e.pointerType === "touch" && e.pointerId !== this.pointerId);
}
onPointerLostCapture(e: PointerEvent<HTMLImageElement>) {
if (this.isCapturedPointer(e)) {
--this.pointersDown;
this.releaseCapture(e);
}
}
updateRange(e: PointerEvent<HTMLImageElement>): 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;
}
onPointerUp(e: PointerEvent<HTMLImageElement>) {
if (this.isCapturedPointer(e)) {
--this.pointersDown;
e.preventDefault();
let dRange = this.updateRange(e)
this.previewRange(dRange, true);
this.releaseCapture(e);
} else {
--this.pointersDown;
}
}
releaseCapture(e: PointerEvent<HTMLImageElement>)
{
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;
}
onPointerMove(e: PointerEvent<HTMLImageElement>): void {
if (this.isCapturedPointer(e)) {
e.preventDefault();
let dRange = this.updateRange(e)
this.previewRange(dRange, false);
}
}
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.formatValue(this.props.uiControl, 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}
onChange={(event) => {
this.onCheckChanged(event.target.checked);
}}
/>
);
}
if (control.isAbToggle()) {
// unchecked color is not gray.
return (
<Switch checked={value !== 0}
onChange={(event) => {
this.onCheckChanged(event.target.checked);
}}
classes={{
track: this.props.classes.switchTrack
}}
style={{ color: this.props.theme.palette.secondary.main }}
/>
);
} else {
return (
<Select
ref={this.selectRef}
value={value}
onChange={this.onSelectChanged}
inputProps={{
name: control.name,
id: 'id' + control.symbol,
style: { fontSize: FONT_SIZE }
}}
style={{ marginLeft: 4, marginRight: 4, width: 140 }}
>
{control.scale_points.map((scale_point: ScalePoint) => (
<MenuItem value={scale_point.value}>{scale_point.label}</MenuItem>
))}
</Select>
);
}
}
formatDisplayValue(uiControl: UiControl | undefined, value: number): string {
if (!uiControl) return "";
for (let i = 0; i < uiControl.scale_points.length; ++i)
{
let scalePoint = uiControl.scale_points[i];
if (scalePoint.value === value)
{
return scalePoint.label;
}
}
let text = this.formatValue(uiControl, value);
switch (uiControl.units) {
case Units.bpm:
text += "bpm";
break;
case Units.cent:
text += "cents";
break;
case Units.cm:
text += "cm";
break;
case Units.db:
text += "dB";
break;
case Units.hz:
text += "Hz";
break;
case Units.khz:
text += "kHz";
break;
case Units.km:
text += "km";
break;
case Units.m:
text += "m";
break;
case Units.mhz:
text += "MHz";
break;
case Units.min:
text += "min";
break;
case Units.ms:
text += "ms";
break;
case Units.pc:
text += "%";
break;
case Units.s:
text += "s";
break;
// Midinote: not handled.
// semitone12TET not handled.
}
return text;
}
formatValue(uiControl: UiControl | undefined, value: number): string {
if (!uiControl) return "";
return uiControl.formatValue(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)
{
range = Math.log(value/uiControl.min_value)/Math.log(uiControl.max_value/uiControl.min_value);
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 (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.is_logarithmic)
{
value = uiControl.min_value*Math.pow(uiControl.max_value/uiControl.min_value,range);
} 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() {
let classes = this.props.classes;
let t = this.props.uiControl;
if (!t) {
return (<div>#Error</div>);
}
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();
if (isAbSwitch) {
switchText = control.scale_points[0].value === value ? control.scale_points[0].label : control.scale_points[1].label;
}
let item_width = isSelect ? 160 : 80;
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-start", width: item_width, margin: 8 }}>
{/* TITLE SECTION */}
<div style={{ flex: "0 0 auto", width: "100%", marginBottom: 8,marginLeft: isSelect? 16: 0, marginRight: 0 }}>
<Typography variant="caption" display="block" noWrap style={{
width: "100%",
textAlign: isSelect ? "left" : "center"
}}> {control.name}</Typography>
</div>
{/* CONTROL SECTION */}
<div style={{ flex: "0 0 auto" }}>
{(isSelect || isAbSwitch || isOnOffSwitch) ? (
this.makeSelect(control, value)
) : (
<div style={{ flex: "0 1 auto" }}>
<img ref={this.imgRef} src="img/fx_dial.svg"
style={{ overscrollBehavior: "none", touchAction: "none", width: 36, height: 36, opacity: DEFAULT_OPACITY, transform: this.getRotationTransform() }}
draggable="true"
onTouchStart={this.onTouchStart} onTouchMove={this.onTouchMove}
onPointerDown={this.onPointerDown} onPointerUp={this.onPointerUp} onPointerMoveCapture={this.onPointerMove} onDrag={this.onDrag}
alt={control.name}
/>
</div>
)
}
</div>
{/* LABEL/EDIT SECTION*/}
<div style={{ flex: "0 0 auto", position: "relative", width: 60 }}>
{(!(isSelect || isOnOffSwitch)) &&
(
(isAbSwitch) ? (
<Typography variant="caption" display="block" noWrap style={{
width: "100%", textAlign: "center"
}}> {switchText} </Typography>
) : (
<div>
<Input key={value}
type="number"
defaultValue={this.formatValue(control, 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<HTMLImageElement> e): boolean
{
return e.pointerId === this.pointerId
&& e.pointerType === this.pointerType;
}
*/
touchPointerId: number = 0;
}
);
export default PluginControl;
+488
View File
@@ -0,0 +1,488 @@
import { ReactNode } from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { UiPlugin, UiControl } 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 GxTunerControl from './GxTunerControl';
import { PiPedalStateError } from './PiPedalError';
import Typography from '@material-ui/core/Typography';
import FullScreenIME from './FullScreenIME';
export const StandardItemSize = { width: 80, height: 110 };
const GXTUNER_URI = "http://guitarix.sourceforge.net/plugins/gxtuner#tuner";
const LANDSCAPE_HEIGHT_BREAK = 500;
const styles = (theme: Theme) => createStyles({
frame: {
display: "block",
position: "relative",
flexDirection: "row",
flexWrap: "nowrap",
paddingTop: "8px",
paddingBottom: "0px",
height: "100%",
overflowX: "auto",
overflowY: "auto"
},
vuMeterL: {
position: "fixed",
paddingLeft: 12,
paddingRight: 4,
paddingBottom: 24,
left: 0,
background: "white",
zIndex: 3
},
vuMeterR: {
position: "fixed",
right: 0,
marginRight: 22,
paddingLeft: 4,
paddingBottom: 24,
background: "white",
zIndex: 3
},
vuMeterRLandscape: {
position: "fixed",
right: 0,
paddingRight: 22,
paddingLeft: 12,
paddingBottom: 24,
background: "white",
zIndex: 3
},
normalGrid: {
position: "relative",
paddingLeft: 22,
paddingRight: 34,
flex: "1 1 auto",
display: "flex", flexDirection: "row", flexWrap: "wrap",
justifyContent: "flex-start", alignItems: "flex_start",
},
landscapeGrid: {
paddingLeft: 40,
// 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",
flex: "0 0 auto"
},
portgroupControlPadding: {
flex: "0 0 auto",
marginTop: 12,
marginBottom: 8
},
controlPadding: {
flex: "0 0 auto",
marginTop: 0,
marginBottom: 0
},
portGroup: {
marginLeft: 8,
marginTop: 0,
marginRight: 8,
marginBottom: 12,
position: "relative",
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
border: "2pt #AAA solid",
borderRadius: 10,
elevation: 12,
display: "flex",
flexDirection: "row", flexWrap: "wrap",
flex: "0 1 auto",
},
portGroupLandscape: {
marginLeft: 8,
marginTop: 0,
marginRight: 8,
marginBottom: 12,
position: "relative",
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
border: "2pt #AAA solid",
borderRadius: 10,
elevation: 12,
display: "flex",
flexDirection: "row", flexWrap: "nowrap",
flex: "0 0 auto"
},
portGroupTitle: {
position: "absolute",
top: -10,
background: "white",
marginLeft: 20,
paddingLeft: 8,
paddingRight: 8
},
portGroupControls: {
display: "flex",
flexDirection: "row", flexWrap: "wrap",
paddingTop: 6,
paddingBottom: 8
}
});
export class ControlGroup {
constructor(name: string, controls: ReactNode[])
{
this.name = name;
this.controls = controls;
}
name: string;
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;
};
const PluginControlView =
withStyles(styles, { withTheme: true })(
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
}
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.setPedalBoardControlValue(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.not_on_gui) {
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
});
}
makeStandardControl(uiControl: UiControl, controlValues: ControlValue[]): ReactNode {
let symbol = uiControl.symbol;
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 uiControl={uiControl} value={controlValue.value}
onChange={(value: number) => { this.onValueChanged(controlValue!.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(controlValue!.key, value) }}
requestIMEEdit={(uiControl, value) => this.requestImeEdit(uiControl, value)}
/>
));
}
getStandardControlNodes(plugin: UiPlugin, controlValues: ControlValue[]): ControlNodes {
let result: ControlNodes = [];
for (let i = 0; i < plugin.controls.length; ++i) {
let pluginControl = plugin.controls[i];
if (!pluginControl.not_on_gui) {
if (pluginControl.port_group !== "") {
let portGroup = pluginControl.port_group;
let groupControls: ReactNode[] = [];
groupControls.push(
this.makeStandardControl(pluginControl, controlValues)
);
while (i + 1 < plugin.controls.length && plugin.controls[i + 1].port_group === portGroup) {
++i;
pluginControl = plugin.controls[i];
if (!pluginControl.not_on_gui) {
groupControls.push(
this.makeStandardControl(pluginControl, controlValues)
)
}
}
result.push(
new ControlGroup(plugin.getPortGroup(portGroup).name, groupControls)
)
} else {
result.push(
this.makeStandardControl(pluginControl, controlValues)
);
}
}
}
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.");
}
getTunerControls(plugin: UiPlugin, controlValues: ControlValue[]): ControlNodes {
// display standard version of just a few controls.
let shortList: ControlValue[] = [];
// for (let i = 0; i < controlValues.length; ++i)
// {
// shortList.push(controlValues[i]);
// }
shortList.push(this.getControl(controlValues, "REFFREQ"));
shortList.push(this.getControl(controlValues, "THRESHOLD"));
let controls = shortList.map((controlValue) => (
<PluginControl uiControl={plugin.getControl(controlValue.key)} value={controlValue.value}
onChange={(value: number) => { this.onValueChanged(controlValue.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(controlValue.key, value) }}
requestIMEEdit={(uiControl, value) => this.requestImeEdit(uiControl, value)}
/>
));
let tunerControl = (<GxTunerControl instanceId={this.props.instanceId} />);
let result: ControlNodes = [];
result.push(tunerControl);
for (let i = 0; i < controls.length; ++i)
{
result.push(controls[i]);
}
return result;
}
onImeValueChange(key: string, value: number) {
this.model.setPedalBoardControlValue(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;
}
controlNodesToNodes(nodes: (ReactNode | ControlGroup)[]): ReactNode[] {
let classes = this.props.classes;
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 className={classes.controlPadding}>
{ item }
</div>
)
);
}
result.push((
<div className={!isLandscapeGrid ? classes.portGroup : classes.portGroupLandscape}>
<div className={classes.portGroupTitle}>
<Typography variant="caption">{controlGroup.name}</Typography>
</div>
<div className={classes.portGroupControls} >
{
controls
}
</div>
</div>
));
} else {
result.push((
<div className={ hasGroups ? classes.portgroupControlPadding: classes.controlPadding } >
{node as ReactNode}
</div>
));
}
}
return result;
}
render(): ReactNode {
let classes = this.props.classes;
let pedalBoardItem = this.model.pedalBoard.get().getItem(this.props.instanceId);
if (!pedalBoardItem)
return (<div className={classes.frame} ></div>);
let controlValues = pedalBoardItem.controlValues;
let plugin: UiPlugin = nullCast(this.model.getUiPlugin(pedalBoardItem.uri));
controlValues = this.filterNotOnGui(controlValues, plugin);
let gridClass = this.state.landscapeGrid ? classes.landscapeGrid : classes.normalGrid;
let vuMeterRClass = this.state.landscapeGrid ? classes.vuMeterRLandscape : classes.vuMeterR;
let controlNodes: ControlNodes;
if (plugin.uri === GXTUNER_URI) {
controlNodes = this.getTunerControls(plugin, controlValues);
} else {
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);
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} >
{
nodes
}
<div style={{ flex: "0 0 40px", width: 40, height: 1 }} />
</div>
<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 >
);
}
}
);
export default PluginControlView;
+145
View File
@@ -0,0 +1,145 @@
import { createStyles, Theme, withStyles, WithStyles } from '@material-ui/core/styles';
import { PiPedalModelFactory } from "./PiPedalModel";
import { PluginType } from './Lv2Plugin';
const styles = (theme: Theme) =>
createStyles({
icon: {
width: "24px",
height: "24px",
margin: "0px",
opacity: "0.6"
},
});
export interface PluginIconProps extends WithStyles<typeof styles> {
size?: number;
offsetY?: number;
pluginType: PluginType;
pluginUri: string;
}
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.HighpassPlugin:
return "fx_highpass.svg";
case PluginType.ParaEQPlugin:
case PluginType.LowpassPlugin:
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";
}
}
var colors: string[] = [
"#14003d",
"#630651",
"#005010",
"#281431",
"#5E0012",
"#401222",
"#3B1502"
];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function colorFromHash(uri: string) {
let hash = hashString(uri);
return colors[hash % colors.length];
}
function hashString(str: string): number {
var hash = 0, i, chr;
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
if (hash < 0) return -hash;
return hash;
}
export function SelectIcon(plugin_type: PluginType, itemUri: string) {
let icon = SelectBaseIcon(plugin_type);
return PiPedalModelFactory.getInstance().svgImgUrl(icon);
}
const PluginIcon = withStyles(styles)((props: PluginIconProps) => {
const { classes, pluginUri, pluginType } = props;
let size: number = 24;
if (props.size) size = props.size;
let topVal: number = (props.offsetY ?? 0);
return (
<img src={SelectIcon(pluginType, pluginUri)} className={classes.icon} style={{ width: size, height: size, color: "#F88", position: "relative", top: topVal }} alt={pluginType as string}
/>);
// 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>
// );
});
export default PluginIcon;
+218
View File
@@ -0,0 +1,218 @@
import React from 'react';
import { createStyles, Theme, withStyles, WithStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import MuiDialogTitle from '@material-ui/core/DialogTitle';
import MuiDialogContent from '@material-ui/core/DialogContent';
import MuiDialogActions from '@material-ui/core/DialogActions';
import IconButton from '@material-ui/core/IconButton';
import CloseIcon from '@material-ui/icons/Close';
import Typography from '@material-ui/core/Typography';
import Grid from '@material-ui/core/Grid';
import { PiPedalModelFactory } from "./PiPedalModel";
import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined';
import { UiPlugin, UiControl } from './Lv2Plugin';
import PluginIcon from './PluginIcon';
const styles = (theme: Theme) =>
createStyles({
root: {
margin: 0,
padding: theme.spacing(2),
},
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
});
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((theme: Theme) => ({
root: {
padding: theme.spacing(2),
},
}))(MuiDialogContent);
const PluginInfoDialogActions = withStyles((theme: Theme) => ({
root: {
margin: 0,
padding: theme.spacing(1),
},
}))(MuiDialogActions);
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[]) {
return (
<Grid container direction="row" justify="flex-start" alignItems="flex-start" spacing={1} style={{ paddingLeft: "24px" }}>
{
controls.map((control) => (
<Grid item spacing={2} xs={6} sm={4} key={control.symbol} >
<Typography variant="body2">
{control.name}
</Typography>
</Grid>
))
}
</Grid>
);
}
const PluginInfoDialog = withStyles(styles)((props: PluginInfoProps) => {
let model = PiPedalModelFactory.getInstance();
const [open, setOpen] = React.useState(false);
let { classes, plugin_uri } = 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 !== "") ? "block" : "none" }} onClick={handleClickOpen}
>
<InfoOutlinedIcon />
</IconButton>
{open && (
<Dialog onClose={handleClose} open={open} fullWidth >
<MuiDialogTitle >
<div style={{ display: "flex", flexDirection: "row", alignItems: "start", flexWrap: "nowrap" }}>
<div style={{ flex: "0 0 auto", marginRight: 16 }}>
<PluginIcon pluginType={plugin.plugin_type} pluginUri={plugin.uri} 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" }}>
<CloseIcon />
</IconButton>
</div>
</MuiDialogTitle>
<PluginInfoDialogContent dividers style={{ width: "100%", maxHeight: "80%", overflowX: "hidden" }}>
<Typography gutterBottom>
Author:&nbsp;
{(plugin.author_homepage !== "")
? <a href={plugin.author_homepage} target="_blank" rel="noreferrer">{plugin.author_name}</a>
: (
plugin.author_name
)
}
</Typography>
<Typography gutterBottom>
{ioDescription(plugin)}
</Typography>
<Typography variant="body1" gutterBottom style={{ paddingTop: "1em" }}>
Controls:
</Typography>
{
makeControls(plugin.controls)
}
<Typography gutterBottom style={{ paddingTop: "1em" }}>
Description:
</Typography>
{
(plugin.description !== "") && makeParagraphs(plugin.description)
}
</PluginInfoDialogContent>
<PluginInfoDialogActions>
<Button autoFocus onClick={handleClose} color="primary" style={{ width: "130px" }}>
OK
</Button>
</PluginInfoDialogActions>
</Dialog>
)}
</div >
);
});
export default PluginInfoDialog;
+20
View File
@@ -0,0 +1,20 @@
export default class PluginPreset {
deserialize(input: any): PluginPreset {
this.presetUri = input.presetUri;
this.name = input.name;
return this;
}
static deserialize_array(input: any) : PluginPreset[] {
let result: PluginPreset[] = [];
for (let i = 0; i < input.length; ++i)
{
result.push(new PluginPreset().deserialize(input[i]));
}
return result;
}
presetUri: string = "";
name: string = "";
}
+284
View File
@@ -0,0 +1,284 @@
import { SyntheticEvent, Component } from 'react';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import { Theme, withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import PresetDialog from './PresetDialog';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import Fade from '@material-ui/core/Fade';
import RenameDialog from './RenameDialog'
import PluginPreset from './PluginPreset';
interface PluginPresetSelectorProps extends WithStyles<typeof styles> {
pluginUri?: string;
onSelectPreset: (presetName: string) => void;
};
interface PluginPresetSelectorState {
presets: PluginPreset[] | null;
enabled: boolean;
showPresetsDialog: boolean;
showEditPresetsDialog: boolean;
presetsMenuAnchorRef: HTMLElement | null;
renameDialogOpen: boolean;
renameDialogDefaultName: string;
renameDialogActionName: string;
renameDialogOnOk?: (name: string) => void;
};
const styles = (theme: Theme) => createStyles({
});
const PluginPresetSelector =
withStyles(styles, { withTheme: true })(
class extends Component<PluginPresetSelectorProps, PluginPresetSelectorState> {
model: PiPedalModel;
constructor(props: PluginPresetSelectorProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
presets: null,
enabled: false,
showPresetsDialog: false,
showEditPresetsDialog: false,
presetsMenuAnchorRef: null,
renameDialogOpen: false,
renameDialogDefaultName: "",
renameDialogActionName: "",
renameDialogOnOk: undefined
};
this.handleDialogClose = this.handleDialogClose.bind(this);
this.handlePresetsMenuClose = this.handlePresetsMenuClose.bind(this);
}
handleLoadPluginPreset(presetName: string)
{
this.handlePresetsMenuClose();
this.props.onSelectPreset(presetName);
}
handlePresetMenuClick(e: SyntheticEvent): void {
this.setState({ presetsMenuAnchorRef: (e.currentTarget as HTMLElement) });
}
handlePresetsMenuClose(): void {
this.setState({ presetsMenuAnchorRef: null });
}
handlePresetsMenuSave(e: SyntheticEvent): void {
this.handlePresetsMenuClose();
e.stopPropagation();
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) => {
// s'fine. dealt with by updates, but we do need error handling.
})
.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);
})
;
}
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;
}
currentUri?: string = "";
componentDidUpdate()
{
if (this.currentUri !== this.props.pluginUri)
{
this.currentUri = this.props.pluginUri;
if (!this.props.pluginUri)
{
this.setState({presets: null});
} else {
this.setState({presets: null});
let captureUri = this.currentUri;
this.model.getPluginPresets(this.props.pluginUri)
.then((presets: PluginPreset[]) => {
if (captureUri === this.currentUri)
{
this.setState({presets: presets});
}
})
.catch(error => {
if (captureUri === this.currentUri)
{
this.setState({presets: null});
}
});
}
}
}
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);
}
handleSave() {
this.model.saveCurrentPreset();
}
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() {
//let classes = this.props.classes;
//let classes = this.props.classes;
if ((!this.props.pluginUri) || (!this.state.presets) ||this.state.presets.length === 0)
{
return (<div/>);
}
return (
<div >
<IconButton onClick={(e)=> this.handlePresetMenuClick(e)}>
<img src="img/ic_pluginpreset.svg" style={{ width: 24, height: 24,opacity: 0.6 }} alt="Plugin Presets" />
</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}
}
}
>
<Typography variant="caption" color="textSecondary" style={{marginLeft: 16,marginTop:4,marginBottom: 4}}>Plugin presets</Typography>
{
this.state.presets.map((preset) => {
return (<MenuItem onClick={(e) => this.handleLoadPluginPreset(preset.presetUri)}>{preset.name}</MenuItem>);
})
}
{/*
<Divider />
<MenuItem onClick={(e) => this.handlePresetsMenuSave(e)}>Save plugin preset</MenuItem>
<MenuItem onClick={(e) => this.handlePresetsMenuSaveAs(e)}>Save plugin preset as...</MenuItem>
<MenuItem onClick={(e) => this.handlePresetsMenuRename(e)}>Rename...</MenuItem>
<MenuItem onClick={(e) => this.handleMenuEditPresets()}>Manage presets...</MenuItem>
*/}
</Menu>
<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)} />
</div>
)
}
});
export default PluginPresetSelector;
+450
View File
@@ -0,0 +1,450 @@
import React, { SyntheticEvent,Component } from 'react';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import { PiPedalModel, PiPedalModelFactory, PresetIndexEntry, PresetIndex } from './PiPedalModel';
import Button from "@material-ui/core/Button";
import ButtonBase from "@material-ui/core/ButtonBase";
import { TransitionProps } from '@material-ui/core/transitions/transition';
import Slide from '@material-ui/core/Slide';
import Dialog from '@material-ui/core/Dialog';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import { Theme, withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import DraggableGrid, { ScrollDirection } from './DraggableGrid';
import MoreVertIcon from '@material-ui/icons/MoreVert';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import Fade from '@material-ui/core/Fade';
import UploadDialog from './UploadDialog';
import SelectHoverBackground from './SelectHoverBackground';
import CloseIcon from '@material-ui/icons/Close';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import EditIcon from '@material-ui/icons/Edit';
import RenameDialog from './RenameDialog';
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;
openUploadDialog: boolean;
};
const styles = (theme: Theme) => createStyles({
dialogAppBar: {
position: 'relative',
top: 0, left: 0
},
dialogActionBar: {
position: 'relative',
top: 0, left: 0,
background: "black"
},
dialogTitle: {
marginLeft: theme.spacing(2),
flex: 1,
},
itemFrame: {
display: "flex",
flexDirection: "row",
flexWrap: "nowrap",
width: "100%",
height: "56px",
alignItems: "center",
textAlign: "left",
justifyContent: "center",
paddingLeft: 8
},
iconFrame: {
flex: "0 0 auto",
},
itemIcon: {
width: 24, height: 24, margin: 12, opacity: 0.6
},
itemLabel: {
flex: "1 1 1px",
marginLeft: 8
}
});
const Transition = React.forwardRef(function Transition(
props: TransitionProps & { children?: React.ReactElement },
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ActionBarTransition = React.forwardRef(function Transition(
props: TransitionProps & { children?: React.ReactElement },
ref: React.Ref<unknown>,
) {
return <Slide direction="right" ref={ref} {...props} />;
});
const PresetDialog = withStyles(styles, { withTheme: true })(
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,
openUploadDialog: 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.model.presets.get().selectedInstanceId);
}
handleUploadPreset() {
this.handleMoreClose();
this.setState({ openUploadDialog: 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;
let classes = this.props.classes;
let selectedItem = this.isEditMode() ? this.state.selectedItem : this.state.presets.selectedInstanceId;
return (
<div key={presetEntry.instanceId} style={{ background: "white" }} >
<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="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() {
let classes = this.props.classes;
let actionBarClass = this.props.isEditDialog ? classes.dialogAppBar : classes.dialogActionBar;
let defaultSelectedIndex = this.getSelectedIndex();
return (
<Dialog fullScreen open={this.props.show}
onClose={() => { this.handleDialogClose() }} TransitionComponent={Transition}>
<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>
<img src="img/file_download_black_24dp.svg" style={{ width: 24, height: 24, opacity: 0.6 }} alt="" />
</ListItemIcon>
<ListItemText>
Download preset
</ListItemText>
</MenuItem>
<MenuItem onClick={() => { this.handleUploadPreset() }}>
<ListItemIcon>
<img src="img/file_upload_black_24dp.svg" style={{ width: 24, height: 24, opacity: 0.6 }} alt="" />
</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>
<UploadDialog
onUploaded={(instanceId) => this.setState({selectedItem: instanceId}) }
uploadAfter={this.state.selectedItem}
open={this.state.openUploadDialog}
onClose={() => { this.setState({ openUploadDialog: false }) }} />
</Dialog >
);
}
}
);
export default PresetDialog;
+355
View File
@@ -0,0 +1,355 @@
import { SyntheticEvent, Component } from 'react';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import { PiPedalModel, PiPedalModelFactory, PresetIndex } from './PiPedalModel';
import SaveIconOutline from '@material-ui/icons/Save';
import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown";
import ButtonBase from "@material-ui/core/ButtonBase";
import MoreVertIcon from '@material-ui/icons/MoreVert';
import { Theme, withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import PresetDialog from './PresetDialog';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import Fade from '@material-ui/core/Fade';
import Divider from '@material-ui/core/Divider';
import RenameDialog from './RenameDialog'
import Select from '@material-ui/core/Select';
import UploadDialog from './UploadDialog';
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;
openUploadDialog: boolean;
};
const selectColor = "white";
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(styles, { withTheme: true })(
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,
openUploadDialog: 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({ openUploadDialog: 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) => {
// s'fine. dealt with by updates, but we do need error handling.
})
.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);
})
;
}
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() {
//let classes = this.props.classes;
let presets = this.state.presets;
let classes = this.props.classes;
return (
<div style={{ marginLeft: 12, display: "flex", flexDirection: "row", justifyContent: "left", flexWrap: "nowrap", alignItems: "center", height: "100%", position: "relative" }}>
<div style={{ flex: "1 1 auto", minWidth: 60, maxWidth: 300, position: "relative", paddingRight: 12 }} >
{true ? (
<Select
className={classes.select}
style={{ width: "100%", position: "relative", top: 0, color: "white" }} disabled={!this.state.enabled}
onChange={(e, extra) => this.handleChange(e, extra)}
onClose={(e) => this.handleSelectClose(e)}
displayEmpty
value={presets.selectedInstanceId}
inputProps={{
classes: { icon: classes.icon },
'aria-label': "Select preset"
}
}
>
{
presets.presets.map((preset) => {
return (
<MenuItem value={preset.instanceId} >
{(presets.presetChanged && preset.instanceId === presets.selectedInstanceId)
? (preset.name + "*")
: (preset.name)
}
</MenuItem>
);
})
}
</Select>
) : (
<ButtonBase style={{ width: "100%", textAlign: "left", borderBottom: "1px white solid", position: "relative", top: 2 }} disabled={!this.state.enabled}
onClick={(e) => { this.showPresetDialog(true); }}
>
<div style={{ width: "100%", textAlign: "left", display: "flex", flexWrap: "nowrap", flexDirection: "row", overflow: "hidden" }}>
<div style={{ flex: "0 1 auto", paddingLeft: 8, overflow: "hidden" }}>
<Typography noWrap >
{this.state.presets.getSelectedText()}
</Typography>
</div>
<div style={{ flex: "1 1 1px" }} />
<div style={{ flex: "0 0 auto" }}>
<ArrowDropDownIcon style={{ opacity: 0.75 }} />
</div>
</div>
</ButtonBase>
)}
</div>
<div style={{ flex: "0 0 auto" }}>
<IconButton color="inherit" style={{ flex: "0 0 auto", opacity: this.state.presets.presetChanged ? 1.0 : 0.0 }}
onClick={(e) => { this.handleSave(); }} >
<SaveIconOutline style={{ opacity: 0.75 }} />
</IconButton>
</div>
<div style={{ flex: "0 0 auto" }}>
<IconButton color="inherit" style={{ flex: "0 0 auto" }} onClick={(e) => this.handlePresetMenuClick(e)}>
<MoreVertIcon style={{ opacity: 0.75 }} />
</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>
<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)} />
<UploadDialog onUploaded={(instanceId) => { this.model.loadPreset(instanceId); }}
open={this.state.openUploadDialog}
uploadAfter={-1}
onClose={() => { this.setState({ openUploadDialog: false }) }} />
</div>
)
}
});
export default PresetSelector;
+70
View File
@@ -0,0 +1,70 @@
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;
+156
View File
@@ -0,0 +1,156 @@
import React from 'react';
import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import { nullCast } from './Utility';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
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>;
constructor(props: RenameDialogProps) {
super(props);
this.state = {
fullScreen: false
};
this.refText = React.createRef<HTMLInputElement>();
this.handlePopState = this.handlePopState.bind(this);
}
mounted: boolean = false;
hasHooks: boolean = false;
stateWasPopped: boolean = false;
handlePopState(e: any): any {
this.stateWasPopped = true;
let shouldClose = (!e.state || !e.state.renameDialog);
if (shouldClose)
{
this.props.onClose();
}
}
updateHooks() : void {
let wantHooks = this.mounted && this.props.open;
if (wantHooks !== this.hasHooks)
{
this.hasHooks = wantHooks;
if (this.hasHooks)
{
this.stateWasPopped = false;
window.addEventListener("popstate",this.handlePopState);
// eslint-disable-next-line no-restricted-globals
let state = history.state;
if (!state)
{
state = {};
}
state.renameDialog = true;
// eslint-disable-next-line no-restricted-globals
history.pushState(
state,
this.props.acceptActionName,
"#RenameDialog"
);
} else {
window.removeEventListener("popstate",this.handlePopState);
if (!this.stateWasPopped)
{
// eslint-disable-next-line no-restricted-globals
history.back();
}
}
}
}
onWindowSizeChanged(width: number, height: number): void
{
this.setState({fullScreen: height < 200})
}
componentDidMount()
{
super.componentDidMount();
this.mounted = true;
this.updateHooks();
}
componentWillUnmount()
{
super.componentDidMount();
this.mounted = false;
this.updateHooks();
}
componentDidUpdate()
{
this.updateHooks();
}
render() {
let props = this.props;
let { open, defaultName, acceptActionName, onClose, onOk } = props;
const handleClose = () => {
onClose();
};
const handleOk = () => {
let text = nullCast(this.refText.current).value;
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 (
<Dialog open={open} fullWidth onClose={handleClose} aria-labelledby="Rename-dialog-title" style={{}}
fullScreen={this.state.fullScreen}
>
<DialogContent>
<TextField
autoFocus
onKeyDown={handleKeyDown}
margin="dense"
id="name"
label="Name"
type="text"
fullWidth
defaultValue={defaultName}
inputRef={this.refText}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
<Button onClick={handleOk} color="secondary" >
{acceptActionName}
</Button>
</DialogActions>
</Dialog>
);
}
}
+44
View File
@@ -0,0 +1,44 @@
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;
+154
View File
@@ -0,0 +1,154 @@
import { useState } from 'react';
import Button from '@material-ui/core/Button';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogActions from '@material-ui/core/DialogActions';
import Dialog from '@material-ui/core/Dialog';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Checkbox from '@material-ui/core/Checkbox';
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 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 => {
onClose(currentSelection);
};
return (
<Dialog onClose={handleClose} aria-labelledby="select-channels-title" open={open}>
<DialogTitle id="simple-dialog-title">Select Channels</DialogTitle>
<List>
{availableChannels.map((channel) => (
<ListItem button >
<FormControlLabel
control={
<Checkbox checked={isChecked(currentSelection, channel)} onClick={() => toggleSelect(channel)} key={channel} />
}
label={channel}
/>
</ListItem>
)
)}
</List>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
<Button onClick={handleOk} color="primary" disabled={currentSelection.length === 0 || currentSelection.length > 2} >
OK
</Button>
</DialogActions>
</Dialog>
);
} else {
const handleClose = () => {
onClose(selectedChannels);
};
const handleListItemClick = (value: string) => {
switch (value) {
case "Stereo":
onClose(availableChannels);
break;
case "Left":
onClose([availableChannels[0]]);
break;
case "Right":
onClose([availableChannels[1]]);
break;
case "None":
default:
onClose([availableChannels[1]]);
}
};
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 (
<Dialog onClose={handleClose} aria-labelledby="select-channels-title" open={open}>
<List style={{ marginLeft: 0, marginRight: 0}}>
<ListItem button onClick={() => handleListItemClick("Stereo")} key={"Stereo"} selected={selectionKey === "Stereo"} >
<ListItemText primary={"Stereo"} />
</ListItem>
<ListItem button onClick={() => handleListItemClick("Left")} key={"LeftChannel"} selected={selectionKey === "Left"} >
<ListItemText primary={"Mono (left channel only)"} />
</ListItem>
<ListItem button onClick={() => handleListItemClick("Right")} key={"RightChannel"} selected={selectionKey === "Right"}>
<ListItemText primary={"Mono (right channel only"} />
</ListItem>
</List>
</Dialog>
);
}
}
export default SelectChannelsDialog;
+95
View File
@@ -0,0 +1,95 @@
import { Component, SyntheticEvent } from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
const styles = ({ palette }: Theme) => createStyles({
frame: {
position: "absolute",
width: "100%",
height: "100%"
},
hover: {
position: "absolute",
width: "100%",
height: "100%",
background: "#000000",
opacity: 0.1
},
selected: {
position: "absolute",
width: "100%",
height: "100%",
background: palette.action.active,
opacity: 0.3
}
});
interface HoverProps extends WithStyles<typeof styles> {
selected: boolean;
showHover?: boolean;
theme: Theme;
};
interface HoverState {
hovering: boolean;
};
export const SelectHoverBackground =
withStyles(styles, { withTheme: true })(
class extends Component<HoverProps, HoverState>
{
constructor(props: HoverProps) {
super(props);
this.state = {
hovering: false
};
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
}
handleMouseEnter(e: SyntheticEvent): void {
if (this.props.showHover??true)
{
this.setHovering(true);
}
}
handleMouseLeave(e: SyntheticEvent): void {
if (this.props.showHover??true)
{
this.setHovering(false);
}
}
setHovering(value: boolean): void {
if (this.state.hovering !== value) {
this.setState({ hovering: value });
}
}
render() {
let classes = this.props.classes;
let isSelected = this.props.selected;
let hovering = this.state.hovering && (this.props.showHover??true);
return (<div className={classes.frame}
onMouseEnter={(e) => { this.handleMouseEnter(e); }} onMouseLeave={(e) => { this.handleMouseLeave(e) }}
>
<div className={classes.selected} style={{ display: (isSelected ? "block" : "none") }} />
<div className={classes.hover} style={{ display: (hovering ? "block" : "none") }} />
</div>);
}
}
);
export default SelectHoverBackground;
+101
View File
@@ -0,0 +1,101 @@
import { useState } from 'react';
import Button from '@material-ui/core/Button';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogActions from '@material-ui/core/DialogActions';
import Dialog from '@material-ui/core/Dialog';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Checkbox from '@material-ui/core/Checkbox';
export interface SelectMidiChannelsDialogProps {
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 SelectMidiChannelsDialog(props: SelectMidiChannelsDialogProps) {
//const classes = useStyles();
const { onClose, selectedChannels, availableChannels, open } = props;
const [currentSelection, setCurrentSelection] = useState(selectedChannels);
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 => {
onClose(currentSelection);
};
return (
<Dialog onClose={handleClose} aria-labelledby="select-channels-title" open={open}>
<DialogTitle id="simple-dialog-title">Select Channels</DialogTitle>
<List>
{availableChannels.map((channel) => (
<ListItem button >
<FormControlLabel
control={
<Checkbox checked={isChecked(currentSelection, channel)} onClick={() => toggleSelect(channel)} key={channel} />
}
label={channel}
/>
</ListItem>
)
)}
</List>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
<Button onClick={handleOk} color="primary" >
OK
</Button>
</DialogActions>
</Dialog>
);
}
export default SelectMidiChannelsDialog;
+497
View File
@@ -0,0 +1,497 @@
import React, { SyntheticEvent, Component } from 'react';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
import ButtonBase from "@material-ui/core/ButtonBase";
import { TransitionProps } from '@material-ui/core/transitions/transition';
import Slide from '@material-ui/core/Slide';
import Dialog from '@material-ui/core/Dialog';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import { Theme, withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import JackConfiguration, { JackChannelSelection } from './Jack';
import Divider from '@material-ui/core/Divider';
import SelectChannelsDialog from './SelectChannelsDialog';
import SelectMidiChannelsDialog from './SelectMidiChannelsDialog';
import SelectHoverBackground from './SelectHoverBackground';
import JackServerSettings from './JackServerSettings';
import JackServerSettingsDialog from './JackServerSettingsDialog';
import JackHostStatus from './JackHostStatus';
interface SettingsDialogProps extends WithStyles<typeof styles> {
open: boolean;
onClose: () => void;
};
interface SettingsDialogState {
jackConfiguration: JackConfiguration;
jackSettings: JackChannelSelection;
jackServerSettings: JackServerSettings;
jackStatus?: JackHostStatus;
showInputSelectDialog: boolean;
showOutputSelectDialog: boolean;
showMidiSelectDialog: boolean;
showJackServerSettingsDialog: boolean;
shuttingDown: boolean;
restarting: boolean;
};
const styles = (theme: Theme) => createStyles({
dialogAppBar: {
position: 'relative',
top: 0, left: 0
},
dialogTitle: {
marginLeft: theme.spacing(2),
flex: 1,
},
sectionHead: {
marginLeft: 24,
marginRight: 24,
marginTop: 16,
paddingBottom: 12
},
textBlock: {
marginLeft: 24,
marginRight: 24,
marginTop: 16,
paddingBottom: 0,
opacity: 0.95
},
textBlockIndented: {
marginLeft: 40,
marginRight: 24,
marginTop: 16,
paddingBottom: 0,
opacity: 0.95
},
setting: {
minHeight: 64,
width: "100%",
textAlign: "left",
paddingLeft: 22,
paddingRight: 22
},
primaryItem: {
},
secondaryItem: {
}
});
const Transition = React.forwardRef(function Transition(
props: TransitionProps & { children?: React.ReactElement },
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
const SettingsDialog = withStyles(styles, { withTheme: true })(
class extends Component<SettingsDialogProps, SettingsDialogState> {
model: PiPedalModel;
constructor(props: SettingsDialogProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.handleDialogClose = this.handleDialogClose.bind(this);
this.state = {
jackServerSettings: this.model.jackServerSettings.get(),
jackConfiguration: this.model.jackConfiguration.get(),
jackStatus: undefined,
jackSettings: this.model.jackSettings.get(),
showInputSelectDialog: false,
showOutputSelectDialog: false,
showMidiSelectDialog: false,
showJackServerSettingsDialog: false,
shuttingDown: false,
restarting: false
};
this.handleJackConfigurationChanged = this.handleJackConfigurationChanged.bind(this);
this.handleJackSettingsChanged = this.handleJackSettingsChanged.bind(this);
this.handleJackServerSettingsChanged = this.handleJackServerSettingsChanged.bind(this);
}
handleJackSettingsChanged(): void {
this.setState({
jackSettings: this.model.jackSettings.get()
});
}
handleJackServerSettingsChanged(): void {
this.setState({
jackServerSettings: this.model.jackServerSettings.get()
});
}
handleJackConfigurationChanged(): void {
this.setState({
jackConfiguration: this.model.jackConfiguration.get()
});
}
mounted: boolean = false;
active: boolean = false;
timerHandle?: NodeJS.Timeout;
tick() {
this.model.getJackStatus()
.then((jackStatus) =>
this.setState(
{
jackStatus: jackStatus
})
)
.catch((error) => { });
}
updateActive() {
let active = this.mounted && this.props.open;
if (active !== this.active) {
this.active = active;
if (active) {
this.model.jackSettings.addOnChangedHandler(this.handleJackSettingsChanged);
this.model.jackConfiguration.addOnChangedHandler(this.handleJackConfigurationChanged);
this.model.jackServerSettings.addOnChangedHandler(this.handleJackServerSettingsChanged);
this.model.getJackStatus()
.then((jackStatus) =>
this.setState(
{
jackStatus: jackStatus
})
)
.catch((error) => {
this.setState(
{
jackStatus: undefined
})
});
this.handleJackConfigurationChanged();
this.handleJackSettingsChanged();
this.handleJackServerSettingsChanged();
this.timerHandle = setInterval(() => this.tick(), 1000);
} else {
if (this.timerHandle) {
clearInterval(this.timerHandle);
}
this.model.jackConfiguration.removeOnChangedHandler(this.handleJackConfigurationChanged);
this.model.jackSettings.removeOnChangedHandler(this.handleJackSettingsChanged);
this.model.jackServerSettings.removeOnChangedHandler(this.handleJackServerSettingsChanged);
}
}
}
componentDidMount() {
this.mounted = true;
this.updateActive();
// scroll selected item into view.
}
componentDidUpdate() {
this.updateActive();
}
componentWillUnmount() {
this.mounted = false;
this.updateActive();
}
handleDialogClose(e: SyntheticEvent) {
this.props.onClose();
}
handleMidiSelection() {
this.setState({
showMidiSelectDialog: true
})
}
handleJackServerSettings() {
this.setState({
showJackServerSettingsDialog: true,
});
}
handleInputSelection() {
this.setState({
showInputSelectDialog: true,
showOutputSelectDialog: false
});
}
handleOutputSelection() {
this.setState({
showInputSelectDialog: false,
showOutputSelectDialog: true
});
}
handleSelectMidiDialogResult(channels: string[] | null): void {
if (channels) {
let newSelection = this.state.jackSettings.clone();
newSelection.inputMidiPorts = channels;
this.model.setJackSettings(newSelection);
}
this.setState({
showMidiSelectDialog: false,
});
}
handleSelectChannelsDialogResult(channels: string[] | null): void {
if (channels) {
let newSelection = this.state.jackSettings.clone();
if (this.state.showInputSelectDialog) {
newSelection.inputAudioPorts = channels;
} else {
newSelection.outputAudioPorts = channels;
}
this.setState({
jackSettings: newSelection,
showInputSelectDialog: false,
showOutputSelectDialog: false
});
this.model.setJackSettings(newSelection);
} else {
this.setState({
showInputSelectDialog: false,
showOutputSelectDialog: false
});
}
}
midiSummary(): string {
let ports = this.state.jackSettings.inputMidiPorts;
if (ports.length === 0) return "Disabled";
if (ports.length === 1) return ports[0];
return ports.length + " channels";
}
handleRestart() {
this.model.restart()
.then(() => {
this.setState({ restarting: true });
})
.catch((error) => {
this.model.showAlert(error);
});
}
handleShutdown() {
this.model.shutdown()
.then(() => {
this.setState({ shuttingDown: true });
})
.catch((error) => {
this.model.showAlert(error);
});
}
render() {
let classes = this.props.classes;
let isConfigValid = this.state.jackConfiguration.isValid;
let selectedChannels: string[] = this.state.showInputSelectDialog ? this.state.jackSettings.inputAudioPorts : this.state.jackSettings.outputAudioPorts;
let disableShutdown = this.state.shuttingDown || this.state.restarting;
return (
<Dialog fullScreen open={this.props.open}
onClose={() => { this.props.onClose() }} TransitionComponent={Transition}>
<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.handleDialogClose} aria-label="back"
>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6" className={classes.dialogTitle}>
Settings
</Typography>
</Toolbar>
</AppBar>
</div>
<div style={{
flex: "1 1 auto", position: "relative", overflow: "hidden",
overflowX: "hidden", overflowY: "auto", marginTop: 22
}}
>
{!isConfigValid && (
<div>
<Typography className={classes.sectionHead} display="block" variant="caption" color="error">
Error
</Typography>
<Typography className={classes.textBlock} display="block" variant="body2" >
The server encounter the following problem:
</Typography>
<Typography className={classes.textBlockIndented} display="block" variant="body2" >
{this.state.jackConfiguration.errorState}
</Typography>
<Typography className={classes.textBlock} display="block" variant="body2">
Please get the Jack Audio Server running properly on the server before attempting to configure audio.
</Typography>
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
</div>
)}
<div style={{ opacity: isConfigValid ? 1.0 : 0.6 }}>
<Typography className={classes.sectionHead} display="block" variant="caption" color="secondary">
AUDIO
</Typography>
<div style={{ paddingLeft: 48, position: "relative", top: -12 }}>
{JackHostStatus.getDisplayView("Status:\u00A0", this.state.jackStatus)}
</div>
<ButtonBase className={classes.setting} onClick={() => this.handleJackServerSettings()}
disabled={!(isConfigValid && this.state.jackServerSettings.valid)}
style={{ opacity: !(isConfigValid && this.state.jackServerSettings.valid) ? 0.6 : 1.0 }}
>
<SelectHoverBackground selected={false} showHover={true} />
<div style={{ width: "100%" }}>
<Typography display="block" variant="body2" noWrap>Jack Server Settings</Typography>
<Typography display="block" variant="caption" noWrap color="textSecondary">{this.state.jackServerSettings.getSummaryText()}</Typography>
</div>
</ButtonBase>
<JackServerSettingsDialog
open={this.state.showJackServerSettingsDialog}
jackServerSettings={this.state.jackServerSettings}
onClose={() => this.setState({ showJackServerSettingsDialog: false })}
onApply={(sampleRate, bufferSize, numberOfBuffers) => {
this.setState({ showJackServerSettingsDialog: false });
this.model.setJackServerSettings(new JackServerSettings(sampleRate, bufferSize, numberOfBuffers));
}}
/>
<ButtonBase className={classes.setting} onClick={() => this.handleInputSelection()} disabled={!isConfigValid}
style={{ opacity: !isConfigValid ? 0.6 : 1.0 }}
>
<SelectHoverBackground selected={false} showHover={true} />
<div style={{ width: "100%" }}>
<Typography display="block" variant="body2" noWrap>Input Channels</Typography>
<Typography display="block" variant="caption" color="textSecondary" noWrap>{this.state.jackSettings.getAudioInputDisplayValue(this.state.jackConfiguration)}</Typography>
</div>
</ButtonBase>
<ButtonBase className={classes.setting} onClick={() => this.handleOutputSelection()} disabled={!isConfigValid}
style={{ opacity: !isConfigValid ? 0.6 : 1.0 }}
>
<SelectHoverBackground selected={false} showHover={true} />
<div style={{ width: "100%" }}>
<Typography display="block" variant="body2" noWrap>Output channels</Typography>
<Typography display="block" variant="caption" color="textSecondary" noWrap>{this.state.jackSettings.getAudioOutputDisplayValue(this.state.jackConfiguration)}</Typography>
</div>
</ButtonBase>
<Divider />
<div >
<Typography className={classes.sectionHead} display="block" variant="caption" color="secondary">MIDI</Typography>
<ButtonBase className={classes.setting} disabled={!isConfigValid} onClick={() => this.handleMidiSelection()} >
<SelectHoverBackground selected={false} showHover={true} />
<div style={{ width: "100%" }}>
<Typography className={classes.primaryItem} display="block" variant="body2" noWrap>Select MIDI Input Channels</Typography>
<Typography className={classes.secondaryItem} display="block" variant="caption" color="textSecondary" noWrap>{this.midiSummary()}</Typography>
</div>
</ButtonBase>
</div>
</div>
<Divider />
<div >
<Typography className={classes.sectionHead} display="block" variant="caption" color="secondary">SYSTEM</Typography>
<ButtonBase className={classes.setting} disabled={!isConfigValid || disableShutdown}
onClick={() => this.handleRestart()} >
<SelectHoverBackground selected={false} showHover={true} />
<div style={{ width: "100%" }}>
{
this.state.restarting ? (
<Typography className={classes.primaryItem} display="block" variant="body2" color="textSecondary" noWrap>Restarting...</Typography>
) : (
<Typography className={classes.primaryItem} display="block" variant="body2" noWrap>Restart</Typography>
)
}
</div>
</ButtonBase>
<ButtonBase className={classes.setting} disabled={!isConfigValid || disableShutdown}
onClick={() => this.handleShutdown()} >
<SelectHoverBackground selected={false} showHover={true} />
<div style={{ width: "100%" }}>
{
this.state.shuttingDown ? (
<Typography className={classes.primaryItem} display="block" variant="body2" color="textSecondary" noWrap>Shutting down...</Typography>
) : (
<Typography className={classes.primaryItem} display="block" variant="body2" noWrap>Shut down</Typography>
)
}
</div >
</ButtonBase>
</div>
</div>
</div>
{
(this.state.showInputSelectDialog || this.state.showOutputSelectDialog) &&
(
<SelectChannelsDialog open={this.state.showInputSelectDialog || this.state.showOutputSelectDialog}
onClose={(selectedChannels: string[] | null) => this.handleSelectChannelsDialogResult(selectedChannels)}
selectedChannels={selectedChannels}
availableChannels={this.state.showInputSelectDialog ? this.state.jackConfiguration.inputAudioPorts : this.state.jackConfiguration.outputAudioPorts}
/>
)
}
{
(this.state.showMidiSelectDialog) &&
(
<SelectMidiChannelsDialog open={this.state.showMidiSelectDialog}
onClose={(selectedChannels: string[] | null) => this.handleSelectMidiDialogResult(selectedChannels)}
selectedChannels={this.state.jackSettings.inputMidiPorts}
availableChannels={this.state.jackConfiguration.inputMidiPorts}
/>
)
}
</Dialog >
);
}
}
);
export default SettingsDialog;
+262
View File
@@ -0,0 +1,262 @@
import { ReactNode } from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
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';
const LANDSCAPE_HEIGHT_BREAK = 500;
const styles = (theme: Theme) => createStyles({
frame: {
display: "block",
position: "relative",
flexDirection: "row",
flexWrap: "nowrap",
paddingTop: "8px",
paddingBottom: "0px",
height: "100%",
overflowX: "auto",
overflowY: "auto"
},
vuMeterL: {
position: "fixed",
paddingLeft: 12,
paddingRight: 12,
left: 0,
background: "white",
zIndex: 3
},
vuMeterR: {
position: "fixed",
right: 0,
marginRight: 22,
paddingLeft: 12,
background: "white",
zIndex: 3
},
vuMeterRLandscape: {
position: "fixed",
right: 0,
paddingRight: 22,
paddingLeft: 12,
background: "white",
zIndex: 3
},
normalGrid: {
position: "relative",
paddingLeft: 40,
paddingRight: 40,
flex: "1 1 auto",
display: "flex", flexDirection: "row", flexWrap: "wrap",
justifyContent: "flex-start", alignItems: "flex_start"
},
landscapeGrid: {
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> {
theme: Theme;
instanceId: number;
item: PedalBoardSplitItem;
};
type SplitControlViewState = {
landscapeGrid: boolean;
};
const SplitControlView =
withStyles(styles, { withTheme: true })(
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.setPedalBoardControlValue(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 {
let classes = this.props.classes;
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 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 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 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 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 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 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 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 >
);
}
}
);
export default SplitControlView;
+162
View File
@@ -0,0 +1,162 @@
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,
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,
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,
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,
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,
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,
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,
units: Units.db,
scale_points: [
new ScalePoint().deserialize({
value: -60,
label: "-INF"
})
]
});
export const SplitControls: UiControl[] =
[
SplitTypeControl,
SplitAbControl,
SplitMixControl,
SplitPanLeftControl,
SplitVolLeftControl,
SplitPanRightControl,
SplitVolRightControl,
];
+21
View File
@@ -0,0 +1,21 @@
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;
+32
View File
@@ -0,0 +1,32 @@
import StringBuilder from './StringBuilder';
class SvgPathBuilder {
_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(' ');
}
closePath(): SvgPathBuilder {
this._sb.append("Z ");
return this;
}
toString(): string { return this._sb.toString(); }
}
export default SvgPathBuilder;
+93
View File
@@ -0,0 +1,93 @@
import React, { Component } from 'react';
import { withStyles, createStyles, WithStyles } from '@material-ui/core';
import Toolbar from '@material-ui/core/Toolbar';
import IconButton from '@material-ui/core/Toolbar';
import Drawer from '@material-ui/core/Drawer';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import { Theme } from '@material-ui/core/styles/createMuiTheme';
const drawerStyles = ({ palette }: Theme) => createStyles({
list: {
width: 250,
},
fullList: {
width: 'auto',
},
drawer_header: {
color: 'white',
background: palette.primary.main,
}
});
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.ReactChild | React.ReactChild[];
};
type DrawerState = {
is_open: boolean;
}
export const TemporaryDrawer = withStyles(drawerStyles)(
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 } = 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(); }}
>
<Toolbar className={classes.drawer_header} style={{backgroundImage: 'url("img/ic_drawer.svg")' }} >
<IconButton style={{ marginLeft: -24 }}
>
<ArrowBackIcon />
</IconButton>
</Toolbar>
{this.props.children}
</div>
</Drawer>
</React.Fragment>
</div >
);
}
}
);
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
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(styles, { withTheme: true })(
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}
/>);
}
}
);
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;
+215
View File
@@ -0,0 +1,215 @@
import React from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import { PiPedalModelFactory, PiPedalModel, State } from "./PiPedalModel";
import { StandardItemSize } from './PluginControlView';
import Utility from './Utility';
import SvgPathBuilder from './SvgPathBuilder';
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;
};
interface ToobFrequencyResponseState {
path: string;
};
const ToobFrequencyResponseView =
withStyles(styles, { withTheme: true })(
class extends React.Component<ToobFrequencyResponseProps, ToobFrequencyResponseState>
{
model: PiPedalModel;
customizationId: number = 1;
pathRef: React.RefObject<SVGPathElement>;
constructor(props: ToobFrequencyResponseProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
this.state = {
path: ""
};
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.updateFrequencyResponse(); // after a reconnect.
}
}
onPedalBoardChanged()
{
this.updateFrequencyResponse();
}
componentDidMount()
{
this.model.state.addOnChangedHandler(this.onStateChanged);
this.model.pedalBoard.addOnChangedHandler(this.onPedalBoardChanged);
this.updateFrequencyResponse();
}
componentWillUnmount()
{
this.model.state.removeOnChangedHandler(this.onStateChanged);
this.model.pedalBoard.removeOnChangedHandler(this.onPedalBoardChanged);
}
componentDidUpdate() {
//this.updateFrequencyResponse();
}
requestOutstanding: boolean = false;
requestDeferred: boolean = false;
dbMinOpt: number = -35;
dbMaxOpt: 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;
dbMin: number = this.dbMaxOpt; // deliberately reversed to flip up and down.
dbMax: number = this.dbMinOpt;
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[]) {
let pathBuilder = new SvgPathBuilder();
if (data.length > 2)
{
pathBuilder.moveTo(this.toX(data[0]),this.toY(data[1]))
for (let i = 2; i < data.length; i += 2)
{
pathBuilder.lineTo(this.toX(data[i]),this.toY(data[i+1]));
}
}
this.currentPath = pathBuilder.toString();
this.setState({path: this.currentPath});
}
updateFrequencyResponse() {
if (this.requestOutstanding) { // throttling.
this.requestDeferred = true;
return;
}
this.requestOutstanding = true;
this.model.getLv2Parameter<number[]>(this.props.instanceId, FREQUENCY_RESPONSE_VECTOR_URI)
.then((data) => {
this.onFrequencyResponseUpdated(data);
if (this.requestDeferred) {
Utility.delay(10) // take breath
.then(
() => {
this.requestOutstanding = false;
this.requestDeferred = false;
this.updateFrequencyResponse();
}
);
} 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 = "";
gridLine(x0: number, y0: number, x1: number, y1: number): React.ReactNode {
return (<line 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);
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 = 0; i < 10; ++i)
{
var f = decade*i;
if (f > this.fMin && f < this.fMax)
{
var x = this.toX(f);
result.push(this.gridLine(x,this.yMin,x,this.yMax));
}
}
}
return result;
}
render() {
let classes = this.props.classes;
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>);
}
}
);
export default ToobFrequencyResponseView;
+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
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(styles, { withTheme: true })(
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)[]
{
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}
/>);
}
}
);
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;
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
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 ToobToneStackProps extends WithStyles<typeof styles> {
instanceId: number;
item: PedalBoardItem;
};
interface ToobToneStackState {
};
const ToobToneStackView =
withStyles(styles, { withTheme: true })(
class extends React.Component<ToobToneStackProps, ToobToneStackState>
implements ControlViewCustomization
{
model: PiPedalModel;
customizationId: number = 1;
constructor(props: ToobToneStackProps) {
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}
/>);
}
}
);
class ToobToneStackViewFactory implements IControlViewFactory {
uri: string = "http://two-play.com/plugins/toob-tone-stack";
Create(model: PiPedalModel, pedalBoardItem: PedalBoardItem): React.ReactNode {
return (<ToobToneStackView instanceId={pedalBoardItem.instanceId} item={pedalBoardItem} />);
}
};
export default ToobToneStackViewFactory;
+26
View File
@@ -0,0 +1,26 @@
enum Units {
none = "none",
unknown = "unknown",
bar = "bar",
beat = "beat",
bpm = "bpm",
cent = "cent",
cm = "cm",
db = "db",
hz = "hz",
khz = "khz",
km = "km",
m = "m",
mhz = "mhz",
midiNote = "midiNote",
min = "min",
ms = "ms",
pc = "pc",
s = "s",
semitone12TET = "semitone12TET"
};
export default Units;
+221
View File
@@ -0,0 +1,221 @@
import React from 'react';
import Button from '@material-ui/core/Button';
import {PiPedalModel,PiPedalModelFactory} from './PiPedalModel';
import Dialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import Typography from '@material-ui/core/Typography';
const PRESET_EXTENSION = ".piPreset";
const BANK_EXTENSION = ".piBank";
export interface UploadDialogProps {
open: boolean,
onClose: () => void,
uploadAfter: number,
onUploaded: (instanceId: number) => void
};
export interface UploadDialogState {
fullScreen: boolean;
};
export default class UploadDialog extends ResizeResponsiveComponent<UploadDialogProps, UploadDialogState> {
model: PiPedalModel;
constructor(props: UploadDialogProps) {
super(props);
this.state = {
fullScreen: false
};
this.model = PiPedalModelFactory.getInstance();
this.handlePopState = this.handlePopState.bind(this);
}
mounted: boolean = false;
hasHooks: boolean = false;
stateWasPopped: boolean = false;
handlePopState(e: any): any {
this.stateWasPopped = true;
let shouldClose = (!e.state || !e.state.UploadDialog);
if (shouldClose) {
this.props.onClose();
}
}
updateHooks(): void {
let wantHooks = this.mounted && this.props.open;
if (wantHooks !== this.hasHooks) {
this.hasHooks = wantHooks;
if (this.hasHooks) {
this.stateWasPopped = false;
window.addEventListener("popstate", this.handlePopState);
// eslint-disable-next-line no-restricted-globals
let state = history.state;
if (!state) {
state = {};
}
state.UploadDialog = true;
// eslint-disable-next-line no-restricted-globals
history.pushState(
state,
"Upload",
"#UploadDialog"
);
} else {
window.removeEventListener("popstate", this.handlePopState);
if (!this.stateWasPopped) {
// eslint-disable-next-line no-restricted-globals
history.back();
}
}
}
}
onWindowSizeChanged(width: number, height: number): void {
this.setState({ fullScreen: height < 200 })
}
componentDidMount() {
super.componentDidMount();
this.mounted = true;
this.updateHooks();
}
componentWillUnmount() {
super.componentDidMount();
this.mounted = false;
this.updateHooks();
}
componentDidUpdate() {
this.updateHooks();
}
handleClose(): void {
this.props.onClose();
}
handleOk(): void {
this.props.onClose();
}
async uploadFiles(fileList: FileList)
{
try {
let uploadAfter = this.props.uploadAfter;
for (let i = 0; i < fileList.length; ++i)
{
uploadAfter = await this.model.uploadPreset(fileList[i],uploadAfter);
}
this.props.onUploaded(uploadAfter);
} catch(error) {
this.model.showAlert(error);
};
this.props.onClose();
}
handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
e.currentTarget.style.background = "";
if (this.wantsTransfer(e.dataTransfer)) {
this.uploadFiles(e.dataTransfer.files)
.then(() => {
this.handleClose();
})
.catch((error)=> {
this.handleClose();
this.model.showAlert(error);
});
}
}
getFileExtension(name: string): string {
let pos = name.lastIndexOf('.');
if (pos !== -1) {
return name.substring(pos);
}
return "";
}
wantsTransfer(fileList: DataTransfer): boolean {
if (fileList.files.length === 0) return false;
for (let i = 0; i < fileList.files.length; ++i) {
let file = fileList.files[i];
let extension = this.getFileExtension(file.name);
if (extension !== PRESET_EXTENSION && extension !== BANK_EXTENSION) return false;
}
return true;
}
handleDragOver(e: React.DragEvent<HTMLDivElement>) {
e.currentTarget.style.background = "#D8D8D8";
e.preventDefault();
}
handleButtonSelect(e: React.ChangeEvent<HTMLInputElement>)
{
if (e.currentTarget.files)
{
this.uploadFiles(e.currentTarget.files);
}
}
render() {
return (
<Dialog open={this.props.open} fullWidth onClose={() => this.handleClose()} style={{}}
fullScreen={this.state.fullScreen}
>
<DialogTitle>Upload preset</DialogTitle>
<DialogContent>
<div style={{
width: "100%", height: 100, marginBottom: 0,
border: "2px dashed #CCC",
borderRadius: "10px",
fontFamily: "Roboto",
padding: 20
}}
onDragEnter={(e) => { this.handleDragOver(e); }}
onDragLeave={(e) => { e.currentTarget.style.background = ""; e.preventDefault(); }}
onDragOver={(e) => { this.handleDragOver(e); }}
onDrop={(e) => {
this.handleDrop(e);
}}
>
<Typography noWrap color="textSecondary" align="center" variant="caption" style={{verticalAlign: "middle"}} >Drop files here</Typography>
</div>
</DialogContent>
<DialogActions>
<Button onClick={() => this.handleClose()} color="primary">
Cancel
</Button>
<Button
component="label" color="secondary" style={{width: 120}}
>
Select File
<input
type="file"
hidden
accept=".piPreset"
multiple
onChange={(e)=> this.handleButtonSelect(e)}
/>
</Button>
</DialogActions>
</Dialog>
);
}
}
+140
View File
@@ -0,0 +1,140 @@
import { PiPedalArgumentError } from './PiPedalError';
export function nullCast<T>(value: T | null | undefined): T {
if (!value) throw new PiPedalArgumentError("Unexpected null value.");
return value;
}
const FLAT = "\u266D";
const LONG_PRESS_TIME_MS = 500; // current android long-press.
export function getLongPressTimeMs(): number { return LONG_PRESS_TIME_MS; }
export interface ControlEntry {
value: number;
displayName: string;
};
const Utility = class {
static isTouchDevice(): boolean {
return navigator.maxTouchPoints > 0;
}
static hasIMEKeyboard(): boolean {
if (/Mobi|Android/i.test(navigator.userAgent)) {
return true;
}
return false;
}
static delay(ms: number): Promise<void> {
return new Promise<void>(resolve => setTimeout(resolve, ms));
}
static NOTE_DEGREES: string[] =
[
"C", "C#", "D", "E" + FLAT, "E", "F", "F#", "G", "A" + FLAT, "A", "B" + FLAT, "B"
];
static midiNoteName(noteNumber: number): string {
let n = noteNumber - 24;
let octave = Math.floor(n / 12);
let iNote = n % 12;
if (iNote < 0) iNote += 12;
return Utility.NOTE_DEGREES[iNote] + octave;
}
static validMidiControllers: ControlEntry[] = [
{ value: 1, displayName: "CC-1 Mod Wheel" },
{ value: 2, displayName: "CC-2 Breath controller" },
{ value: 3, displayName: "CC-3" },
{ value: 4, displayName: "CC-4 Foot Pedal" },
{ value: 5, displayName: "CC-5 Portamento Time" },
{ value: 7, displayName: "CC-7 Volume" },
{ value: 8, displayName: "CC-8 Balance" },
{ value: 10, displayName: "CC-10 Pan position" },
{ value: 11, displayName: "CC-11 Expression" },
{ value: 12, displayName: "CC-12 Effect Control 1" },
{ value: 13, displayName: "CC-13 Effect Control 2" },
{ value: 14, displayName: "CC-14" },
{ value: 15, displayName: "CC-15" },
{ value: 16, displayName: "CC-16" },
{ value: 17, displayName: "CC-17" },
{ value: 18, displayName: "CC-18" },
{ value: 19, displayName: "CC-19" },
{ value: 20, displayName: "CC-20" },
{ value: 21, displayName: "CC-21" },
{ value: 22, displayName: "CC-22" },
{ value: 23, displayName: "CC-23" },
{ value: 24, displayName: "CC-24" },
{ value: 25, displayName: "CC-25" },
{ value: 26, displayName: "CC-26" },
{ value: 27, displayName: "CC-27" },
{ value: 28, displayName: "CC-28" },
{ value: 29, displayName: "CC-29" },
{ value: 30, displayName: "CC-30" },
{ value: 31, displayName: "CC-31" },
// Control LSB.
{ value: 64, displayName: "CC-64 Hold Pedal" },
{ value: 65, displayName: "CC-65 Portamento" },
{ value: 66, displayName: "CC-66 Sustenuto Pedal" },
{ value: 67, displayName: "CC-67 Soft Pedal" },
{ value: 68, displayName: "CC-68 Legato Pedal" },
{ value: 69, displayName: "CC-69 Hold 2 Pedal" },
{ value: 70, displayName: "CC-70 Sound Variation" },
{ value: 71, displayName: "CC-71 Resonance" },
{ value: 72, displayName: "CC-72 Sound Release Time" },
{ value: 73, displayName: "CC-73 Sound Attack Time" },
{ value: 74, displayName: "CC-74 Frequency Cutoff" },
{ value: 75, displayName: "CC-75" },
{ value: 76, displayName: "CC-76" },
{ value: 77, displayName: "CC-77" },
{ value: 78, displayName: "CC-78" },
{ value: 79, displayName: "CC-79" },
{ value: 80, displayName: "CC-80 Decay" },
{ value: 81, displayName: "CC-81" },
{ value: 82, displayName: "CC-82" },
{ value: 83, displayName: "CC-83" },
{ value: 91, displayName: "CC-91 Reverb Level" },
{ value: 92, displayName: "CC-92 Tremolo Level" },
{ value: 93, displayName: "CC-93 Chorus Level" },
{ value: 94, displayName: "CC-94 Detune" },
{ value: 95, displayName: "CC-95 Phaser Level" },
// RPN/DATA controls
{ value: 102, displayName: "CC-102" },
{ value: 103, displayName: "CC-103" },
{ value: 104, displayName: "CC-104" },
{ value: 105, displayName: "CC-105" },
{ value: 106, displayName: "CC-106" },
{ value: 107, displayName: "CC-107" },
{ value: 108, displayName: "CC-108" },
{ value: 109, displayName: "CC-109" },
{ value: 110, displayName: "CC-110" },
{ value: 111, displayName: "CC-111" },
{ value: 112, displayName: "CC-112" },
{ value: 113, displayName: "CC-113" },
{ value: 114, displayName: "CC-114" },
{ value: 115, displayName: "CC-115" },
{ value: 116, displayName: "CC-116" },
{ value: 117, displayName: "CC-117" },
{ value: 118, displayName: "CC-118" },
{ value: 119, displayName: "CC-119" },
{ value: 120, displayName: "CC-120 All Sound Off" },
{ value: 121, displayName: "CC-121 All Controllers Off" },
{ value: 122, displayName: "CC-122 Local Keyboard" },
{ value: 123, displayName: "CC-123 All Notes Off" },
{ value: 124, displayName: "CC-124 Omni Mode Off" },
{ value: 125, displayName: "CC-125 Omni Mode On" },
{ value: 126, displayName: "CC-126 Mono " },
{ value: 127, displayName: "CC-127 Poly" },
];
};
export default Utility;
+399
View File
@@ -0,0 +1,399 @@
import React, { Component } from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import { PiPedalModel, PiPedalModelFactory, VuUpdateInfo, VuSubscriptionHandle } from './PiPedalModel';
const DISPLAY_HEIGHT = 110;
const BORDER_THICKNESS = 2;
const TELLTALE_HEIGHT = 4;
const CHANNEL_WIDTH = 4;
const TELLTALE_HOLD_MS = 2000;
const TELLTALE_DECAY_RATE = -20 / 1000.0; // -20db in one second.
const INTERIOR_DISPLAY_HEIGHT = DISPLAY_HEIGHT - BORDER_THICKNESS * 2;
const MIN_DB = -35;
const MAX_DB = 6;
const DB_YELLOW = -12;
interface VuChannelData {
value: number;
redDiv: HTMLDivElement;
yellowDiv: HTMLDivElement;
greenDiv: HTMLDivElement;
telltaleDiv: HTMLDivElement;
};
class TelltaleState {
telltaleDb: number = MIN_DB;
lastTelltaleTime: number = 0;
telltaleHoldTime: number = 0;
reset() : void {
this.telltaleHoldTime = 0;
this.lastTelltaleTime = 0;
this.telltaleDb = MIN_DB;
}
getValue(currentDb: number): number {
let t = new Date().getTime();
let holdValue: number;
if (t < this.telltaleHoldTime) {
holdValue = this.telltaleDb;
} else {
if (this.telltaleHoldTime === 0) {
holdValue = MIN_DB;
} else {
holdValue = this.telltaleDb + (t - this.telltaleHoldTime) * TELLTALE_DECAY_RATE;
if (holdValue < MIN_DB) {
this.telltaleDb = holdValue = MIN_DB;
this.telltaleHoldTime = t + 100 * 1000; // wait for a long time.
}
}
}
if (currentDb > holdValue) {
this.telltaleDb = currentDb;
this.telltaleHoldTime = t + TELLTALE_HOLD_MS;
return currentDb;
} else {
return holdValue;
}
}
};
const LEFT_X = BORDER_THICKNESS;
const RIGHT_X = BORDER_THICKNESS + + CHANNEL_WIDTH + BORDER_THICKNESS;
const styles = ({ palette }: Theme) => createStyles({
frame: {
position: "relative",
width: CHANNEL_WIDTH + 2*BORDER_THICKNESS,
borderTop: BORDER_THICKNESS + "px black solid",
borderBottom: BORDER_THICKNESS + "px black solid",
height: DISPLAY_HEIGHT,
background: "black",
overflow: "hidden",
marginLeft: (CHANNEL_WIDTH+BORDER_THICKNESS)/2,
marginRight: (CHANNEL_WIDTH+BORDER_THICKNESS)/2
},
frameStereo: {
position: "relative",
width: 2*CHANNEL_WIDTH + 3*BORDER_THICKNESS,
borderTop: BORDER_THICKNESS + "px black solid",
borderBottom: BORDER_THICKNESS + "px black solid",
height: DISPLAY_HEIGHT,
background: "black",
overflow: "hidden",
},
redFrameL: {
position: "absolute",
top: "100%",
left: LEFT_X,
width: CHANNEL_WIDTH,
height: DISPLAY_HEIGHT - 2,
background: "#F00"
},
yellowFrameL: {
top: "100%",
left: LEFT_X,
width: CHANNEL_WIDTH,
position: "absolute",
height: DISPLAY_HEIGHT - 2,
background: "#CC0"
},
greenFrameL: {
top: "100%",
left: LEFT_X,
width: CHANNEL_WIDTH,
position: "absolute",
height: DISPLAY_HEIGHT - 2,
background: "#0C2"
},
indicatorL: {
position: "absolute",
left: LEFT_X,
width: CHANNEL_WIDTH,
height: TELLTALE_HEIGHT + "px",
top: "100%",
background: "#F00"
},
redFrameR: {
position: "absolute",
top: "100%",
left: RIGHT_X,
width: CHANNEL_WIDTH,
height: DISPLAY_HEIGHT - 2,
background: "#F00"
},
yellowFrameR: {
top: "100%",
left: RIGHT_X,
width: CHANNEL_WIDTH,
position: "absolute",
height: DISPLAY_HEIGHT - 2,
background: "#CC0"
},
greenFrameR: {
top: "100%",
left: RIGHT_X,
width: CHANNEL_WIDTH,
position: "absolute",
height: DISPLAY_HEIGHT - 2,
background: "#0C2"
},
indicatorR: {
position: "absolute",
left: RIGHT_X,
width: CHANNEL_WIDTH,
height: TELLTALE_HEIGHT + "px",
top: "100%",
background: "#F00"
}
});
interface VuMeterProps extends WithStyles<typeof styles> {
instanceId: number;
display: "input" | "output";
theme: Theme;
};
interface VuMeterState {
isStereo: boolean;
};
function aToDb(value: number): number {
if (value === 0) return -96;
return Math.log10(Math.abs(value)) * 20;
}
function dbToY(db: number): number {
if (db < MIN_DB) db = MIN_DB;
if (db > MAX_DB) db = MAX_DB;
let y = INTERIOR_DISPLAY_HEIGHT - (db - MIN_DB) / (MAX_DB - MIN_DB) * INTERIOR_DISPLAY_HEIGHT;;
return y;
}
export const VuMeter =
withStyles(styles, { withTheme: true })(
class extends Component<VuMeterProps, VuMeterState>
{
divRef: React.RefObject<HTMLDivElement> = React.createRef();
model: PiPedalModel;
yZero: number;
yYellow: number;
constructor(props: VuMeterProps) {
super(props);
this.state = {
isStereo: false
};
this.model = PiPedalModelFactory.getInstance();
this.yZero = dbToY(0);
this.yYellow = dbToY(DB_YELLOW);
this.onVuChanged = this.onVuChanged.bind(this);
}
telltaleStateL: TelltaleState = new TelltaleState();
telltaleStateR: TelltaleState = new TelltaleState();
vuInfo?: VuUpdateInfo;
requesttedStereoState: boolean = false;
onVuChanged(vuInfo: VuUpdateInfo): void {
let value: number;
let valueR: number;
this.vuInfo = vuInfo;
let isStereo: boolean;
if (this.props.display === "input") {
value = vuInfo.inputMaxValueL;
valueR = vuInfo.inputMaxValueR;
isStereo = vuInfo.isStereoInput;
} else {
isStereo = vuInfo.isStereoOutput;
value = vuInfo.outputMaxValueL;
valueR = vuInfo.outputMaxValueR;
}
if (this.state.isStereo !== isStereo)
{
if (isStereo !== this.requesttedStereoState) // guard against overrunning the layout engine.
{
this.requesttedStereoState = isStereo;
this.setState({
isStereo: isStereo
});
return; // we can't procede without stereo layout.
}
}
let childNodes = this.divRef.current!.childNodes;
let vuData: VuChannelData = {
value: value,
redDiv: childNodes[0] as HTMLDivElement,
yellowDiv: childNodes[1] as HTMLDivElement,
greenDiv: childNodes[2] as HTMLDivElement,
telltaleDiv: childNodes[3] as HTMLDivElement
};
this.updateChannel(vuData,this.telltaleStateL);
if (this.state.isStereo) {
vuData = {
value: valueR,
redDiv: childNodes[4] as HTMLDivElement,
yellowDiv: childNodes[5] as HTMLDivElement,
greenDiv: childNodes[6] as HTMLDivElement,
telltaleDiv: childNodes[7] as HTMLDivElement
};
this.updateChannel(vuData, this.telltaleStateR);
}
}
resetTelltales() {
this.telltaleStateL.reset();
this.telltaleStateR.reset();
}
updateChannel(vuData: VuChannelData, telltaleState: TelltaleState) {
let db = aToDb(vuData.value);
if (db > MAX_DB) db = MAX_DB;
if (db < MIN_DB) db = MIN_DB;
let y = dbToY(db);
let INVISIBLE_Y = INTERIOR_DISPLAY_HEIGHT + "px";
if (y >= this.yYellow) {
// green only.
vuData.greenDiv.style.top = y + "px";
vuData.yellowDiv.style.top = INVISIBLE_Y;
vuData.redDiv.style.top = INVISIBLE_Y;
} else {
vuData.greenDiv.style.top = this.yYellow + "px";
if (y >= this.yZero) {
vuData.yellowDiv.style.top = y + "px";
vuData.redDiv.style.top = INVISIBLE_Y;
} else {
vuData.yellowDiv.style.top = this.yZero + "px";
vuData.redDiv.style.top = y + "px";
}
}
let dbTelltale = telltaleState.getValue(db);
let yTelltale = dbToY(dbTelltale);
if (yTelltale < this.yZero) {
vuData.telltaleDiv.style.background = "#F00";
} else if (yTelltale < this.yYellow) {
vuData.telltaleDiv.style.background = "#CC0";
} else {
vuData.telltaleDiv.style.background = "#0F2";
}
if (yTelltale > INTERIOR_DISPLAY_HEIGHT - TELLTALE_HEIGHT) {
// non-zero and zero are different displays.
// zero: nothing.. tiny: show the telltale.
if (vuData.value !== 0) {
yTelltale = INTERIOR_DISPLAY_HEIGHT - TELLTALE_HEIGHT;
} else {
yTelltale = INTERIOR_DISPLAY_HEIGHT;
}
}
vuData.telltaleDiv.style.top = yTelltale + "px";
}
subscriptionHandle?: VuSubscriptionHandle;
subscribedInstanceId?: number;
addVuSubscription() {
this.removeVuSubscription();
if (this.props.instanceId !== -1) {
this.subscribedInstanceId = this.props.instanceId;
this.subscriptionHandle = this.model.addVuSubscription(this.props.instanceId, this.onVuChanged);
this.resetTelltales();
}
}
removeVuSubscription() {
if (this.subscriptionHandle) {
this.model.removeVuSubscription(this.subscriptionHandle);
this.subscriptionHandle = undefined;
this.subscribedInstanceId = undefined;
}
}
componentDidUpdate()
{
if (this.subscribedInstanceId)
{
if (this.props.instanceId !== this.subscribedInstanceId)
{
this.removeVuSubscription();
this.addVuSubscription();
}
}
}
render() {
let classes = this.props.classes;
if (!this.state.isStereo) {
return (<div className={classes.frame} ref={this.divRef}
>
<div className={classes.redFrameL} />
<div className={classes.yellowFrameL} />
<div className={classes.greenFrameL} />
<div className={classes.indicatorL} />
</div>);
} else {
return (<div className={classes.frameStereo} ref={this.divRef}
>
<div className={classes.redFrameL} />
<div className={classes.yellowFrameL} />
<div className={classes.greenFrameL} />
<div className={classes.indicatorL} />
<div className={classes.redFrameR} />
<div className={classes.yellowFrameR} />
<div className={classes.greenFrameR} />
<div className={classes.indicatorR} />
</div>);
}
}
componentDidMount() {
this.addVuSubscription();
}
componentWillUnmount() {
this.removeVuSubscription();
}
}
);
export default VuMeter;
+399
View File
@@ -0,0 +1,399 @@
import React, { Component } from 'react';
import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles';
import { PiddleModel, PiddleModelFactory, VuUpdateInfo, VuSubscriptionHandle } from './PiddleModel';
const DISPLAY_HEIGHT = 110;
const BORDER_THICKNESS = 2;
const TELLTALE_HEIGHT = 4;
const CHANNEL_WIDTH = 4;
const TELLTALE_HOLD_MS = 2000;
const TELLTALE_DECAY_RATE = -20 / 1000.0; // -20db in one second.
const INTERIOR_DISPLAY_HEIGHT = DISPLAY_HEIGHT - BORDER_THICKNESS * 2;
const MIN_DB = -35;
const MAX_DB = 6;
const DB_YELLOW = -12;
interface VuChannelData {
value: number;
redDiv: HTMLDivElement;
yellowDiv: HTMLDivElement;
greenDiv: HTMLDivElement;
telltaleDiv: HTMLDivElement;
};
class TelltaleState {
telltaleDb: number = MIN_DB;
lastTelltaleTime: number = 0;
telltaleHoldTime: number = 0;
reset() : void {
this.telltaleHoldTime = 0;
this.lastTelltaleTime = 0;
this.telltaleDb = MIN_DB;
}
getValue(currentDb: number): number {
let t = new Date().getTime();
let holdValue: number;
if (t < this.telltaleHoldTime) {
holdValue = this.telltaleDb;
} else {
if (this.telltaleHoldTime === 0) {
holdValue = MIN_DB;
} else {
holdValue = this.telltaleDb + (t - this.telltaleHoldTime) * TELLTALE_DECAY_RATE;
if (holdValue < MIN_DB) {
this.telltaleDb = holdValue = MIN_DB;
this.telltaleHoldTime = t + 100 * 1000; // wait for a long time.
}
}
}
if (currentDb > holdValue) {
this.telltaleDb = currentDb;
this.telltaleHoldTime = t + TELLTALE_HOLD_MS;
return currentDb;
} else {
return holdValue;
}
}
};
const LEFT_X = BORDER_THICKNESS;
const RIGHT_X = BORDER_THICKNESS + + CHANNEL_WIDTH + BORDER_THICKNESS;
const styles = ({ palette }: Theme) => createStyles({
frame: {
position: "relative",
width: CHANNEL_WIDTH + 2*BORDER_THICKNESS,
borderTop: BORDER_THICKNESS + "px black solid",
borderBottom: BORDER_THICKNESS + "px black solid",
height: DISPLAY_HEIGHT,
background: "black",
overflow: "hidden",
marginLeft: (CHANNEL_WIDTH+BORDER_THICKNESS)/2,
marginRight: (CHANNEL_WIDTH+BORDER_THICKNESS)/2
},
frameStereo: {
position: "relative",
width: 2*CHANNEL_WIDTH + 3*BORDER_THICKNESS,
borderTop: BORDER_THICKNESS + "px black solid",
borderBottom: BORDER_THICKNESS + "px black solid",
height: DISPLAY_HEIGHT,
background: "black",
overflow: "hidden",
},
redFrameL: {
position: "absolute",
top: "100%",
left: LEFT_X,
width: CHANNEL_WIDTH,
height: DISPLAY_HEIGHT - 2,
background: "#F00"
},
yellowFrameL: {
top: "100%",
left: LEFT_X,
width: CHANNEL_WIDTH,
position: "absolute",
height: DISPLAY_HEIGHT - 2,
background: "#CC0"
},
greenFrameL: {
top: "100%",
left: LEFT_X,
width: CHANNEL_WIDTH,
position: "absolute",
height: DISPLAY_HEIGHT - 2,
background: "#0C2"
},
indicatorL: {
position: "absolute",
left: LEFT_X,
width: CHANNEL_WIDTH,
height: TELLTALE_HEIGHT + "px",
top: "100%",
background: "#F00"
},
redFrameR: {
position: "absolute",
top: "100%",
left: RIGHT_X,
width: CHANNEL_WIDTH,
height: DISPLAY_HEIGHT - 2,
background: "#F00"
},
yellowFrameR: {
top: "100%",
left: RIGHT_X,
width: CHANNEL_WIDTH,
position: "absolute",
height: DISPLAY_HEIGHT - 2,
background: "#CC0"
},
greenFrameR: {
top: "100%",
left: RIGHT_X,
width: CHANNEL_WIDTH,
position: "absolute",
height: DISPLAY_HEIGHT - 2,
background: "#0C2"
},
indicatorR: {
position: "absolute",
left: RIGHT_X,
width: CHANNEL_WIDTH,
height: TELLTALE_HEIGHT + "px",
top: "100%",
background: "#F00"
}
});
interface VuMeterProps extends WithStyles<typeof styles> {
instanceId: number;
display: "input" | "output";
theme: Theme;
};
interface VuMeterState {
isStereo: boolean;
};
function aToDb(value: number): number {
if (value === 0) return -96;
return Math.log10(Math.abs(value)) * 20;
}
function dbToY(db: number): number {
if (db < MIN_DB) db = MIN_DB;
if (db > MAX_DB) db = MAX_DB;
let y = INTERIOR_DISPLAY_HEIGHT - (db - MIN_DB) / (MAX_DB - MIN_DB) * INTERIOR_DISPLAY_HEIGHT;;
return y;
}
export const VuMeter =
withStyles(styles, { withTheme: true })(
class extends Component<VuMeterProps, VuMeterState>
{
divRef: React.RefObject<HTMLDivElement> = React.createRef();
model: PiddleModel;
yZero: number;
yYellow: number;
constructor(props: VuMeterProps) {
super(props);
this.state = {
isStereo: false
};
this.model = PiddleModelFactory.getInstance();
this.yZero = dbToY(0);
this.yYellow = dbToY(DB_YELLOW);
this.onVuChanged = this.onVuChanged.bind(this);
}
telltaleStateL: TelltaleState = new TelltaleState();
telltaleStateR: TelltaleState = new TelltaleState();
vuInfo?: VuUpdateInfo;
requesttedStereoState: boolean = false;
onVuChanged(vuInfo: VuUpdateInfo): void {
let value: number;
let valueR: number;
this.vuInfo = vuInfo;
let isStereo: boolean;
if (this.props.display === "input") {
value = vuInfo.inputMaxValueL;
valueR = vuInfo.inputMaxValueR;
isStereo = vuInfo.isStereoInput;
} else {
isStereo = vuInfo.isStereoOutput;
value = vuInfo.outputMaxValueL;
valueR = vuInfo.outputMaxValueR;
}
if (this.state.isStereo !== isStereo)
{
if (isStereo !== this.requesttedStereoState) // guard against overrunning the layout engine.
{
this.requesttedStereoState = isStereo;
this.setState({
isStereo: isStereo
});
return; // we can't procede without stereo layout.
}
}
let childNodes = this.divRef.current!.childNodes;
let vuData: VuChannelData = {
value: value,
redDiv: childNodes[0] as HTMLDivElement,
yellowDiv: childNodes[1] as HTMLDivElement,
greenDiv: childNodes[2] as HTMLDivElement,
telltaleDiv: childNodes[3] as HTMLDivElement
};
this.updateChannel(vuData,this.telltaleStateL);
if (this.state.isStereo) {
vuData = {
value: valueR,
redDiv: childNodes[4] as HTMLDivElement,
yellowDiv: childNodes[5] as HTMLDivElement,
greenDiv: childNodes[6] as HTMLDivElement,
telltaleDiv: childNodes[7] as HTMLDivElement
};
this.updateChannel(vuData, this.telltaleStateR);
}
}
resetTelltales() {
this.telltaleStateL.reset();
this.telltaleStateR.reset();
}
updateChannel(vuData: VuChannelData, telltaleState: TelltaleState) {
let db = aToDb(vuData.value);
if (db > MAX_DB) db = MAX_DB;
if (db < MIN_DB) db = MIN_DB;
let y = dbToY(db);
let INVISIBLE_Y = INTERIOR_DISPLAY_HEIGHT + "px";
if (y >= this.yYellow) {
// green only.
vuData.greenDiv.style.top = y + "px";
vuData.yellowDiv.style.top = INVISIBLE_Y;
vuData.redDiv.style.top = INVISIBLE_Y;
} else {
vuData.greenDiv.style.top = this.yYellow + "px";
if (y >= this.yZero) {
vuData.yellowDiv.style.top = y + "px";
vuData.redDiv.style.top = INVISIBLE_Y;
} else {
vuData.yellowDiv.style.top = this.yZero + "px";
vuData.redDiv.style.top = y + "px";
}
}
let dbTelltale = telltaleState.getValue(db);
let yTelltale = dbToY(dbTelltale);
if (yTelltale < this.yZero) {
vuData.telltaleDiv.style.background = "#F00";
} else if (yTelltale < this.yYellow) {
vuData.telltaleDiv.style.background = "#CC0";
} else {
vuData.telltaleDiv.style.background = "#0F2";
}
if (yTelltale > INTERIOR_DISPLAY_HEIGHT - TELLTALE_HEIGHT) {
// non-zero and zero are different displays.
// zero: nothing.. tiny: show the telltale.
if (vuData.value !== 0) {
yTelltale = INTERIOR_DISPLAY_HEIGHT - TELLTALE_HEIGHT;
} else {
yTelltale = INTERIOR_DISPLAY_HEIGHT;
}
}
vuData.telltaleDiv.style.top = yTelltale + "px";
}
subscriptionHandle?: VuSubscriptionHandle;
subscribedInstanceId?: number;
addVuSubscription() {
this.removeVuSubscription();
if (this.props.instanceId !== -1) {
this.subscribedInstanceId = this.props.instanceId;
this.subscriptionHandle = this.model.addVuSubscription(this.props.instanceId, this.onVuChanged);
this.resetTelltales();
}
}
removeVuSubscription() {
if (this.subscriptionHandle) {
this.model.removeVuSubscription(this.subscriptionHandle);
this.subscriptionHandle = undefined;
this.subscribedInstanceId = undefined;
}
}
componentDidUpdate()
{
if (this.subscribedInstanceId)
{
if (this.props.instanceId !== this.subscribedInstanceId)
{
this.removeVuSubscription();
this.addVuSubscription();
}
}
}
render() {
let classes = this.props.classes;
if (!this.state.isStereo) {
return (<div className={classes.frame} ref={this.divRef}
>
<div className={classes.redFrameL} />
<div className={classes.yellowFrameL} />
<div className={classes.greenFrameL} />
<div className={classes.indicatorL} />
</div>);
} else {
return (<div className={classes.frameStereo} ref={this.divRef}
>
<div className={classes.redFrameL} />
<div className={classes.yellowFrameL} />
<div className={classes.greenFrameL} />
<div className={classes.indicatorL} />
<div className={classes.redFrameR} />
<div className={classes.yellowFrameR} />
<div className={classes.greenFrameR} />
<div className={classes.indicatorR} />
</div>);
}
}
componentDidMount() {
this.addVuSubscription();
}
componentWillUnmount() {
this.removeVuSubscription();
}
}
);
export default VuMeter;
+13
View File
@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+15
View File
@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';