// Copyright (c) 2025 Robin Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of // the Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS // FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR // COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER // IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import React from 'react'; import { Theme } from '@mui/material/styles'; import ToobFrequencyResponseView, { FREQUENCY_RESPONSE_VECTOR_URI } from './ToobFrequencyResponseView'; import WithStyles from './WithStyles'; import { createStyles } from './WithStyles'; import SvgPathBuilder from './SvgPathBuilder'; import CircleUpIcon from './svg/expand_circle_up_24dp.svg?react'; import CircleDownIcon from './svg/expand_circle_down_24dp.svg?react'; import { withStyles } from "tss-react/mui"; import DialogEx from './DialogEx'; import DialogContent from '@mui/material/DialogContent'; import IControlViewFactory from './IControlViewFactory'; import { PiPedalModelFactory, PiPedalModel, State } from "./PiPedalModel"; import { PedalboardItem } from './Pedalboard'; import PluginControlView, { ICustomizationHost, ControlGroup, ControlViewCustomization } from './PluginControlView'; import { UiPlugin } from './Lv2Plugin'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton/IconButton'; import { css } from '@emotion/react'; const styles = (theme: Theme) => createStyles({ menuIcon: css({ fill: (theme.palette.text.primary + "!important"), //theme.palette.text.primary, opacity: 0.6 }), upIcon: css({ fill: (theme.palette.text.primary + "!important"), //theme.palette.text.primary, opacity: 0.6, alignSelf: "flex-start" }), }); interface Point { x: number, y: number }; function offsetFromEnd(pt0: Point, pt1: Point, distance: number) { let dx = pt1.x - pt0.x; let dy = pt1.y - pt0.y; let mag = Math.sqrt(dx * dx + dy * dy); let normX = dx / mag; let normY = dy / mag; return { x: pt1.x - normX * distance, y: pt1.y - normY * distance } } const getWindowSize = () => { return { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, }; }; interface UnpackedControls { eq: React.ReactNode; filters_loCut: React.ReactNode; filters_hiCut: React.ReactNode; low_level: React.ReactNode; low_freq: React.ReactNode; lmf_level: React.ReactNode; lmf_freq: React.ReactNode; lmf_q: React.ReactNode; hmf_level: React.ReactNode; hmf_freq: React.ReactNode; hmf_q: React.ReactNode; hi_level: React.ReactNode; hi_freq: React.ReactNode; gain: React.ReactNode; } interface ToobParametricEqViewProps extends WithStyles { instanceId: number; item: PedalboardItem; } interface ToobParametricEqViewState { windowSize: { width: number, height: number }; maximized: boolean; } const ToobParametricEqView = withStyles( class extends React.Component implements ControlViewCustomization { model: PiPedalModel; customizationId: number = 943; constructor(props: ToobParametricEqViewProps) { super(props); this.handleResize = this.handleResize.bind(this); this.handleConnectionStateChanged = this.handleConnectionStateChanged.bind(this); this.model = PiPedalModelFactory.getInstance(); this.state = { windowSize: getWindowSize(), maximized: false } let pluginInfo: UiPlugin | null = this.model.getUiPlugin(this.props.item.uri); if (pluginInfo === null) { throw new Error("Plugin not fouund."); } } fullScreen() { return true; } horizontalScrollControls(host: ICustomizationHost, controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { // Implement horizontal scrolling behavior for tiny horizontal screens controls[0] = (
{controls[0] as React.ReactNode}
); return controls; } unpackControls(host: ICustomizationHost, controls: (React.ReactNode | ControlGroup)[]): UnpackedControls { return { eq: controls[0] as React.ReactNode, filters_loCut: (controls[1] as ControlGroup).controls[0], filters_hiCut: (controls[1] as ControlGroup).controls[1], low_level: (controls[2] as ControlGroup).controls[0], low_freq: (controls[2] as ControlGroup).controls[1], lmf_level: (controls[3] as ControlGroup).controls[0], lmf_freq: (controls[3] as ControlGroup).controls[1], lmf_q: (controls[3] as ControlGroup).controls[2], hmf_level: (controls[4] as ControlGroup).controls[0], hmf_freq: (controls[4] as ControlGroup).controls[1], hmf_q: (controls[4] as ControlGroup).controls[2], hi_level: (controls[5] as ControlGroup).controls[0], hi_freq: (controls[5] as ControlGroup).controls[1], gain: controls[6] as React.ReactNode }; } absolutePosition(x: number, y: number, el: React.ReactNode) { return (
{el}
); } private ROW_HEIGHT = 140; private BORDER_HEIGHT = 350; private DIAL_WIDTH = 63; private GRAPH_WIDTH = this.DIAL_WIDTH * 2; private smoothPath(pt0: Point, pt1: Point, pt2: Point, pt3: Point, curveRadius: number): string { let pathBuilder = new SvgPathBuilder(); let ctl01 = offsetFromEnd(pt0, pt1, curveRadius); let ctl02 = offsetFromEnd(pt0, pt1, curveRadius / 3); let ctl03 = offsetFromEnd(pt2, pt1, curveRadius / 3); let ctl04 = offsetFromEnd(pt2, pt1, curveRadius); let ctl11 = offsetFromEnd(pt1, pt2, curveRadius); let ctl12 = offsetFromEnd(pt1, pt2, curveRadius / 3); let ctl13 = offsetFromEnd(pt3, pt2, curveRadius / 3); let ctl14 = offsetFromEnd(pt3, pt2, curveRadius); pathBuilder.moveTo(pt0.x, pt0.y); pathBuilder.lineTo(ctl01.x, ctl01.y); pathBuilder.bezierTo( ctl02.x, ctl02.y, ctl03.x, ctl03.y, ctl04.x, ctl04.y ); pathBuilder.lineTo(ctl11.x, ctl11.y); pathBuilder.bezierTo( ctl12.x, ctl12.y, ctl13.x, ctl13.y, ctl14.x, ctl14.y ); pathBuilder.lineTo(pt3.x, pt3.y); return pathBuilder.toString(); } horizontalCompactControls(host: ICustomizationHost, controls: UnpackedControls) { let height = this.BORDER_HEIGHT; let row0 = height / 2 - this.ROW_HEIGHT; let row1 = row0 + this.ROW_HEIGHT; let borderTop = 0; let borderBottom = height - 8; let labelTop = row0 - 30; let labelBottom = row1 + this.ROW_HEIGHT - 5; let colPos = (group: number, col: number) => this.DIAL_WIDTH * (0.3 * group + col); let cpos0 = (group: number, col: number, el: React.ReactNode) => this.absolutePosition(colPos(group, col), row0, el); let cpos1 = (group: number, col: number, el: React.ReactNode) => this.absolutePosition(colPos(group, col) + this.DIAL_WIDTH / 2, row1, el); let label1 = (y: number, group: number, col0: number, col1: number, text: string, align: "left" | "center" | "right") => { let left = colPos(group, col0); let right = colPos(group, col1); return ( {text} ); }; let divider = (group: number, col0: number, y0: number, col1: number, y1: number) => { let x0 = colPos(group, col0); let x1 = colPos(group, col1); return ( ); }; let leftDivider = (group: number, col0: number, y0: number, col1: number, y1: number) => { let x0 = colPos(group, col0); let x1 = colPos(group, col1); let xOrg = Math.min(x0, x1) - 2; let yOrg = Math.min(y0, y1) - 2; let width = Math.abs(x1 - x0) + 4; let height = Math.abs(y1 - y0) + 4; let bezierLength = 10; let pt0 = { x: x0, y: y0 }; let pt1 = { x: x0, y: row1 - 18 }; let pt2 = { x: x1, y: row1 + 3 }; let pt3 = { x: x1, y: y1 }; let path = this.smoothPath(pt0, pt1, pt2, pt3, bezierLength); return ( ); }; let graph = ( ); return [(
{label1(labelBottom, 1, 2, 3.0, "Lo Shelf", "center")} {label1(labelTop, 2, 3, 5, "Low Mid", "center")} {label1(labelBottom, 3, 4.5, 6.5, "High Mid", "center")} {label1(labelTop, 4, 6.5, 7.5, "Hi Shelf", "center")} {divider(1, 2.0, borderTop, 2.0, borderBottom)} {divider(5, 7.5, borderTop, 7.5, borderBottom)} {leftDivider(2, 3.0, borderTop, 3.0, borderBottom)} {leftDivider(3, 5.0, borderTop, 4.5, borderBottom)} {leftDivider(4, 6.5, borderTop, 6.5, borderBottom)} {this.absolutePosition(0, row0, graph)} {this.absolutePosition(colPos(0, 0), row1, controls.filters_loCut)} {this.absolutePosition(colPos(0, 1), row1, controls.filters_hiCut)} {cpos0(1, 2, controls.low_level)} {cpos1(1, 2 - 0.5, controls.low_freq)} {cpos0(2, 3.0, controls.lmf_q)} {cpos1(2, 2.75, controls.lmf_freq)} {cpos0(2, 4, controls.lmf_level)} {cpos1(3, 4, controls.hmf_freq)} {cpos0(3, 5.25, controls.hmf_level)} {cpos1(3, 5, controls.hmf_q)} {cpos0(4, 6.5, controls.hi_level)} {cpos1(4, 6, controls.hi_freq)} {cpos0(5, 7.5, controls.gain)}
)]; } verticalCompactControls(host: ICustomizationHost, controls: UnpackedControls) { let width = 380; let graph = ( ); let divider = () => { return (
); } let vDivider = () => { return (
); } let panel = (label: string, controls: React.ReactNode[]) => { return (
{controls.map((ctl, i) => { return (
{ctl}
); })}
{label}
); } return [(
{graph} {(
)} {panel("", [ controls.filters_loCut, controls.filters_hiCut, ])} {panel("", [ controls.gain ])} {divider()} {panel("Low", [controls.low_level, controls.low_freq ]) } {vDivider()} {panel("Low Mid", [ controls.lmf_level, controls.lmf_freq, controls.lmf_q, ]) } {divider()} {panel("High Mid", [ controls.hmf_level, controls.hmf_freq, controls.hmf_q, ]) } {vDivider()} {panel("High", [controls.hi_level, controls.hi_freq ]) }
)]; } tinyControls(host: ICustomizationHost, controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { const classes = withStyles.getClasses(this.props); let isLandscape = host.isLandscapeGrid(); let panelControls: React.ReactNode[] = []; let filterGroup = controls[1] as ControlGroup; let miscGroup: ControlGroup = { name: "", indexes: [], controls: [ filterGroup.controls[0], filterGroup.controls[1], controls[controls.length - 1] as React.ReactNode ] }; panelControls.push(host.renderControlGroup(miscGroup, "vtc_miscGroup")); if (!isLandscape) { panelControls.push( (
) ); } for (let i = 2; i < controls.length - 1; ++i) { let c = controls[i]; if (c instanceof ControlGroup) { panelControls.push( host. renderControlGroup(c as ControlGroup, "vtc_cg" + i) ); } else { panelControls.push(c as React.ReactNode); } } let panelStyle: React.CSSProperties; let scrollStyle: React.CSSProperties; let frameStyle: React.CSSProperties; if (isLandscape) { panelStyle = { display: "flex", flexDirection: "row", alignItems: "stretch", width: "100%" }; scrollStyle = { flex: "1 1 auto", overflowX: "auto", overflowY: "hidden", whiteSpace: "nowrap" }; frameStyle = { display: "flex", flexFlow: "row nowrap", alignItems: "center", paddingTop: 8 }; } else { panelStyle = { display: "flex", flexDirection: "column", alignItems: "stretch", height: "100%" }; scrollStyle = { flex: "1 1 auto", overflowX: "hidden", overflowY: "auto", whiteSpace: "nowrap" }; frameStyle = { display: "flex", flexFlow: "row wrap", alignItems: "left", justifyContent: "left", gap: 12, paddingTop: 24, paddingBottom: 48 }; } return [(
{host.isLandscapeGrid() ? (
{ e.stopPropagation(); this.setState({ maximized: true }); }}>
{controls[0] as React.ReactNode}
) : (
{controls[0] as React.ReactNode}
{ e.stopPropagation(); this.setState({ maximized: true }); }}>
)}
{panelControls}
)]; } private dialogRef: HTMLDivElement | null = null; private panelRef: HTMLDivElement | null = null; dlgTickInAnimation(yOffset: number, startTime: number) { let elapsed = Date.now() - startTime; let pct = elapsed / 200; if (pct > 1) pct = 1; if (!this.dialogRef) { return; } this.dialogRef.style.opacity = `${pct}`; this.dialogRef.style.transform = `translateY(${(1 - pct) * yOffset}px)`; if (pct >= 1) { return; } window.requestAnimationFrame(() => { this.dlgTickInAnimation(yOffset, startTime); }); } dlgTickOutAnimation(yOffset: number, startTime: number) { if (!this.dialogRef || !this.panelRef) { this.setState({ maximized: false }); return; } let elapsed = Date.now() - startTime; let pct = elapsed / 200; if (pct > 1) pct = 1; if (!this.dialogRef) { return; } this.dialogRef.style.transform = `translateY(${(pct) * yOffset}px)`; this.dialogRef.style.opacity = `${1 - pct}`; if (pct >= 1) { this.setState({ maximized: false }); return; } window.requestAnimationFrame(() => { this.dlgTickOutAnimation(yOffset, startTime); }); } dlgSlideInAnimation(yOffset: number) { if (!this.animateDialog) { return; } this.animateDialog = false; window.requestAnimationFrame(() => { if (this.dialogRef) { let startTime = Date.now(); this.dlgTickInAnimation(yOffset, startTime); } }); } dlgSlideOutAnimation(yOffset: number) { if (!this.animateDialog) { return; } this.animateDialog = false; window.requestAnimationFrame(() => { if (this.dialogRef) { let startTime = Date.now(); this.dlgTickInAnimation(yOffset, startTime); } }); } startSlideOutAnimation() { if (this.panelRef && this.dialogRef) { let yOffset = this.panelRef.getBoundingClientRect().top; window.requestAnimationFrame(() => { if (this.dialogRef && this.dialogRef) { let startTime = Date.now(); this.dlgTickOutAnimation(yOffset, startTime); } }); } else { this.setState({ maximized: false }); } } checkTransitionStart() { if (this.dialogRef && this.panelRef) { let panelRect = this.panelRef.getBoundingClientRect(); this.dlgSlideInAnimation(panelRect.top); } } setDialogRef(ref: HTMLDivElement | null) { this.dialogRef = ref; this.checkTransitionStart(); }; setPanelRef(ref: HTMLDivElement | null) { this.panelRef = ref; this.checkTransitionStart(); } modifyControls(host: ICustomizationHost, controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { const classes = withStyles.getClasses(this.props); let unpackedControls = this.unpackControls(host, controls); if (this.state.maximized) { return [(
{ this.setPanelRef(instance); }} style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}> this.setState({ maximized: false })} onClose={() => this.setState({ maximized: false })} fullScreen={true} tag="peq_dialog" dlgRef={(instance: HTMLDivElement | null): void => { this.setDialogRef(instance); }} > {this.state.windowSize.width > this.state.windowSize.height ? (
{ e.stopPropagation(); this.startSlideOutAnimation(); }} style={{ paddingTop: 48, paddingLeft: 8, paddingRight: 8 }}>
{this.horizontalCompactControls(host, unpackedControls)}
) : (
{ e.stopPropagation(); this.startSlideOutAnimation(); }} style={{ paddingLeft: 8, paddingTop: 8, paddingBottom: 8 }}>
{this.verticalCompactControls(host, unpackedControls)}
) }
)]; } if (this.state.windowSize.width > 750 && this.state.windowSize.height > 600) { return this.horizontalCompactControls(host, unpackedControls); } return this.tinyControls(host, controls); } private handleConnectionStateChanged(state: State) { if (state === State.Ready) { this.unsubscribe(); this.subscribe(); } } private handleResize() { this.setState({ windowSize: getWindowSize() }); } subscribe() { this.subscribedId = this.props.instanceId; } unsubscribe() { } componentDidMount() { if (super.componentDidMount) { super.componentDidMount(); } this.subscribe(); this.model.state.addOnChangedHandler(this.handleConnectionStateChanged); window.addEventListener("resize", this.handleResize); } componentWillUnmount() { this.unsubscribe(); if (super.componentWillUnmount) { super.componentWillUnmount(); } this.model.state.removeOnChangedHandler(this.handleConnectionStateChanged); window.removeEventListener("resize", this.handleResize); } private subscribedId: number | null = null; private animateDialog: boolean = false; componentDidUpdate(oldProps: ToobParametricEqViewProps, oldState: ToobParametricEqViewState) { if (this.props.instanceId !== this.subscribedId) { this.unsubscribe(); this.subscribe(); } this.animateDialog = this.state.maximized != oldState.maximized; } render() { return ( { }} />); } }, styles ); class ToobParametricEqViewFactory implements IControlViewFactory { uri: string = "http://two-play.com/plugins/toob-parametric-eq"; Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode { return (); } }; export default ToobParametricEqViewFactory; class ToobParametricEqViewFactoryStereo implements IControlViewFactory { uri: string = "http://two-play.com/plugins/toob-parametric-eq-stereo"; Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode { return (); } }; export { ToobParametricEqViewFactoryStereo };