diff --git a/react/CMakeLists.txt b/react/CMakeLists.txt index c986a24..e4eb2f7 100644 --- a/react/CMakeLists.txt +++ b/react/CMakeLists.txt @@ -21,6 +21,7 @@ add_custom_command( WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}/react DEPENDS + src/SearchControl.tsx src/WifiChannel.tsx src/JackServerSettingsDialog.tsx src/JackServerSettings.tsx diff --git a/react/src/App.tsx b/react/src/App.tsx index 729755c..d0b8caa 100644 --- a/react/src/App.tsx +++ b/react/src/App.tsx @@ -86,6 +86,7 @@ const appStyles = ({ palette, spacing, mixins }: Theme) => createStyles({ zIndex: 2010 }, errorContent: { + display: "flex", flexDirection: "column", justifyContent: "center", diff --git a/react/src/LoadPluginDialog.tsx b/react/src/LoadPluginDialog.tsx index 7c7c65f..9b0737f 100644 --- a/react/src/LoadPluginDialog.tsx +++ b/react/src/LoadPluginDialog.tsx @@ -18,7 +18,6 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 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'; @@ -41,6 +40,8 @@ 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'; +import SearchControl from './SearchControl'; +import SearchFilter from './SearchFilter'; export type CloseEventHandler = () => void; @@ -154,9 +155,12 @@ interface PluginGridProps extends WithStyles { type PluginGridState = { selected_uri?: string, hover_uri?: string, + search_string: string; + search_collapsed: boolean; filterType: PluginType, client_width: number, client_height: number, + grid_cell_width: number, minimumItemWidth: number, } @@ -173,10 +177,12 @@ export const LoadPluginDialog = class extends ResizeResponsiveComponent { model: PiPedalModel; + searchInputRef: React.RefObject; constructor(props: PluginGridProps) { super(props); this.model = PiPedalModelFactory.getInstance(); + this.searchInputRef = React.createRef(); let filterType_ = PluginType.Plugin; // i.e. "Any". let persistedFilter = window.localStorage.getItem(FILTER_STORAGE_KEY); @@ -187,21 +193,68 @@ export const LoadPluginDialog = this.state = { selected_uri: this.props.uri, hover_uri: "", + search_string: "", + search_collapsed: true, filterType: filterType_, client_width: window.innerWidth, client_height: window.innerHeight, + grid_cell_width: this.getCellWidth(window.innerWidth), minimumItemWidth: props.minimumItemWidth ? props.minimumItemWidth : 220 }; this.updateWindowSize = this.updateWindowSize.bind(this); this.handleCancel = this.handleCancel.bind(this); this.handleOk = this.handleOk.bind(this); + this.handleSearchStringReady = this.handleSearchStringReady.bind(this); + this.handleKeyPress = this.handleKeyPress.bind(this); + } + + nominal_column_width: number = 250; + margin_reserve: number = 30; + + getCellWidth(width: number) + { + let gridWidth = width-this.margin_reserve; + let columns = Math.floor((gridWidth)/this.nominal_column_width); + if (columns < 1) columns = 1; + + return Math.floor(gridWidth/columns); + } + + handleKeyPress(e: React.KeyboardEvent) + { + let searchInput = this.searchInputRef.current; + if (searchInput && e.target !== searchInput) // if the search input doesn't have focus. + { + if (this.searchInputRef.current) // we do have one, right? + { + if (e.key.length === 1) + { + if (/[a-zA-Z0-9]/.exec(e.key)) // if it's alpha-numeric. + { + if (this.state.search_collapsed) // and search is collapsed. + { + e.preventDefault(); + + let newValue = searchInput.value + e.key; + searchInput.value = newValue; // add the key. to the value. + searchInput.focus(); + this.setState({ + search_string: newValue, + search_collapsed: false}) + + } + } + } + } + } } updateWindowSize() { this.setState({ client_width: window.innerWidth, - client_height: window.innerHeight + client_height: window.innerHeight, + grid_cell_width: this.getCellWidth(window.innerWidth) }); } componentDidMount() { @@ -214,6 +267,16 @@ export const LoadPluginDialog = window.removeEventListener('resize', this.updateWindowSize); } + componentDidUpdate(oldProps: PluginGridProps) { + if (oldProps.open !== this.props.open) + { + if (this.props.open) + { + this.setState({ search_string: "", search_collapsed: true}); + } + } + } + onWindowSizeChanged(width: number, height: number): void { super.onWindowSizeChanged(width, height); @@ -324,18 +387,48 @@ export const LoadPluginDialog = return result; } + applySearchFilter(plugins: UiPlugin[],searchString: string): UiPlugin[] + { + let results: { score: number; plugin: UiPlugin}[] = []; + let searchFilter = new SearchFilter(searchString); + + for (let i = 0; i < plugins.length; ++i) + { + let plugin = plugins[i]; + let score = searchFilter.score(plugin.name,plugin.plugin_display_type,plugin.author_name); + if (score !== 0) + { + results.push({score:score, plugin:plugin}); + } + } + results.sort((left: {score:number; plugin: UiPlugin},right: {score:number; plugin: UiPlugin}) => { + if (right.score < left.score) return -1; + if (right.score > left.score) return 1; + return left.plugin.name.localeCompare(right.plugin.name); + }); + let t: UiPlugin[] = []; + for (let i = 0; i < results.length; ++i) + { + t.push(results[i].plugin); + } + return t; + } filterPlugins(plugins: UiPlugin[]): ReactNode[] { let result: ReactNode[] = []; let filterType = this.state.filterType; let classes = this.props.classes; let rootClass = this.model.plugin_classes.get(); + if (this.state.search_string.length !== 0) + { + plugins = this.applySearchFilter(plugins,this.state.search_string); + } 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( ( - { this.onDoubleClick(e, value.uri) }} onClick={(e) => { this.onClick(e, value.uri) }} onMouseEnter={(e) => { this.handleMouseEnter(e, value.uri) }} @@ -358,7 +451,7 @@ export const LoadPluginDialog = - + ) ); @@ -366,6 +459,30 @@ export const LoadPluginDialog = } return result; } + changedSearchString? : string = undefined; + hSearchTimeout?: NodeJS.Timeout; + + handleSearchStringReady() { + if (this.changedSearchString !== undefined) + { + this.setState({search_string: this.changedSearchString}); + this.changedSearchString = undefined; + } + this.hSearchTimeout = undefined; + } + + + handleSearchStringChanged(text: string) : void { + if (this.hSearchTimeout) { + clearTimeout(this.hSearchTimeout); + this.hSearchTimeout = undefined; + } + this.changedSearchString = text; + this.hSearchTimeout = setTimeout( + this.handleSearchStringReady, + 2000); + + } render() { @@ -381,10 +498,17 @@ export const LoadPluginDialog = if (t) selectedPlugin = t; } + let showSearchIcon = true; + if (this.state.client_width < 500) + { + showSearchIcon = this.state.search_collapsed + } + return ( { this.handleKeyPress(e); }} fullScreen={true} TransitionComponent={Transition} maxWidth={false} @@ -393,16 +517,37 @@ export const LoadPluginDialog = onClose={this.handleCancel} style={{ overflowX: "hidden", overflowY: "hidden", display: "flex", flexDirection: "column", flexWrap: "nowrap" }} aria-labelledby="select-plugin-dialog-title"> - -
+ +
{ this.cancel(); }} style={{ flex: "0 0 auto" }} > - + {this.state.client_width > 520 ? "Select Plugin" : ""} -
+
+ { + this.handleSearchStringChanged(text); + }} + onClearFilterClick={()=> { + this.setState({search_collapsed: true,search_string:""}); + }} + onClick={()=> { + if (this.state.search_collapsed) { + this.setState({search_collapsed: false}); + } else { + this.setState({ search_collapsed: true, search_string: ""}); + } + + }} + /> +
+ { + this.props.onClearFilterClick(); + let inputRef = this.getInputRef(); + if (inputRef.current) { + inputRef.current.value = ""; + } + }} + edge="end" + > + + + + + )} + onChange={ + (e) => { + this.props.onTextChanged(e.target.value); + } + } + + /> + + +
+ ); + } + } +); + +export default SearchControl; diff --git a/react/src/SearchFilter.tsx b/react/src/SearchFilter.tsx new file mode 100644 index 0000000..d9de89b --- /dev/null +++ b/react/src/SearchFilter.tsx @@ -0,0 +1,73 @@ + + +let escapedCharacters = /[^a-zA-Z0-9]/g; + +function stringToRegex(text: string): RegExp +{ + text = text.replace(escapedCharacters,"\\$&"); + + return new RegExp(text,"i"); +} + + +class SearchFilter { + regexes: RegExp[]; + + constructor(text: string) + { + let regexes: RegExp[] = []; + + let bits = text.split(' '); + for (let bit of bits) + { + if (bit.length !== 0) + { + regexes.push(stringToRegex(bit)); + } + } + + + this.regexes = regexes; + } + + score(...strings: string[]): number { + let score = 0; + for (let r = 0; r < this.regexes.length; ++r) + { + let regex = this.regexes[r]; + regex.lastIndex = 0; + let matched = false; + for (let i = 0; i < strings.length; ++i) + { + let s = strings[i]; + let m = regex.exec(s); + if (m) { + matched = true; + if (m.index === 0) + { + if (r === 0 && i === 0) { + score += 16; + } else { + score += 8; + } + } + let end = m.index + m[0].length; + if (end === s.length || (end < s.length && s[end] === ' ')) // word boundary + { + score += 2; + } else { + score += 1; + } + break; + } + } + if (!matched) return 0; + } + + return score; + + } + +}; + +export default SearchFilter; \ No newline at end of file diff --git a/src/BeastServer.cpp b/src/BeastServer.cpp index 0189c33..0e4be84 100644 --- a/src/BeastServer.cpp +++ b/src/BeastServer.cpp @@ -97,6 +97,7 @@ namespace pipedal { class BeastServerImpl : public BeastServer { private: + int signalOnDone = -1; std::string address; int port = -1; std::filesystem::path rootPath; @@ -210,6 +211,9 @@ private: } void on_close(connection_hdl hdl) { + #ifndef NDEBUG + + #endif auto shThis = this->shared_from_this(); // we will destruct as we return. pServer->on_session_closed(shThis,hdl); } @@ -472,13 +476,19 @@ private: using websocketpp::lib::bind; using websocketpp::lib::placeholders::_1; using websocketpp::lib::placeholders::_2; + m_endpoint.set_open_handler(bind(&BeastServerImpl::on_open, this, _1)); m_endpoint.set_close_handler(bind(&BeastServerImpl::on_close, this, _1)); m_endpoint.set_http_handler(bind(&BeastServerImpl::on_http, this, _1)); + { + std::stringstream ss; + ss << "Listening on " << this->address << ":" << this->port; + Lv2Log::info(ss.str()); + } + std::stringstream ss; ss << port; - m_endpoint.listen(this->address, ss.str()); m_endpoint.start_accept(); @@ -494,11 +504,6 @@ private: // Start the ASIO io_service run loop - { - std::stringstream ss; - ss << "Listening on " << this->address << ":" << this->port; - Lv2Log::info(ss.str()); - } m_endpoint.run(); //ioc.run(); /****************** */ @@ -514,8 +519,11 @@ private: catch (websocketpp::exception const &e) { std::cout << e.what() << std::endl; - return; } + if (this->signalOnDone != -1) { + kill(getpid(),this->signalOnDone); + } + } static void ThreadProc(BeastServerImpl *server) @@ -565,12 +573,13 @@ public: this->pBgThread = nullptr; } - virtual void RunInBackground() + virtual void RunInBackground(int signalOnDone) { if (this->pBgThread != nullptr) { throw std::runtime_error("Bad state."); } + this->signalOnDone = signalOnDone; this->pBgThread = new std::thread(ThreadProc, this); } diff --git a/src/BeastServer.hpp b/src/BeastServer.hpp index d279055..d8c3c17 100644 --- a/src/BeastServer.hpp +++ b/src/BeastServer.hpp @@ -191,7 +191,8 @@ public: virtual void ShutDown(int timeoutMs) = 0; virtual void Join() = 0; - virtual void RunInBackground() = 0; + // signalOnDone: fire the specified POSIX signal when the service thread terminates. -1 for no signal. + virtual void RunInBackground(int signalOnDone = -1) = 0; }; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 87d85c6..0a219dc 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -72,7 +72,7 @@ endif() message (STATUS "Cxx flags: ${CMAKE_CXX_FLAGS}" ) -add_definitions(-DBOOST_ERROR_CODE_HEADER_ONLY) +add_definitions(-DBOOST_ERROR_CODE_HEADER_ONLY -DBOOST_BIND_GLOBAL_PLACEHOLDERS) set (PIPEDAL_SOURCES SysExec.cpp SysExec.hpp diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index 7942c15..b066c97 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -89,6 +89,13 @@ PiPedalModel::~PiPedalModel() #include + +void PiPedalModel::LoadLv2PluginInfo(const PiPedalConfiguration&configuration) +{ + lv2Host.Load(configuration.GetLv2Path().c_str()); + +} + void PiPedalModel::Load(const PiPedalConfiguration &configuration) { @@ -112,7 +119,7 @@ void PiPedalModel::Load(const PiPedalConfiguration &configuration) s << "Unable to load " << pluginClassesPath << ". " << e.what(); throw PiPedalException(s.str().c_str()); } - lv2Host.Load(configuration.GetLv2Path().c_str()); + // lv2Host.Load(configuration.GetLv2Path().c_str()); this->pedalBoard = storage.GetCurrentPreset(); // the current *saved* preset. diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index 6339b80..6c705b2 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -127,6 +127,7 @@ public: virtual ~PiPedalModel(); void Close(); + void LoadLv2PluginInfo(const PiPedalConfiguration&configuration); void Load(const PiPedalConfiguration&configuration); const Lv2Host& getPlugins() const { return lv2Host; } diff --git a/src/SetWifiConfig.cpp b/src/SetWifiConfig.cpp index c041123..d9bec7b 100644 --- a/src/SetWifiConfig.cpp +++ b/src/SetWifiConfig.cpp @@ -156,7 +156,7 @@ void pipedal::SetWifiConfig(const WifiConfigSettings&settings) dhcpcd.InsertLine(line++,"interface wlan0"); dhcpcd.InsertLine(line++," static ip_address=" PIPEDAL_NETWORK); dhcpcd.InsertLine(line++," nohook wpa_supplicant"); - dhcpcd.Save(dhcpcdConfig);adding + dhcpcd.Save(dhcpcdConfig); } // ***** save the config files *** diff --git a/src/main.cpp b/src/main.cpp index 1a7471e..637454d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -66,8 +66,11 @@ public: static volatile bool g_SigBreak = false; void sig_handler(int signo) { - g_SigBreak = true; - sem_post(&signalSemaphore); + if (!g_SigBreak) + { + g_SigBreak = true; + sem_post(&signalSemaphore); + } } using namespace boost::system; @@ -465,6 +468,7 @@ int main(int argc, char *argv[]) Lv2Log::set_logger(MakeLv2SystemdLogger()); } signal(SIGTERM, sig_handler); + signal(SIGUSR1, sig_handler); std::filesystem::path doc_root = parser.Arguments()[0]; std::filesystem::path web_root = doc_root; @@ -548,10 +552,13 @@ int main(int argc, char *argv[]) } } } - Lv2Log::info("Waiting for jack service."); - // pre-cache device info. + // pre-cache device info before we let audio services run. model.GetAlsaDevices(); + + // Get heavy IO out of the way before letting dependent (Jack) services run. + model.LoadLv2PluginInfo(configuration); + // Tell systemd we're done. sd_notify(0, "READY=1"); if (!isJackServiceRunning()) @@ -570,9 +577,14 @@ int main(int argc, char *argv[]) break; } } - Lv2Log::info("Found Jack service."); } - sleep(3); // jack needs a little time to get up to speed. + if (isJackServiceRunning()) + { + Lv2Log::info("Found Jack service."); + sleep(3); // jack needs a little time to get up to speed. + } else { + Lv2Log::info("Jack service not started."); + } } model.Load(configuration); @@ -587,7 +599,7 @@ int main(int argc, char *argv[]) std::shared_ptr downloadIntercept = std::make_shared(&model); server->AddRequestHandler(downloadIntercept); - server->RunInBackground(); + server->RunInBackground(SIGUSR1); sem_wait(&signalSemaphore); diff --git a/src/template.service b/src/template.service index dbbcc82..6196d99 100644 --- a/src/template.service +++ b/src/template.service @@ -15,6 +15,7 @@ ExecStart=${COMMAND} User=pipedal_d Group=pipedal_d Restart=always +TimeoutStartSec=60 RestartSec=25 TimeoutStopSec=10 WorkingDirectory=/var/pipedal