diff --git a/ChangeLog.md b/ChangeLog.md index 0b3ae160..f56b5f25 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,7 @@ # Changelog: +* **11.08.20** + - Fixed the voice push to talk delay + * **09.08.20** - Added a "watch to gather" context menu entry for clients - Disassembled the current client icon sprite into his icons diff --git a/shared/img/icon_settings_loading.svg b/shared/img/icon_settings_loading.svg index 9f535540..d185fa84 100644 --- a/shared/img/icon_settings_loading.svg +++ b/shared/img/icon_settings_loading.svg @@ -1 +1,19 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/shared/js/ui/client.ts b/shared/js/ui/client.ts index 0c9d7d1c..a03d7281 100644 --- a/shared/js/ui/client.ts +++ b/shared/js/ui/client.ts @@ -994,7 +994,7 @@ export class LocalClientEntry extends ClientEntry { renameSelf(new_name: string) : Promise { const old_name = this.properties.client_nickname; this.updateVariables({ key: "client_nickname", value: new_name }); /* change it locally */ - return this.handle.serverConnection.command_helper.updateClient("client_nickname", new_name).then((e) => { + return this.handle.serverConnection.send_command("clientupdate", { client_nickname: new_name }).then((e) => { settings.changeGlobal(Settings.KEY_CONNECT_USERNAME, new_name); this.channelTree.client.log.log(EventType.CLIENT_NICKNAME_CHANGED_OWN, { client: this.log_data(), diff --git a/shared/js/ui/modal/ModalNewcomer.ts b/shared/js/ui/modal/ModalNewcomer.ts index 4ab4eb06..cefea293 100644 --- a/shared/js/ui/modal/ModalNewcomer.ts +++ b/shared/js/ui/modal/ModalNewcomer.ts @@ -294,8 +294,8 @@ function initializeStepIdentity(tag: JQuery, event_registry: Registry, modal: Modal) { const microphone_events = new Registry(); //microphone_events.enable_debug("settings-microphone"); - modal_settings.initialize_audio_microphone_controller(microphone_events); - modal_settings.initialize_audio_microphone_view(tag, microphone_events); + //modal_settings.initialize_audio_microphone_controller(microphone_events); + //modal_settings.initialize_audio_microphone_view(tag, microphone_events); modal.close_listener.push(() => microphone_events.fire_async("deinitialize")); let help_animation_done = false; diff --git a/shared/js/ui/modal/ModalSettings.tsx b/shared/js/ui/modal/ModalSettings.tsx index c37caf9d..1a221047 100644 --- a/shared/js/ui/modal/ModalSettings.tsx +++ b/shared/js/ui/modal/ModalSettings.tsx @@ -32,6 +32,8 @@ import {KeyMapSettings} from "tc-shared/ui/modal/settings/Keymap"; import * as React from "react"; import * as ReactDOM from "react-dom"; import {NotificationSettings} from "tc-shared/ui/modal/settings/Notifications"; +import {initialize_audio_microphone_controller, MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/Microphone"; +import {MicrophoneSettings} from "tc-shared/ui/modal/settings/MicrophoneRenderer"; export function spawnSettingsModal(default_page?: string) : Modal { let modal: Modal; @@ -443,12 +445,16 @@ function settings_general_chat(container: JQuery, modal: Modal) { } function settings_audio_microphone(container: JQuery, modal: Modal) { - const registry = new Registry(); - registry.enableDebug("settings-microphone"); - modal_settings.initialize_audio_microphone_controller(registry); - modal_settings.initialize_audio_microphone_view(container, registry); + const registry = new Registry(); + initialize_audio_microphone_controller(registry); + + const entry = ; + ReactDOM.render(entry, container[0]); + modal.close_listener.push(() => { + ReactDOM.unmountComponentAtNode(container[0]); + registry.fire("notify_destroy"); + }); - modal.close_listener.push(() => registry.fire_async("deinitialize")); return; } @@ -1698,699 +1704,6 @@ export namespace modal_settings { event_registry.fire("select-profile", { profile_id: "default" }); event_registry.fire("select-identity-type", { profile_id: "default", identity_type: undefined }); } - - export function initialize_audio_microphone_controller(event_registry: Registry) { - /* level meters */ - { - const level_meters: {[key: string]:Promise} = {}; - const level_info: {[key: string]:any} = {}; - let level_update_task; - - const destroy_meters = () => { - Object.keys(level_meters).forEach(e => { - const meter = level_meters[e]; - delete level_meters[e]; - - meter.then(e => e.destory()); - }); - Object.keys(level_info).forEach(e => delete level_info[e]); - }; - - const update_level_meter = () => { - destroy_meters(); - - for(const device of arecorder.devices()) { - let promise = arecorder.create_levelmeter(device).then(meter => { - meter.set_observer(level => { - if(level_meters[device.unique_id] !== promise) return; /* old level meter */ - - level_info[device.unique_id] = { - device_id: device.unique_id, - status: "success", - level: level - }; - }); - return Promise.resolve(meter); - }).catch(error => { - if(level_meters[device.unique_id] !== promise) return; /* old level meter */ - level_info[device.unique_id] = { - device_id: device.unique_id, - status: "error", - - error: error - }; - - log.warn(LogCategory.AUDIO, tr("Failed to initialize a level meter for device %s (%s): %o"), device.unique_id, device.driver + ":" + device.name, error); - return Promise.reject(error); - }); - level_meters[device.unique_id] = promise; - } - }; - - level_update_task = setInterval(() => { - event_registry.fire("update-device-level", { - devices: Object.keys(level_info).map(e => level_info[e]) - }); - }, 50); - - event_registry.on("query-device-result", event => { - if(event.status !== "success") return; - - update_level_meter(); - }); - - event_registry.on("deinitialize", event => { - destroy_meters(); - clearInterval(level_update_task); - }); - } - - /* device list */ - { - event_registry.on("query-devices", event => { - Promise.resolve().then(() => { - return arecorder.device_refresh_available() && event.refresh_list ? arecorder.refresh_devices() : Promise.resolve(); - }).catch(error => { - log.warn(LogCategory.AUDIO, tr("Failed to refresh device list: %o"), error); - return Promise.resolve(); - }).then(() => { - const devices = arecorder.devices(); - - event_registry.fire_async("query-device-result", { - status: "success", - active_device: default_recorder.current_device() ? default_recorder.current_device().unique_id : "none", - devices: devices.map(e => { return { id: e.unique_id, name: e.name, driver: e.driver }}) - }); - }); - }); - - event_registry.on("set-device", event => { - const device = arecorder.devices().find(e => e.unique_id === event.device_id); - if(!device && event.device_id !== "none") { - event_registry.fire_async("set-device-result", { status: "error", error: tr("Invalid device id"), device_id: event.device_id }); - return; - } - - default_recorder.set_device(device).then(() => { - console.debug(tr("Changed default microphone device")); - event_registry.fire_async("set-device-result", { status: "success", device_id: event.device_id }); - }).catch((error) => { - log.warn(LogCategory.AUDIO, tr("Failed to change microphone to device %s: %o"), device ? device.unique_id : "none", error); - event_registry.fire_async("set-device-result", { status: "success", device_id: event.device_id }); - }); - }); - } - - /* settings */ - { - event_registry.on("query-settings", event => { - event_registry.fire_async("query-settings-result", { - status: "success", - info: { - volume: default_recorder.get_volume(), - vad_type: default_recorder.get_vad_type(), - vad_ppt: { - key: default_recorder.get_vad_ppt_key(), - release_delay: Math.abs(default_recorder.get_vad_ppt_delay()), - release_delay_active: default_recorder.get_vad_ppt_delay() >= 0 - }, - vad_threshold: { - threshold: default_recorder.get_vad_threshold() - } - } - }); - }); - - event_registry.on("set-setting", event => { - const ensure_type = (type: "object" | "string" | "boolean" | "number" | "undefined") => { - if(typeof event.value !== type) { - event_registry.fire_async("set-setting-result", { status: "error", error: tr("Invalid value type for key") + " (expected: " + type + ", received: " + typeof event.value + ")", setting: event.setting }); - return false; - } - return true; - }; - - switch (event.setting) { - case "volume": - if(!ensure_type("number")) return; - default_recorder.set_volume(event.value); - break; - - case "threshold-threshold": - if(!ensure_type("number")) return; - default_recorder.set_vad_threshold(event.value); - break; - - case "vad-type": - if(!ensure_type("string")) return; - if(!default_recorder.set_vad_type(event.value)) { - event_registry.fire_async("set-setting-result", { status: "error", error: tr("Unknown VAD type"), setting: event.setting }); - return; - } - break; - - case "ppt-key": - if(!ensure_type("object")) return; - default_recorder.set_vad_ppt_key(event.value); - break; - - case "ppt-release-delay": - if(!ensure_type("number")) return; - const sign = default_recorder.get_vad_ppt_delay() >= 0 ? 1 : -1; - default_recorder.set_vad_ppt_delay(sign * event.value); - break; - - case "ppt-release-delay-active": - if(!ensure_type("boolean")) return; - default_recorder.set_vad_ppt_delay(Math.abs(default_recorder.get_vad_ppt_delay()) * (event.value ? 1 : -1)); - break; - - default: - event_registry.fire_async("set-setting-result", { status: "error", error: tr("Invalid setting key"), setting: event.setting }); - return; - } - event_registry.fire_async("set-setting-result", { status: "success", setting: event.setting, value: event.value }); - }); - } - - aplayer.on_ready(() => event_registry.fire_async("audio-initialized", {})); - } - export function initialize_audio_microphone_view(container: JQuery, event_registry: Registry) { - /* device list */ - { - /* actual list */ - { - const container_devices = container.find(".container-devices"); - const volume_bar_tags: {[key: string]:{ volume: JQuery, error: JQuery }} = {}; - let pending_changes = 0; - let default_device_id; - - const build_device = (device: { id: string, name: string, driver: string }, selected: boolean) => { - let tag_volume: JQuery, tag_volume_error: JQuery; - const tag = $.spawn("div").attr("device-id", device ? device.id : "none").addClass("device").toggleClass("selected", selected).append( - $.spawn("div").addClass("container-selected").append( - $.spawn("div").addClass("icon_em client-apply"), - $.spawn("div").addClass("icon-loading").append( - $.spawn("img").attr("src", "img/icon_settings_loading.svg") - ) - ), - $.spawn("div").addClass("container-name").append( - $.spawn("div").addClass("device-driver").text( - device ? (device.driver || "Unknown driver") : "No device" - ), - $.spawn("div").addClass("device-name").text( - device ? (device.name || "Unknown name") : "No device" - ), - ), - $.spawn("div").addClass("container-activity").append( - $.spawn("div").addClass("container-activity-bar").append( - tag_volume = $.spawn("div").addClass("bar-hider"), - tag_volume_error = $.spawn("div").addClass("bar-error") - ) - ) - ); - tag_volume.css('width', '100%'); /* initially hide the bar */ - if(device) - volume_bar_tags[device.id] = { volume: tag_volume, error: tag_volume_error }; - - tag.on('click', event => { - if(tag.hasClass("selected") || pending_changes > 0) return; - - event_registry.fire("set-device", { device_id: device ? device.id : "none" }); - }); - - return tag; - }; - - event_registry.on("set-device", event => { - pending_changes++; - - const default_device = container_devices.find(".selected"); - default_device_id = default_device.attr("device-id"); - default_device.removeClass("selected"); - - const new_device = container_devices.find(".device[device-id='" + event.device_id + "']"); - new_device.addClass("loading"); - }); - event_registry.on("set-device-result", event => { - pending_changes--; - container_devices.find(".loading").removeClass("loading"); - - if(event.status !== "success") { - createErrorModal(tr("Failed to change microphone"), formatMessage(tr("Failed to change the microphone to the target microphone{:br:}{}"), event.status === "timeout" ? tr("Timeout") : event.error || tr("Unknown error"))).open(); - } else { - default_device_id = event.device_id; - } - - container_devices.find(".device[device-id='" + default_device_id + "']").addClass("selected"); - }); - - event_registry.on('query-devices', event => { - Object.keys(volume_bar_tags).forEach(e => delete volume_bar_tags[e]); - container_devices.find(".device").remove(); - container_devices.find(".overlay").hide(); - container_devices.find(".overlay.overlay-loading").show(); - }); - - event_registry.on("query-device-result", event => { - container_devices.find(".device").remove(); - container_devices.find(".overlay").hide(); - - if(event.status !== "success") { - const container_text = container_devices.find(".overlay.overlay-error").show().find(".error-text"); - container_text.text(event.status === "timeout" ? tr("Timeout while loading") : event.error || tr("An unknown error happened")); - return; - } - - build_device(undefined, event.active_device === "none").appendTo(container_devices); - for(const device of event.devices) - build_device(device, event.active_device === device.id).appendTo(container_devices); - }); - - event_registry.on("update-device-level", event => { - for(const device of event.devices) { - const tags = volume_bar_tags[device.device_id]; - if(!tags) continue; - - let level = typeof device.level === "number" ? device.level : 0; - if(level > 100) level = 100; - else if(level < 0) level = 0; - tags.error.attr('title', device.error || null).text(device.error || null); - tags.volume.css('width', (100 - level) + '%'); - } - }); - } - - /* device list update button */ - { - - const button_update = container.find(".button-update"); - event_registry.on(["query-devices", "set-device"], event => button_update.prop("disabled", true)); - event_registry.on(["query-device-result", "set-device-result"], event => button_update.prop("disabled", false)); - - button_update.on("click", event => event_registry.fire("query-devices", { refresh_list: true })); - } - } - - /* settings */ - { - /* TODO: Query settings error handling */ - - /* volume */ - { - const container_volume = container.find(".container-volume"); - const slider_tag = container_volume.find(".container-slider"); - let triggered_events = 0; - let last_value = -1; - - const slider = sliderfy(slider_tag, { - min_value: 0, - max_value: 100, - step: 1, - initial_value: 0 - }); - - slider_tag.on('change', event => { - const value = parseInt(slider_tag.attr("value")); - if(last_value === value) return; - - triggered_events++; - event_registry.fire("set-setting", { setting: "volume", value: value }); - }); - - event_registry.on("query-settings-result", event => { - if(event.status !== "success") return; - - last_value = event.info.volume; - slider.value(event.info.volume); - }); - - event_registry.on("set-setting-result", event => { - if(event.setting !== "volume") return; - if(triggered_events > 0) { - triggered_events--; - return; - } - if(event.status !== "success") return; - - last_value = event.value; - slider.value(event.value); - }); - } - - /* vad type */ - { - const container_select = container.find(".container-select-vad"); - let last_value; - - container_select.find("input").on('change', event => { - if(!(event.target as HTMLInputElement).checked) - return; - - const mode = (event.target as HTMLInputElement).value; - if(mode === last_value) return; - - event_registry.fire("set-setting", { setting: "vad-type", value: mode }); - }); - - const select_vad_type = type => { - let elements = container_select.find('input[value="' + type + '"]'); - if(elements.length < 1) - elements = container_select.find('input[value]'); - elements.first().trigger('click'); - }; - - event_registry.on("query-settings-result", event => { - if(event.status !== "success") return; - - last_value = event.info.vad_type; - select_vad_type(event.info.vad_type); - }); - - event_registry.on("set-setting-result", event => { - if(event.setting !== "vad-type") return; - if(event.status !== "success") { - createErrorModal(tr("Failed to change setting"), formatMessage(tr("Failed to change vad type{:br:}{}"), event.status === "timeout" ? tr("Timeout") : event.error || tr("Unknown error"))).open(); - } else { - last_value = event.value; - } - - select_vad_type(last_value); - }); - } - - /* Sensitivity */ - { - const container_sensitivity = container.find(".container-sensitivity"); - - const container_bar = container_sensitivity.find(".container-activity-bar"); - const bar_hider = container_bar.find(".bar-hider"); - - let last_value; - let triggered_events = 0; - let enabled; - - const slider = sliderfy(container_bar, { - min_value: 0, - max_value: 100, - step: 1, - initial_value: 0 - }); - - const set_enabled = value => { - if(enabled === value) return; - - enabled = value; - container_sensitivity.toggleClass("disabled", !value); - }; - - container_bar.on('change', event => { - const value = parseInt(container_bar.attr("value")); - if(last_value === value) return; - - triggered_events++; - event_registry.fire("set-setting", { setting: "threshold-threshold", value: value }); - }); - - event_registry.on("query-settings", event => set_enabled(false)); - event_registry.on("query-settings-result", event => { - if(event.status !== "success") return; - - last_value = event.info.vad_threshold.threshold; - slider.value(event.info.vad_threshold.threshold); - set_enabled(event.info.vad_type === "threshold"); - }); - - event_registry.on("set-setting-result", event => { - if(event.setting === "threshold-threshold") { - if(event.status !== "success") return; - - if(triggered_events > 0) { - triggered_events--; - return; - } - - last_value = event.value; - slider.value(event.value); - } else if(event.setting === "vad-type") { - if(event.status !== "success") return; - - set_enabled(event.value === "threshold"); - } - }); - - let selected_device; - event_registry.on("query-device-result", event => { - if(event.status !== "success") return; - - selected_device = event.active_device; - }); - event_registry.on("set-device-result", event => { - if(event.status !== "success") return; - - selected_device = event.device_id; - }); - - - bar_hider.css("width", "100%"); - event_registry.on("update-device-level", event => { - if(!enabled) return; - - const data = event.devices.find(e => e.device_id === selected_device); - let level = data && typeof data.level === "number" ? data.level : 0; - if(level > 100) level = 100; - else if(level < 0) level = 0; - - bar_hider.css("width", (100 - level) + "%"); - }); - - set_enabled(false); - } - - /* ppt settings */ - { - /* PPT Key */ - { - const button_key = container.find(".container-ppt button"); - event_registry.on("query-settings", event => button_key.prop("disabled", true).text(tr("loading"))); - let last_value; - - event_registry.on("query-settings-result", event => { - if(event.status !== "success") return; - - button_key.prop('disabled', event.info.vad_type !== "push_to_talk"); - button_key.text(last_value = key_description(event.info.vad_ppt.key)); - }); - - - event_registry.on("set-setting", event => { - if(event.setting !== "ppt-key") return; - - button_key.prop("enabled", false); - button_key.text(tr("applying")); - }); - - event_registry.on("set-setting-result", event => { - if(event.setting === "vad-type") { - if(event.status !== "success") return; - - button_key.prop('disabled', event.value !== "push_to_talk"); - } else if(event.setting === "ppt-key") { - if(event.status !== "success") { - createErrorModal(tr("Failed to change PPT key"), formatMessage(tr("Failed to change PPT key:{:br:}{}"), event.status === "timeout" ? tr("Timeout") : event.error || tr("Unknown error"))).open(); - } else { - last_value = key_description(event.value); - } - button_key.text(last_value); - } - }); - - button_key.on('click', event => { - spawnKeySelect(key => { - if(!key) return; - - event_registry.fire("set-setting", { setting: "ppt-key", value: key }); - }); - }); - } - - /* delay */ - { - const container_delay = container.find(".container-ppt-delay"); - - /* toggle button */ - { - const input_enabled = container_delay.find("input.delay-enabled"); - const update_enabled_state = () => { - const value = !loading && !applying && ppt_selected; - input_enabled.prop("disabled", !value).parent().toggleClass("disabled", !value); - }; - - let last_state; - let loading = true, applying = false, ppt_selected = false; - - event_registry.on("query-settings", event => { loading = true; update_enabled_state(); }); - event_registry.on("query-settings-result", event => { - if(event.status !== "success") return; - - loading = false; - ppt_selected = event.info.vad_type === "push_to_talk"; - update_enabled_state(); - input_enabled.prop("checked", last_state = event.info.vad_ppt.release_delay_active); - }); - - event_registry.on("set-setting", event => { - if(event.setting !== "ppt-release-delay-active") return; - - applying = true; - update_enabled_state(); - }); - - event_registry.on("set-setting-result", event => { - if(event.setting === "vad-type") { - if(event.status !== "success") return; - - ppt_selected = event.value === "push_to_talk"; - update_enabled_state(); - } else if(event.setting === "ppt-release-delay-active") { - applying = false; - update_enabled_state(); - - if(event.status !== "success") { - createErrorModal(tr("Failed to change PPT delay state"), formatMessage(tr("Failed to change PPT delay state:{:br:}{}"), event.status === "timeout" ? tr("Timeout") : event.error || tr("Unknown error"))).open(); - } else { - last_state = event.value; - } - input_enabled.prop("checked", last_state); - } - }); - - input_enabled.on('change', event => { - event_registry.fire("set-setting", { setting: "ppt-release-delay-active", value: input_enabled.prop("checked") }); - }); - } - - /* delay input */ - { - const input_time = container_delay.find("input.delay-time"); - const update_enabled_state = () => { - const value = !loading && !applying && ppt_selected && delay_active; - input_time.prop("disabled", !value).parent().toggleClass("disabled", !value); - }; - - let last_state; - let loading = true, applying = false, ppt_selected = false, delay_active = false; - - event_registry.on("query-settings", event => { loading = true; update_enabled_state(); }); - event_registry.on("query-settings-result", event => { - if(event.status !== "success") return; - - loading = false; - ppt_selected = event.info.vad_type === "push_to_talk"; - delay_active = event.info.vad_ppt.release_delay_active; - update_enabled_state(); - input_time.val(last_state = event.info.vad_ppt.release_delay); - }); - - event_registry.on("set-setting", event => { - if(event.setting !== "ppt-release-delay") return; - - applying = true; - update_enabled_state(); - }); - - event_registry.on("set-setting-result", event => { - if(event.setting === "vad-type") { - if(event.status !== "success") return; - - ppt_selected = event.value === "push_to_talk"; - update_enabled_state(); - } else if(event.setting === "ppt-release-delay-active") { - if(event.status !== "success") return; - - delay_active = event.value; - update_enabled_state(); - } else if(event.setting === "ppt-release-delay") { - applying = false; - update_enabled_state(); - - if(event.status !== "success") { - createErrorModal(tr("Failed to change PPT delay"), formatMessage(tr("Failed to change PPT delay:{:br:}{}"), event.status === "timeout" ? tr("Timeout") : event.error || tr("Unknown error"))).open(); - } else { - last_state = event.value; - } - input_time.val(last_state); - } - }); - - input_time.on('change', event => { - event_registry.fire("set-setting", { setting: "ppt-release-delay", value: parseInt(input_time.val() as any) }); - }); - } - } - } - } - - /* timeouts */ - { - /* device query */ - { - let timeout; - event_registry.on('query-devices', event => { - clearTimeout(timeout); - timeout = setTimeout(() => { - event_registry.fire("query-device-result", { status: "timeout" }); - }, 5000); - }); - - event_registry.on("query-device-result", event => clearTimeout(timeout)); - } - - /* device set */ - { - let timeouts = {}; - event_registry.on('set-device', event => { - clearTimeout(timeouts[event.device_id]); - timeouts[event.device_id] = setTimeout(() => { - event_registry.fire("set-device-result", { status: "timeout", device_id: event.device_id }); - }, 5000); - }); - - event_registry.on("set-device-result", event => clearTimeout(timeouts[event.device_id])); - } - - /* settings query */ - { - let timeout; - event_registry.on('query-settings', event => { - clearTimeout(timeout); - timeout = setTimeout(() => { - event_registry.fire("query-settings-result", { status: "timeout" }); - }, 5000); - }); - - event_registry.on("query-settings-result", event => clearTimeout(timeout)); - } - - /* settings change */ - { - let timeouts = {}; - event_registry.on('set-setting', event => { - clearTimeout(timeouts[event.setting]); - timeouts[event.setting] = setTimeout(() => { - event_registry.fire("set-setting-result", { status: "timeout", setting: event.setting }); - }, 5000); - }); - - event_registry.on("set-setting-result", event => clearTimeout(timeouts[event.setting])); - } - } - - event_registry.on("audio-initialized", () => { - event_registry.fire("query-settings"); - event_registry.fire("query-devices", { refresh_list: false }); - }); - } } function settings_identity_forum(container: JQuery, modal: Modal, update_profiles: () => any) { diff --git a/shared/js/ui/modal/settings/Microphone.scss b/shared/js/ui/modal/settings/Microphone.scss new file mode 100644 index 00000000..99de55c1 --- /dev/null +++ b/shared/js/ui/modal/settings/Microphone.scss @@ -0,0 +1,579 @@ +@import "../../../../css/static/properties"; +@import "../../../../css/static/mixin"; + +.container { + display: flex; + flex-direction: row; + justify-content: stretch; + flex-shrink: 1; + flex-grow: 1; + + min-width: 43em; + min-height: 41em; + + position: relative; + + .left, .right { + flex-grow: 1; + flex-shrink: 1; + + width: calc(50% - .5em); /* the .5em for the padding/margin */ + + display: flex; + flex-direction: column; + justify-content: stretch; + } + + .left { + margin-right: 1em; + + min-height: 0; + max-height: 100%; + + .body { + flex-grow: 1; + flex-shrink: 1; + + min-height: 6.5em; + + display: flex; + flex-direction: column; + justify-content: stretch; + + border: 1px $color_list_border solid; + border-radius: $border_radius_large; + + background-color: $color_list_background; + + .buttons { + flex-grow: 0; + flex-shrink: 0; + + height: 3.5em; + padding: .5em; + + display: flex; + flex-direction: row; + justify-content: stretch; + + border: none; + border-top: 1px $color_list_border solid; + + .spacer { + flex-grow: 1; + flex-shrink: 1; + } + + :not(.spacer) { + flex-grow: 0; + flex-shrink: 0; + } + + button { + min-width: 8em; + height: 2.5em; + } + } + } + } + + .right { + padding-right: .5em; /* for the sliders etc*/ + justify-content: flex-start; + + .body { + flex-grow: 0; + flex-shrink: 1; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + /* microphone */ + .containerVolume { + flex-grow: 0; + flex-shrink: 0; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + height: 3em; + width: 100%; + } + + /* microphone */ + .containerSelectVad { + margin-top: .5em; + width: 100%; + + .fieldset { + padding: 0; + margin: 0; + + flex-shrink: 1; + flex-grow: 1; + + display: flex; + flex-direction: column; + justify-content: stretch; + + > .containerOption { + padding: 0; + + display: flex; + flex-direction: row; + justify-content: space-between; + + > label { + flex-shrink: 0; + min-width: 5em; + + cursor: pointer; + + display: flex; + flex-direction: row; + justify-content: flex-start; + + height: 1.7em; + + a { + align-self: center; + line-height: 1.2em; + } + } + + button { + width: 100%; + + height: 2em; + font-size: .75em; + + align-self: center; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .containerButton { + flex-shrink: 1; + margin-left: .5em; + + min-width: 3em; + width: 15em; + } + } + } + } + + /* microphone */ + .containerSensitivity { + width: 100%; + + display: flex; + flex-direction: row; + justify-content: stretch; + + position: relative; + + .slider { + width: 100%; + background: transparent; + + .filler { + display: none; + } + } + + .containerBar { + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: center; + + .containerActivityBar { + width: 100%; + } + } + } + + /* microphone */ + .containerAdvanced { + display: flex; + flex-direction: column; + justify-content: flex-start; + + .containerPptDelay { + display: flex; + flex-direction: row; + justify-content: space-between; + + .input { + width: 6em; + font-size: .7em; + + input { + font-size: 1.1em; + } + } + } + } + + /* speaker */ + .containerVolumeMaster { + .filler { + background-color: #2b8541; + } + } + + .containerVolumeSoundpack { + padding-top: .75em; + } + } + } +} + +.header { + height: 2.6em; + + flex-grow: 0; + flex-shrink: 0; + + display: flex; + flex-direction: row; + justify-content: stretch; + + padding-bottom: .5em; + + a { + flex-grow: 1; + flex-shrink: 1; + + align-self: flex-end; + + font-weight: bold; + color: #e0e0e0; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .btn { + flex-shrink: 0; + flex-grow: 0; + + height: 2em; + align-self: flex-end; + + margin-left: 1em; + min-width: 8em; + } +} + +.containerDevices { + flex-grow: 1; + flex-shrink: 1; + + min-height: 3em; + position: relative; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + overflow-x: hidden; + overflow-y: auto; + + @include chat-scrollbar-vertical(); + + .device { + flex-shrink: 0; + flex-grow: 0; + + display: flex; + flex-direction: row; + justify-content: stretch; + + cursor: pointer; + + height: 3em; + width: 100%; + + .containerSelected { + /* the selected border */ + margin-top: 1px; + margin-bottom: 1px; + + flex-shrink: 0; + flex-grow: 0; + + width: 3em; + position: relative; + + border: none; + border-right: 1px solid #242527; + + > * { + padding: .5em; + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + margin: auto; + } + + > :global(.icon_em) { + font-size: 2em; + } + + > .iconLoading { + img { + max-height: 100%; + max-width: 100%; + + -webkit-animation:spin 4s linear infinite; + -moz-animation:spin 4s linear infinite; + animation:spin 4s linear infinite; + } + } + } + + .containerName { + /* the selected border */ + margin-top: 1px; + margin-bottom: 1px; + + flex-shrink: 1; + flex-grow: 1; + + min-width: 4em; + + padding: .5em; + + display: flex; + flex-direction: column; + justify-content: space-around; + + border: none; + + .driver { + font-size: .8em; + line-height: 1em; + + color: #6a6a6a; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .name { + line-height: 1em; + + color: #999999; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .containerActivity { + /* the selected border */ + margin-top: 1px; + margin-bottom: 1px; + + flex-shrink: 0; + flex-grow: 0; + + display: flex; + flex-direction: column; + justify-content: space-around; + + padding: .5em; + + width: 10em; + + border: none; + border-left: 1px solid #242527; + + .bar { + flex-grow: 0; + flex-shrink: 0; + + width: 8em; + } + } + + &:hover { + background-color: $color_list_hover; + } + + &.selected { + > div { + margin-top: 0; + margin-bottom: 0; + + border-bottom: 1px solid #242527; + border-top: 1px solid #242527; + } + } + } + + .overlay { + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: center; + + background-color: #28292b; + + a { + font-size: 1.3em; + align-self: center; + color: #9e9494; + text-align: center; + } + + &.hidden { + pointer-events: none; + opacity: 0; + } + } +} + +.containerActivityBar { + $bar_height: 1em; + + $thumb_width: .6em; + $thumb_height: 2em; + + position: relative; + align-self: center; + + display: flex; + flex-direction: column; + justify-content: space-around; + + height: $bar_height; + border-radius: $border_radius_large; + + overflow: hidden; + cursor: pointer; + + .hider { + position: absolute; + + top: 0; + right: 0; + bottom: 0; + + background-color: #242527; + + -webkit-box-shadow: inset 0 0 2px 0 rgba(0,0,0,0.75); + -moz-box-shadow: inset 0 0 2px 0 rgba(0,0,0,0.75); + box-shadow: inset 0 0 2px 0 rgba(0,0,0,0.75); + + border-bottom-right-radius: $border_radius_large; + border-top-right-radius: $border_radius_large; + + @include transition(.06s ease-in-out); + } + + &[value] { + overflow: visible; /* for the thumb */ + + border-bottom-left-radius: $border_radius_large; + border-top-left-radius: $border_radius_large; + } + + .text { + z-index: 2; + width: 100%; + text-align: center; + + line-height: 1em; + font-size: .8em; + + padding-left: .2em; + padding-right: .2em; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &.error { + color: #a10000; + } + } + + .thumb { + position: absolute; + + top: 0; + right: 0; + + height: $thumb_height; + width: $thumb_width; + + margin-left: -($thumb_width / 2); + margin-right: -($thumb_width / 2); + + margin-top: -($thumb_height - $bar_height) / 2; + margin-bottom: -($thumb_height - $bar_height) / 2; + + background-color: #808080; + + .tooltip { + display: none; + } + } + + &.disabled { + pointer-events: none; + + .hider { + width: 100%; + } + + .thumb { + background-color: #4d4d4d; + + .tooltip { + opacity: 0; + } + } + } + + -webkit-box-shadow: inset 0 0 2px 0 rgba(0,0,0,0.25); + -moz-box-shadow: inset 0 0 2px 0 rgba(0,0,0,0.25); + box-shadow: inset 0 0 2px 0 rgba(0,0,0,0.25); + + /* Permalink - use to edit and share this gradient: https://colorzilla.com/gradient-editor/#70407e+0,45407e+100 */ + background: rgb(112,64,126); /* Old browsers */ + background: -moz-linear-gradient(left, rgba(112,64,126,1) 0%, rgba(69,64,126,1) 100%); /* FF3.6-15 */ + background: -webkit-linear-gradient(left, rgba(112,64,126,1) 0%,rgba(69,64,126,1) 100%); /* Chrome10-25,Safari5.1-6 */ + background: linear-gradient(to right, rgba(112,64,126,1) 0%,rgba(69,64,126,1) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#70407e', endColorstr='#45407e',GradientType=1 ); /* IE6-9 */ +} + +@-moz-keyframes spin { 100% { -moz-transform: rotate(360deg); } } +@-webkit-keyframes spin { 100% { -webkit-transform: rotate(360deg); } } +@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } } \ No newline at end of file diff --git a/shared/js/ui/modal/settings/Microphone.tsx b/shared/js/ui/modal/settings/Microphone.tsx new file mode 100644 index 00000000..3d8449a6 --- /dev/null +++ b/shared/js/ui/modal/settings/Microphone.tsx @@ -0,0 +1,271 @@ +import * as aplayer from "tc-backend/audio/player"; +import * as React from "react"; +import {Registry} from "tc-shared/events"; +import {LevelMeter} from "tc-shared/voice/RecorderBase"; +import * as arecorder from "tc-backend/audio/recorder"; +import * as log from "tc-shared/log"; +import {LogCategory, logWarn} from "tc-shared/log"; +import {default_recorder} from "tc-shared/voice/RecorderProfile"; +import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; +import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {MicrophoneSettings} from "tc-shared/ui/modal/settings/MicrophoneRenderer"; + +export type MicrophoneSetting = "volume" | "vad-type" | "ppt-key" | "ppt-release-delay" | "ppt-release-delay-active" | "threshold-threshold"; + +export type MicrophoneDevice = { + id: string, + name: string, + driver: string +}; + + +export interface MicrophoneSettingsEvents { + "query_devices": { refresh_list: boolean }, + + "query_setting": { + setting: MicrophoneSetting + }, + + "action_set_selected_device": { deviceId: string }, + "action_set_selected_device_result": { + deviceId: string, /* on error it will contain the current selected device */ + status: "success" | "error", + + error?: string + }, + + "action_set_setting": { + setting: MicrophoneSetting; + value: any; + }, + + notify_setting: { + setting: MicrophoneSetting; + value: any; + } + + "notify_devices": { + status: "success" | "error" | "audio-not-initialized", + + error?: string, + devices?: MicrophoneDevice[] + selectedDevice?: string; + }, + + notify_device_level: { + level: {[key: string]: { + deviceId: string, + status: "success" | "error", + + level?: number, + error?: string + }} + }, + + notify_destroy: {} +} + +export function initialize_audio_microphone_controller(events: Registry) { + /* level meters */ + { + const level_meters: {[key: string]:Promise} = {}; + const level_info: {[key: string]:any} = {}; + let level_update_task; + + const destroy_meters = () => { + Object.keys(level_meters).forEach(e => { + const meter = level_meters[e]; + delete level_meters[e]; + + meter.then(e => e.destory()); + }); + Object.keys(level_info).forEach(e => delete level_info[e]); + }; + + const update_level_meter = () => { + destroy_meters(); + + for(const device of arecorder.devices()) { + let promise = arecorder.create_levelmeter(device).then(meter => { + meter.set_observer(level => { + if(level_meters[device.unique_id] !== promise) return; /* old level meter */ + + level_info[device.unique_id] = { + deviceId: device.unique_id, + status: "success", + level: level + }; + }); + return Promise.resolve(meter); + }).catch(error => { + if(level_meters[device.unique_id] !== promise) return; /* old level meter */ + level_info[device.unique_id] = { + deviceId: device.unique_id, + status: "error", + + error: error + }; + + log.warn(LogCategory.AUDIO, tr("Failed to initialize a level meter for device %s (%s): %o"), device.unique_id, device.driver + ":" + device.name, error); + return Promise.reject(error); + }); + level_meters[device.unique_id] = promise; + } + }; + + level_update_task = setInterval(() => { + events.fire("notify_device_level", { + level: level_info + }); + }, 50); + + events.on("notify_devices", event => { + if(event.status !== "success") return; + + update_level_meter(); + }); + + events.on("notify_destroy", event => { + destroy_meters(); + clearInterval(level_update_task); + }); + } + + /* device list */ + { + events.on("query_devices", event => { + if(!aplayer.initialized()) { + events.fire_async("notify_devices", { status: "audio-not-initialized" }); + return; + } + + Promise.resolve().then(() => { + return arecorder.device_refresh_available() && event.refresh_list ? arecorder.refresh_devices() : Promise.resolve(); + }).catch(error => { + log.warn(LogCategory.AUDIO, tr("Failed to refresh device list: %o"), error); + return Promise.resolve(); + }).then(() => { + const devices = arecorder.devices(); + + events.fire_async("notify_devices", { + status: "success", + selectedDevice: default_recorder.current_device() ? default_recorder.current_device().unique_id : "none", + devices: devices.map(e => { return { id: e.unique_id, name: e.name, driver: e.driver }}) + }); + }); + }); + + events.on("action_set_selected_device", event => { + const device = arecorder.devices().find(e => e.unique_id === event.deviceId); + if(!device && event.deviceId !== "none") { + events.fire_async("action_set_selected_device_result", { status: "error", error: tr("Invalid device id"), deviceId: default_recorder.current_device().unique_id }); + return; + } + + default_recorder.set_device(device).then(() => { + console.debug(tr("Changed default microphone device")); + events.fire_async("action_set_selected_device_result", { status: "success", deviceId: event.deviceId }); + }).catch((error) => { + log.warn(LogCategory.AUDIO, tr("Failed to change microphone to device %s: %o"), device ? device.unique_id : "none", error); + events.fire_async("action_set_selected_device_result", { status: "success", deviceId: event.deviceId }); + }); + }); + } + + /* settings */ + { + events.on("query_setting", event => { + let value; + switch (event.setting) { + case "volume": + value = default_recorder.get_volume(); + break; + + case "threshold-threshold": + value = default_recorder.get_vad_threshold(); + break; + + case "vad-type": + value = default_recorder.get_vad_type(); + break; + + case "ppt-key": + value = default_recorder.get_vad_ppt_key(); + break; + + case "ppt-release-delay": + value = Math.abs(default_recorder.get_vad_ppt_delay()); + break; + + case "ppt-release-delay-active": + value = default_recorder.get_vad_ppt_delay() > 0; + break; + + default: + return; + } + + events.fire_async("notify_setting", { setting: event.setting, value: value }); + }); + + events.on("action_set_setting", event => { + const ensure_type = (type: "object" | "string" | "boolean" | "number" | "undefined") => { + if(typeof event.value !== type) { + logWarn(LogCategory.GENERAL, tr("Failed to change microphone setting (Invalid value type supplied. Expected %s, Received: %s)"), + type, + typeof event.value + ); + return false; + } + return true; + }; + + switch (event.setting) { + case "volume": + if(!ensure_type("number")) return; + default_recorder.set_volume(event.value); + break; + + case "threshold-threshold": + if(!ensure_type("number")) return; + default_recorder.set_vad_threshold(event.value); + break; + + case "vad-type": + if(!ensure_type("string")) return; + if(!default_recorder.set_vad_type(event.value)) { + logWarn(LogCategory.GENERAL, tr("Failed to change recorders VAD type to %s"), event.value); + return; + } + break; + + case "ppt-key": + if(!ensure_type("object")) return; + default_recorder.set_vad_ppt_key(event.value); + break; + + case "ppt-release-delay": + if(!ensure_type("number")) return; + const sign = default_recorder.get_vad_ppt_delay() >= 0 ? 1 : -1; + default_recorder.set_vad_ppt_delay(sign * event.value); + break; + + case "ppt-release-delay-active": + if(!ensure_type("boolean")) return; + default_recorder.set_vad_ppt_delay(Math.abs(default_recorder.get_vad_ppt_delay()) * (event.value ? 1 : -1)); + break; + + default: + return; + } + events.fire_async("notify_setting", { setting: event.setting, value: event.value }); + }); + } + + if(!aplayer.initialized()) { + aplayer.on_ready(() => { events.fire_async("query_devices"); }); + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/settings/MicrophoneRenderer.tsx b/shared/js/ui/modal/settings/MicrophoneRenderer.tsx new file mode 100644 index 00000000..89369a89 --- /dev/null +++ b/shared/js/ui/modal/settings/MicrophoneRenderer.tsx @@ -0,0 +1,458 @@ +import * as React from "react"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {Button} from "tc-shared/ui/react-elements/Button"; +import {modal, Registry} from "tc-shared/events"; +import {MicrophoneDevice, MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/Microphone"; +import {useEffect, useRef, useState} from "react"; +import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; +import {ClientIcon} from "svg-sprites/client-icons"; +import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; +import {createErrorModal} from "tc-shared/ui/elements/Modal"; +import {Slider} from "tc-shared/ui/react-elements/Slider"; +import MicrophoneSettings = modal.settings.MicrophoneSettings; +import {RadioButton} from "tc-shared/ui/react-elements/RadioButton"; +import {VadType} from "tc-shared/voice/RecorderProfile"; +import {key_description, KeyDescriptor} from "tc-shared/PPTListener"; +import {spawnKeySelect} from "tc-shared/ui/modal/ModalKeySelect"; +import {Checkbox} from "tc-shared/ui/react-elements/Checkbox"; +import {BoxedInputField} from "tc-shared/ui/react-elements/InputField"; + +const cssStyle = require("./Microphone.scss"); + +type MicrophoneSelectedState = "selected" | "applying" | "unselected"; +const MicrophoneStatus = (props: { state: MicrophoneSelectedState }) => { + switch (props.state) { + case "applying": + return ( +
+ {tr("applying")} +
+ ); + + case "unselected": + return null; + + case "selected": + return ; + } +} + +type ActivityBarStatus = { mode: "success" } | { mode: "error", message: string } | { mode: "loading" }; +const ActivityBar = (props: { events: Registry, deviceId: string, disabled?: boolean }) => { + const refHider = useRef(); + const [ status, setStatus ] = useState({ mode: "loading" }); + + props.events.reactUse("notify_device_level", event => { + const device = event.level[props.deviceId]; + if(!device) { + if(status.mode === "loading") + return; + + setStatus({ mode: "loading" }); + } else if(device.status === "success") { + if(status.mode !== "success") { + setStatus({ mode: "success" }); + } + refHider.current.style.width = (100 - device.level) + "%"; + } else { + if(status.mode === "error" && status.message === device.error) + return; + + setStatus({ mode: "error", message: device.error }); + } + }); + + + let error; + switch (status.mode) { + case "error": + error =
{status.message}
; + break; + + case "loading": + error =
Loading 
; + break; + + case "success": + error = undefined; + break; + } + return ( +
+
+ {error} +
+ ) +}; + +const Microphone = (props: { events: Registry, device: MicrophoneDevice, state: MicrophoneSelectedState, onClick: () => void }) => { + return ( +
+
+ +
+
+
{props.device.driver}
+
{props.device.name}
+
+
+ +
+
+ ); +}; + +const MicrophoneList = (props: { events: Registry }) => { + const [ state, setState ] = useState<"normal" | "loading" | "error" | "audio-not-initialized">(() => { + props.events.fire("query_devices"); + return "loading"; + }); + const [ selectedDevice, setSelectedDevice ] = useState<{ deviceId: string, mode: "selected" | "selecting" }>(); + const [ deviceList, setDeviceList ] = useState([]); + const [ error, setError ] = useState(undefined); + + props.events.reactUse("notify_devices", event => { + setSelectedDevice(undefined); + switch (event.status) { + case "success": + setDeviceList(event.devices.slice(0)); + setState("normal"); + setSelectedDevice({ mode: "selected", deviceId: event.selectedDevice }); + break; + + case "error": + setError(event.error || tr("Unknown error")); + setState("error"); + break; + + case "audio-not-initialized": + setState("audio-not-initialized"); + break; + } + }); + + props.events.reactUse("action_set_selected_device", event => { + setSelectedDevice({ mode: "selecting", deviceId: event.deviceId }); + }); + + props.events.reactUse("action_set_selected_device_result", event => { + if(event.status === "error") + createErrorModal(tr("Failed to select microphone"), tra("Failed to select microphone:\n{}", event.error)).open(); + + setSelectedDevice({ mode: "selected", deviceId: event.deviceId }); + }); + + return ( +
+ +
+ {error} +
+
+ Loading  +
+ {deviceList.map(e => { + if(state !== "normal" || selectedDevice?.mode === "selecting") + return; + + props.events.fire("action_set_selected_device", { deviceId: e.id }); + }} + />)} +
+ ) +} + +const ListRefreshButton = (props: { events: Registry }) => { + const [ updateTimeout, setUpdateTimeout ] = useState(Date.now() + 2000); + + useEffect(() => { + if(updateTimeout === 0) + return; + + const id = setTimeout(() => { + setUpdateTimeout(0); + }, Math.max(updateTimeout - Date.now(), 0)); + return () => clearTimeout(id); + }); + + props.events.reactUse("query_devices", () => setUpdateTimeout(Date.now() + 2000)); + + return ; +} + +const VolumeSettings = (props: { events: Registry }) => { + const refSlider = useRef(); + const [ value, setValue ] = useState<"loading" | number>(() => { + props.events.fire("query_setting", { setting: "volume" }); + return "loading"; + }) + + props.events.reactUse("notify_setting", event => { + if(event.setting !== "volume") + return; + + console.error("Set value: %o", event.value); + refSlider.current?.setState({ value: event.value }); + setValue(event.value); + }); + + return ( +
+ Volume + props.events.fire("action_set_setting", { setting: "volume", value: value })} + /> +
+ ) +}; + +const PPTKeyButton = React.memo((props: { events: Registry }) => { + const [ key, setKey ] = useState<"loading" | KeyDescriptor>(() => { + props.events.fire("query_setting", { setting: "ppt-key" }); + return "loading"; + }); + + const [ isActive, setActive ] = useState(false); + + props.events.reactUse("notify_setting", event => { + if(event.setting === "vad-type") + setActive(event.value === "push_to_talk"); + else if(event.setting === "ppt-key") + setKey(event.value); + }); + + if(key === "loading") { + return ; + } else { + return ; + } +}); + +const PPTDelaySettings = (props: { events: Registry }) => { + const [ delayActive, setDelayActive ] = useState<"loading" | boolean>(() => { + props.events.fire("query_setting", { setting: "ppt-release-delay" }); + return "loading"; + }); + + const [ delay, setDelay ] = useState<"loading" | number>(() => { + props.events.fire("query_setting", { setting: "ppt-release-delay-active" }); + return "loading"; + }); + + const [ isActive, setActive ] = useState(false); + + props.events.reactUse("notify_setting", event => { + if(event.setting === "vad-type") + setActive(event.value === "push_to_talk"); + else if(event.setting === "ppt-release-delay") + setDelay(event.value); + else if(event.setting === "ppt-release-delay-active") + setDelayActive(event.value); + }); + + return ( +
+ { props.events.fire("action_set_setting", { setting: "ppt-release-delay-active", value: value })}} + disabled={!isActive} + value={delayActive === true} + label={Delay on Push to Talk} + /> + + { + if(event.target.value === "") { + const target = event.target; + setImmediate(() => target.value = ""); + return; + } + + + const newValue = event.target.valueAsNumber; + if(isNaN(newValue)) + return; + + if(newValue < 0 || newValue > 4000) + return; + + props.events.fire("action_set_setting", { setting: "ppt-release-delay", value: newValue }); + }} + />} + /> +
+ ); +} + +const VadSelector = (props: { events: Registry }) => { + const [ selectedType, setSelectedType ] = useState(() => { + props.events.fire("query_setting", { setting: "vad-type" }); + return "loading"; + }); + + props.events.reactUse("notify_setting", event => { + if(event.setting !== "vad-type") + return; + + setSelectedType(event.value); + }); + + return ( +
+
+
+ { props.events.fire("action_set_setting", { setting: "vad-type", value: "push_to_talk" }) }} + selected={selectedType === "push_to_talk"} + disabled={selectedType === "loading"} + > + Push to Talk + +
+ +
+
+
+ { props.events.fire("action_set_setting", { setting: "vad-type", value: "threshold" }) }} + selected={selectedType === "threshold"} + disabled={selectedType === "loading"} + > + Voice activity detection + +
+
+ { props.events.fire("action_set_setting", { setting: "vad-type", value: "active" }) }} + selected={selectedType === "active"} + disabled={selectedType === "loading"} + > + Always active + +
+
+
+ ); +} + +const ThresholdSelector = (props: { events: Registry }) => { + const refSlider = useRef(); + const [ value, setValue ] = useState<"loading" | number>(() => { + props.events.fire("query_setting", { setting: "threshold-threshold" }); + return "loading"; + }); + + const [ isActive, setActive ] = useState(false); + + props.events.reactUse("notify_setting", event => { + if(event.setting === "threshold-threshold") { + refSlider.current?.setState({ value: event.value }); + setValue(event.value); + } else if(event.setting === "vad-type") { + setActive(event.value === "threshold"); + } + }); + + return ( +
+
+ +
+ {}} + /> +
+ ) +}; + +export const MicrophoneSettings = (props: { events: Registry }) => ( +
+ +
+ +
+ + +
+ +
+ +
+ +
+
+ +
+
+
+
+) \ No newline at end of file diff --git a/shared/js/ui/react-elements/Button.scss b/shared/js/ui/react-elements/Button.scss index 5c384634..a188c825 100644 --- a/shared/js/ui/react-elements/Button.scss +++ b/shared/js/ui/react-elements/Button.scss @@ -77,6 +77,10 @@ html:root { border-bottom-color: var(--button-yellow); } + &.color-none { + --keep-alive: true; + } + &.type-normal { } &.type-small { diff --git a/shared/js/ui/react-elements/Button.tsx b/shared/js/ui/react-elements/Button.tsx index 30e26cca..57f0da81 100644 --- a/shared/js/ui/react-elements/Button.tsx +++ b/shared/js/ui/react-elements/Button.tsx @@ -3,7 +3,7 @@ import * as React from "react"; const cssStyle = require("./Button.scss"); export interface ButtonProperties { - color?: "green" | "blue" | "red" | "purple" | "brown" | "yellow" | "default"; + color?: "green" | "blue" | "red" | "purple" | "brown" | "yellow" | "default" | "none"; type?: "normal" | "small" | "extra-small"; className?: string; diff --git a/shared/js/ui/react-elements/InputField.scss b/shared/js/ui/react-elements/InputField.scss index ce45935b..e076cfbb 100644 --- a/shared/js/ui/react-elements/InputField.scss +++ b/shared/js/ui/react-elements/InputField.scss @@ -46,7 +46,7 @@ html:root { color: var(--boxed-input-field-placeholder); }; - .prefix { + .prefix, .suffix { flex-grow: 0; flex-shrink: 0; @@ -65,6 +65,10 @@ html:root { @include transition($button_hover_animation_time ease-in-out); } + .suffix { + padding-left: 0; + } + &.is-invalid { background-color: var(--boxed-input-field-invalid-background); border-color: var(--boxed-input-field-invalid-border); @@ -80,7 +84,7 @@ html:root { color: var(--boxed-input-field-focus-text); - .prefix { + .prefix, .suffix { width: 0; padding-left: 0; padding-right: 0; @@ -125,6 +129,7 @@ html:root { } &.disabled, &:disabled { + @include user-select(none); background-color: var(--boxed-input-field-disabled-background); } @@ -140,6 +145,12 @@ html:root { } } + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + @include transition($button_hover_animation_time ease-in-out); } diff --git a/shared/js/ui/react-elements/InputField.tsx b/shared/js/ui/react-elements/InputField.tsx index 1f0e80c9..74db72b3 100644 --- a/shared/js/ui/react-elements/InputField.tsx +++ b/shared/js/ui/react-elements/InputField.tsx @@ -5,6 +5,8 @@ const cssStyle = require("./InputField.scss"); export interface BoxedInputFieldProperties { prefix?: string; + suffix?: string; + placeholder?: string; disabled?: boolean; @@ -79,6 +81,7 @@ export class BoxedInputField extends React.Component this.props.onInput(event.currentTarget.value))} onKeyDown={e => this.onKeyDown(e)} />} + {this.props.suffix ? {this.props.suffix} : undefined} {this.props.rightIcon ? this.props.rightIcon() : ""}
) diff --git a/shared/js/ui/react-elements/RadioButton.scss b/shared/js/ui/react-elements/RadioButton.scss new file mode 100644 index 00000000..4efed01f --- /dev/null +++ b/shared/js/ui/react-elements/RadioButton.scss @@ -0,0 +1,68 @@ +@import "../../../css/static/mixin"; +@import "../../../css/static/properties"; + +.container { + $button_size: 1.2em; + $mark_size: .6em; + + position: relative; + + width: $button_size; + height: $button_size; + + cursor: pointer; + + overflow: hidden; + + background-color: #272626; + border-radius: 50%; + + align-self: center; + margin-right: .5em; + + input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + } + + .mark { + position: absolute; + opacity: 0; + + top: ($button_size - $mark_size) / 2; + bottom: ($button_size - $mark_size) / 2; + right: ($button_size - $mark_size) / 2; + left: ($button_size - $mark_size) / 2; + + background-color: #46c0ec; + box-shadow: 0 0 .5em 1px rgba(70, 192, 236, 0.4); + border-radius: 50%; + + @include transition(.4s); + } + + input:checked + .mark { + opacity: 1; + } + + @include transition(background-color $button_hover_animation_time); + + -webkit-box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5); + -moz-box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5); + box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5); +} + +label:hover > .container, .container:hover { + &.container, > .container { + background-color: #2c2b2b; + } +} + +label.disabled > .container, .container.disabled, .container:disabled { + &.container, > .container { + pointer-events: none!important; + background-color: #1a1919!important; + } +} \ No newline at end of file diff --git a/shared/js/ui/react-elements/RadioButton.tsx b/shared/js/ui/react-elements/RadioButton.tsx new file mode 100644 index 00000000..f7c14f14 --- /dev/null +++ b/shared/js/ui/react-elements/RadioButton.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +const cssStyle = require("./RadioButton.scss"); +export const RadioButton = (props: { + children?: React.ReactNode | React.ReactNode[], + + name: string, + selected: boolean, + + disabled?: boolean + + onChange: (checked: boolean) => void, +}) => { + return ( +