Using React for the microphone settings

canary
WolverinDEV 2020-08-11 00:25:20 +02:00
parent 405bc7512d
commit 56d9e9fa16
18 changed files with 1499 additions and 731 deletions

View File

@ -1,4 +1,7 @@
# Changelog: # Changelog:
* **11.08.20**
- Fixed the voice push to talk delay
* **09.08.20** * **09.08.20**
- Added a "watch to gather" context menu entry for clients - Added a "watch to gather" context menu entry for clients
- Disassembled the current client icon sprite into his icons - Disassembled the current client icon sprite into his icons

View File

@ -1 +1,19 @@
<svg id="Flat" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g fill="#8690fa"><circle cx="48" cy="304" r="24"/><circle cx="192" cy="448" r="24"/><circle cx="101" cy="395" r="24"/><circle cx="48" cy="304" r="24"/><path d="m216 448a24 24 0 1 0 -24 24 24 24 0 0 0 24-24"/><circle cx="101" cy="395" r="24"/><circle cx="48" cy="192" r="24"/><circle cx="192" cy="48" r="24"/><circle cx="101" cy="101" r="24"/><circle cx="48" cy="192" r="24"/><path d="m216 48a24 24 0 1 1 -24-24 24 24 0 0 1 24 24"/><circle cx="101" cy="101" r="24"/><path d="m311.992 472a24 24 0 0 1 -6.433-47.123 185.506 185.506 0 0 0 96.328-64.917 181.561 181.561 0 0 0 38.113-111.96c0-81.5-55.349-154.248-134.6-176.922a24 24 0 0 1 13.2-46.147 236.543 236.543 0 0 1 121 82.086 230.506 230.506 0 0 1 .276 282.283 233.819 233.819 0 0 1 -121.421 81.812 24.04 24.04 0 0 1 -6.463.888z"/></g><path d="m456.029 488a24.512 24.512 0 0 1 -2.679-.148l-144-16a24 24 0 0 1 -20.474-30.278l40-144a24 24 0 1 1 46.248 12.848l-32.454 116.838 115.98 12.886a24 24 0 0 1 -2.621 47.854z" fill="#5153ff"/></svg> <svg id="Flat" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg">
<g fill="#7289da">
<circle cx="48" cy="304" r="24"/>
<circle cx="192" cy="448" r="24"/>
<circle cx="101" cy="395" r="24"/>
<circle cx="48" cy="304" r="24"/>
<path d="m216 448a24 24 0 1 0 -24 24 24 24 0 0 0 24-24"/>
<circle cx="101" cy="395" r="24"/>
<circle cx="48" cy="192" r="24"/>
<circle cx="192" cy="48" r="24"/>
<circle cx="101" cy="101" r="24"/>
<circle cx="48" cy="192" r="24"/>
<path d="m216 48a24 24 0 1 1 -24-24 24 24 0 0 1 24 24"/>
<circle cx="101" cy="101" r="24"/>
<path d="m311.992 472a24 24 0 0 1 -6.433-47.123 185.506 185.506 0 0 0 96.328-64.917 181.561 181.561 0 0 0 38.113-111.96c0-81.5-55.349-154.248-134.6-176.922a24 24 0 0 1 13.2-46.147 236.543 236.543 0 0 1 121 82.086 230.506 230.506 0 0 1 .276 282.283 233.819 233.819 0 0 1 -121.421 81.812 24.04 24.04 0 0 1 -6.463.888z"/>
</g>
<path d="m456.029 488a24.512 24.512 0 0 1 -2.679-.148l-144-16a24 24 0 0 1 -20.474-30.278l40-144a24 24 0 1 1 46.248 12.848l-32.454 116.838 115.98 12.886a24 24 0 0 1 -2.621 47.854z"
fill="#7289da"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -994,7 +994,7 @@ export class LocalClientEntry extends ClientEntry {
renameSelf(new_name: string) : Promise<boolean> { renameSelf(new_name: string) : Promise<boolean> {
const old_name = this.properties.client_nickname; const old_name = this.properties.client_nickname;
this.updateVariables({ key: "client_nickname", value: new_name }); /* change it locally */ 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); settings.changeGlobal(Settings.KEY_CONNECT_USERNAME, new_name);
this.channelTree.client.log.log(EventType.CLIENT_NICKNAME_CHANGED_OWN, { this.channelTree.client.log.log(EventType.CLIENT_NICKNAME_CHANGED_OWN, {
client: this.log_data(), client: this.log_data(),

View File

@ -294,8 +294,8 @@ function initializeStepIdentity(tag: JQuery, event_registry: Registry<emodal.new
function initializeStepMicrophone(tag: JQuery, event_registry: Registry<emodal.newcomer>, modal: Modal) { function initializeStepMicrophone(tag: JQuery, event_registry: Registry<emodal.newcomer>, modal: Modal) {
const microphone_events = new Registry<emodal.settings.microphone>(); const microphone_events = new Registry<emodal.settings.microphone>();
//microphone_events.enable_debug("settings-microphone"); //microphone_events.enable_debug("settings-microphone");
modal_settings.initialize_audio_microphone_controller(microphone_events); //modal_settings.initialize_audio_microphone_controller(microphone_events);
modal_settings.initialize_audio_microphone_view(tag, microphone_events); //modal_settings.initialize_audio_microphone_view(tag, microphone_events);
modal.close_listener.push(() => microphone_events.fire_async("deinitialize")); modal.close_listener.push(() => microphone_events.fire_async("deinitialize"));
let help_animation_done = false; let help_animation_done = false;

View File

@ -32,6 +32,8 @@ import {KeyMapSettings} from "tc-shared/ui/modal/settings/Keymap";
import * as React from "react"; import * as React from "react";
import * as ReactDOM from "react-dom"; import * as ReactDOM from "react-dom";
import {NotificationSettings} from "tc-shared/ui/modal/settings/Notifications"; 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 { export function spawnSettingsModal(default_page?: string) : Modal {
let modal: Modal; let modal: Modal;
@ -443,12 +445,16 @@ function settings_general_chat(container: JQuery, modal: Modal) {
} }
function settings_audio_microphone(container: JQuery, modal: Modal) { function settings_audio_microphone(container: JQuery, modal: Modal) {
const registry = new Registry<events.modal.settings.microphone>(); const registry = new Registry<MicrophoneSettingsEvents>();
registry.enableDebug("settings-microphone"); initialize_audio_microphone_controller(registry);
modal_settings.initialize_audio_microphone_controller(registry);
modal_settings.initialize_audio_microphone_view(container, registry); const entry = <MicrophoneSettings events={registry} />;
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; return;
} }
@ -1698,699 +1704,6 @@ export namespace modal_settings {
event_registry.fire("select-profile", { profile_id: "default" }); event_registry.fire("select-profile", { profile_id: "default" });
event_registry.fire("select-identity-type", { profile_id: "default", identity_type: undefined }); event_registry.fire("select-identity-type", { profile_id: "default", identity_type: undefined });
} }
export function initialize_audio_microphone_controller(event_registry: Registry<events.modal.settings.microphone>) {
/* level meters */
{
const level_meters: {[key: string]:Promise<LevelMeter>} = {};
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<events.modal.settings.microphone>) {
/* 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) { function settings_identity_forum(container: JQuery, modal: Modal, update_profiles: () => any) {

View File

@ -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); } }

View File

@ -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<MicrophoneSettingsEvents>) {
/* level meters */
{
const level_meters: {[key: string]:Promise<LevelMeter>} = {};
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"); });
}
}

View File

@ -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 (
<div key={"applying"} className={cssStyle.iconLoading}>
<img draggable={false} src="img/icon_settings_loading.svg" alt={tr("applying")} />
</div>
);
case "unselected":
return null;
case "selected":
return <ClientIconRenderer key={"selected"} icon={ClientIcon.Apply} />;
}
}
type ActivityBarStatus = { mode: "success" } | { mode: "error", message: string } | { mode: "loading" };
const ActivityBar = (props: { events: Registry<MicrophoneSettingsEvents>, deviceId: string, disabled?: boolean }) => {
const refHider = useRef<HTMLDivElement>();
const [ status, setStatus ] = useState<ActivityBarStatus>({ 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 = <div className={cssStyle.text + " " + cssStyle.error} key={"error"}>{status.message}</div>;
break;
case "loading":
error = <div className={cssStyle.text} key={"loading"}><Translatable>Loading</Translatable>&nbsp;<LoadingDots /></div>;
break;
case "success":
error = undefined;
break;
}
return (
<div className={cssStyle.containerActivityBar + " " + cssStyle.bar + " " + (props.disabled ? cssStyle.disabled : "")}>
<div ref={refHider} className={cssStyle.hider} style={{ width: "100%" }} />
{error}
</div>
)
};
const Microphone = (props: { events: Registry<MicrophoneSettingsEvents>, device: MicrophoneDevice, state: MicrophoneSelectedState, onClick: () => void }) => {
return (
<div className={cssStyle.device + " " + (props.state === "unselected" ? "" : cssStyle.selected)} onClick={props.onClick}>
<div className={cssStyle.containerSelected}>
<MicrophoneStatus state={props.state} />
</div>
<div className={cssStyle.containerName}>
<div className={cssStyle.driver}>{props.device.driver}</div>
<div className={cssStyle.name}>{props.device.name}</div>
</div>
<div className={cssStyle.containerActivity}>
<ActivityBar events={props.events} deviceId={props.device.id} />
</div>
</div>
);
};
const MicrophoneList = (props: { events: Registry<MicrophoneSettingsEvents> }) => {
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<MicrophoneDevice[]>([]);
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 (
<div className={cssStyle.body + " " + cssStyle.containerDevices}>
<div className={cssStyle.overlay + " " + (state !== "audio-not-initialized" ? cssStyle.hidden : undefined)}>
<a>
<Translatable>The web audio play hasn't been initialized yet.</Translatable>&nbsp;
<Translatable>Click somewhere on the base to initialize it.</Translatable>
</a>
</div>
<div className={cssStyle.overlay + " " + (state !== "error" ? cssStyle.hidden : undefined)}>
<a>{error}</a>
</div>
<div className={cssStyle.overlay + " " + (state !== "loading" ? cssStyle.hidden : undefined)}>
<a><Translatable>Loading</Translatable>&nbsp;<LoadingDots/></a>
</div>
{deviceList.map(e => <Microphone
key={"d-" + e.id}
device={e}
events={props.events}
state={e.id === selectedDevice?.deviceId ? selectedDevice.mode === "selecting" ? "applying" : "selected" : "unselected"}
onClick={() => {
if(state !== "normal" || selectedDevice?.mode === "selecting")
return;
props.events.fire("action_set_selected_device", { deviceId: e.id });
}}
/>)}
</div>
)
}
const ListRefreshButton = (props: { events: Registry<MicrophoneSettingsEvents> }) => {
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 <Button disabled={updateTimeout > 0} type={"small"} color={"blue"} onClick={() => props.events.fire("query_devices", { refresh_list: true })}>
<Translatable>Update</Translatable>
</Button>;
}
const VolumeSettings = (props: { events: Registry<MicrophoneSettingsEvents> }) => {
const refSlider = useRef<Slider>();
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 (
<div className={cssStyle.containerVolume}>
<a><Translatable>Volume</Translatable></a>
<Slider
ref={refSlider}
minValue={0}
maxValue={100}
stepSize={1}
value={value === "loading" ? 0 : value}
unit={"%"}
disabled={value === "loading"}
onChange={value => props.events.fire("action_set_setting", { setting: "volume", value: value })}
/>
</div>
)
};
const PPTKeyButton = React.memo((props: { events: Registry<MicrophoneSettingsEvents> }) => {
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 <Button color={"none"} disabled={true} key={"loading"}><Translatable>loading</Translatable> <LoadingDots /></Button>;
} else {
return <Button
color={"none"}
key={"key"}
disabled={!isActive}
onClick={() => {
spawnKeySelect(key => {
if(!key) return;
props.events.fire("action_set_setting", { setting: "ppt-key", value: key });
});
}}
>{key_description(key)}</Button>;
}
});
const PPTDelaySettings = (props: { events: Registry<MicrophoneSettingsEvents> }) => {
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 (
<div className={cssStyle.containerPptDelay}>
<Checkbox
onChange={value => { props.events.fire("action_set_setting", { setting: "ppt-release-delay-active", value: value })}}
disabled={!isActive}
value={delayActive === true}
label={<Translatable>Delay on Push to Talk</Translatable>}
/>
<BoxedInputField
className={cssStyle.input}
disabled={!isActive || delayActive === "loading" || !delayActive}
suffix={"ms"}
inputBox={() => <input
type="number"
min={0}
max={4000}
step={1}
value={delay}
disabled={!isActive || delayActive === "loading" || !delayActive}
onChange={event => {
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 });
}}
/>}
/>
</div>
);
}
const VadSelector = (props: { events: Registry<MicrophoneSettingsEvents> }) => {
const [ selectedType, setSelectedType ] = useState<VadType | "loading">(() => {
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 (
<div className={cssStyle.containerSelectVad}>
<div className={cssStyle.fieldset}>
<div className={cssStyle.containerOption}>
<RadioButton
name={"vad-type"}
onChange={() => { props.events.fire("action_set_setting", { setting: "vad-type", value: "push_to_talk" }) }}
selected={selectedType === "push_to_talk"}
disabled={selectedType === "loading"}
>
<a><Translatable>Push to Talk</Translatable></a>
</RadioButton>
<div className={cssStyle.containerButton}>
<PPTKeyButton events={props.events} />
</div>
</div>
<div className={cssStyle.containerOption}>
<RadioButton
name={"vad-type"}
onChange={() => { props.events.fire("action_set_setting", { setting: "vad-type", value: "threshold" }) }}
selected={selectedType === "threshold"}
disabled={selectedType === "loading"}
>
<a><Translatable>Voice activity detection</Translatable></a>
</RadioButton>
</div>
<div className={cssStyle.containerOption}>
<RadioButton
name={"vad-type"}
onChange={() => { props.events.fire("action_set_setting", { setting: "vad-type", value: "active" }) }}
selected={selectedType === "active"}
disabled={selectedType === "loading"}
>
<a><Translatable>Always active</Translatable></a>
</RadioButton>
</div>
</div>
</div>
);
}
const ThresholdSelector = (props: { events: Registry<MicrophoneSettingsEvents> }) => {
const refSlider = useRef<Slider>();
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 (
<div className={cssStyle.containerSensitivity}>
<div className={cssStyle.containerBar}>
<ActivityBar events={props.events} deviceId={"default"} disabled={!isActive} />
</div>
<Slider
ref={refSlider}
className={cssStyle.slider}
classNameFiller={cssStyle.filler}
minValue={0}
maxValue={100}
stepSize={1}
value={value === "loading" ? 0 : value}
unit={"%"}
disabled={value === "loading" || !isActive}
onChange={value => {}}
/>
</div>
)
};
export const MicrophoneSettings = (props: { events: Registry<MicrophoneSettingsEvents> }) => (
<div className={cssStyle.container}>
<div className={cssStyle.left}>
<div className={cssStyle.header}>
<a><Translatable>Select your Microphone Device</Translatable></a>
<ListRefreshButton events={props.events} />
</div>
<MicrophoneList events={props.events} />
</div>
<div className={cssStyle.right}>
<div className={cssStyle.header}>
<a><Translatable>Microphone Settings</Translatable></a>
</div>
<div className={cssStyle.body}>
<VolumeSettings events={props.events} />
<VadSelector events={props.events} />
</div>
<div className={cssStyle.header}>
<a><Translatable>Sensitivity Settings</Translatable></a>
</div>
<div className={cssStyle.body}>
<ThresholdSelector events={props.events} />
</div>
<div className={cssStyle.header}>
<a><Translatable>Advanced Settings</Translatable></a>
</div>
<div className={cssStyle.body}>
<div className={cssStyle.containerAdvanced}>
<PPTDelaySettings events={props.events} />
</div>
</div>
</div>
</div>
)

View File

@ -77,6 +77,10 @@ html:root {
border-bottom-color: var(--button-yellow); border-bottom-color: var(--button-yellow);
} }
&.color-none {
--keep-alive: true;
}
&.type-normal { } &.type-normal { }
&.type-small { &.type-small {

View File

@ -3,7 +3,7 @@ import * as React from "react";
const cssStyle = require("./Button.scss"); const cssStyle = require("./Button.scss");
export interface ButtonProperties { 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"; type?: "normal" | "small" | "extra-small";
className?: string; className?: string;

View File

@ -46,7 +46,7 @@ html:root {
color: var(--boxed-input-field-placeholder); color: var(--boxed-input-field-placeholder);
}; };
.prefix { .prefix, .suffix {
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
@ -65,6 +65,10 @@ html:root {
@include transition($button_hover_animation_time ease-in-out); @include transition($button_hover_animation_time ease-in-out);
} }
.suffix {
padding-left: 0;
}
&.is-invalid { &.is-invalid {
background-color: var(--boxed-input-field-invalid-background); background-color: var(--boxed-input-field-invalid-background);
border-color: var(--boxed-input-field-invalid-border); border-color: var(--boxed-input-field-invalid-border);
@ -80,7 +84,7 @@ html:root {
color: var(--boxed-input-field-focus-text); color: var(--boxed-input-field-focus-text);
.prefix { .prefix, .suffix {
width: 0; width: 0;
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
@ -125,6 +129,7 @@ html:root {
} }
&.disabled, &:disabled { &.disabled, &:disabled {
@include user-select(none);
background-color: var(--boxed-input-field-disabled-background); 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); @include transition($button_hover_animation_time ease-in-out);
} }

View File

@ -5,6 +5,8 @@ const cssStyle = require("./InputField.scss");
export interface BoxedInputFieldProperties { export interface BoxedInputFieldProperties {
prefix?: string; prefix?: string;
suffix?: string;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
@ -79,6 +81,7 @@ export class BoxedInputField extends React.Component<BoxedInputFieldProperties,
onInput={this.props.onInput && (event => this.props.onInput(event.currentTarget.value))} onInput={this.props.onInput && (event => this.props.onInput(event.currentTarget.value))}
onKeyDown={e => this.onKeyDown(e)} onKeyDown={e => this.onKeyDown(e)}
/>} />}
{this.props.suffix ? <a key={"suffix"} className={cssStyle.suffix}>{this.props.suffix}</a> : undefined}
{this.props.rightIcon ? this.props.rightIcon() : ""} {this.props.rightIcon ? this.props.rightIcon() : ""}
</div> </div>
) )

View File

@ -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;
}
}

View File

@ -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 (
<label>
<div className={cssStyle.container + " " + (props.disabled ? cssStyle.disabled : "")}>
<input
disabled={props.disabled}
type={"radio"}
name={props.name}
onChange={event => props.onChange(event.target.checked)}
checked={props.selected} />
<div className={cssStyle.mark} />
</div>
{props.children}
</label>
)
}

View File

@ -18,6 +18,10 @@ html:root {
--slider-disabled-thumb-color: #4d4d4d; --slider-disabled-thumb-color: #4d4d4d;
} }
.documentClass {
@include user-select(none);
}
.container { .container {
font-size: .8em; font-size: .8em;
@ -40,7 +44,6 @@ html:root {
.filler { .filler {
position: absolute; position: absolute;
left: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;

View File

@ -13,6 +13,9 @@ export interface SliderProperties {
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
classNameFiller?: string;
inverseFiller?: boolean;
unit?: string; unit?: string;
tooltip?: (value: number) => ReactElement | string; tooltip?: (value: number) => ReactElement | string;
@ -36,8 +39,8 @@ export class Slider extends React.Component<SliderProperties, SliderState> {
private readonly mouseListener; private readonly mouseListener;
private readonly mouseUpListener; private readonly mouseUpListener;
private readonly refTooltip = React.createRef<Tooltip>(); protected readonly refTooltip = React.createRef<Tooltip>();
private readonly refSlider = React.createRef<HTMLDivElement>(); protected readonly refSlider = React.createRef<HTMLDivElement>();
constructor(props) { constructor(props) {
super(props); super(props);
@ -87,6 +90,7 @@ export class Slider extends React.Component<SliderProperties, SliderState> {
if(!this.documentListenersRegistered) return; if(!this.documentListenersRegistered) return;
this.documentListenersRegistered = false; this.documentListenersRegistered = false;
document.body.classList.remove(cssStyle.documentClass);
document.removeEventListener('mousemove', this.mouseListener); document.removeEventListener('mousemove', this.mouseListener);
document.removeEventListener('touchmove', this.mouseListener); document.removeEventListener('touchmove', this.mouseListener);
@ -100,6 +104,7 @@ export class Slider extends React.Component<SliderProperties, SliderState> {
if(this.documentListenersRegistered) return; if(this.documentListenersRegistered) return;
this.documentListenersRegistered = true; this.documentListenersRegistered = true;
document.body.classList.add(cssStyle.documentClass);
document.addEventListener('mousemove', this.mouseListener); document.addEventListener('mousemove', this.mouseListener);
document.addEventListener('touchmove', this.mouseListener); document.addEventListener('touchmove', this.mouseListener);
@ -124,7 +129,10 @@ export class Slider extends React.Component<SliderProperties, SliderState> {
onMouseDown={e => this.enableSliderMode(e)} onMouseDown={e => this.enableSliderMode(e)}
onTouchStart={e => this.enableSliderMode(e)} onTouchStart={e => this.enableSliderMode(e)}
> >
<div className={cssStyle.filler} style={{right: (100 - offset) + "%"}} /> <div className={cssStyle.filler + " " + (this.props.classNameFiller || "")} style={{
right: this.props.inverseFiller ? 0 : (100 - offset) + "%",
left: this.props.inverseFiller ? offset + "%" : 0
}} />
<Tooltip ref={this.refTooltip} tooltip={() => this.props.tooltip ? this.props.tooltip(this.state.value) : this.renderTooltip()}> <Tooltip ref={this.refTooltip} tooltip={() => this.props.tooltip ? this.props.tooltip(this.state.value) : this.renderTooltip()}>
<div className={cssStyle.thumb} style={{left: offset + "%"}} /> <div className={cssStyle.thumb} style={{left: offset + "%"}} />
</Tooltip> </Tooltip>
@ -132,14 +140,14 @@ export class Slider extends React.Component<SliderProperties, SliderState> {
); );
} }
private enableSliderMode(event: React.MouseEvent | React.TouchEvent) { protected enableSliderMode(event: React.MouseEvent | React.TouchEvent) {
this.setState({ active: true }); this.setState({ active: true });
this.registerDocumentListener(); this.registerDocumentListener();
this.mouseListener(event); this.mouseListener(event);
this.refTooltip.current?.setState({ forceShow: true }); this.refTooltip.current?.setState({ forceShow: true });
} }
private renderTooltip() { protected renderTooltip() {
return <a>{this.state.value + (this.props.unit || "")}</a>; return <a>{this.state.value + (this.props.unit || "")}</a>;
} }

View File

@ -94,4 +94,4 @@ export class InternalModalController<InstanceType extends InternalModal = Intern
} }
} }
export abstract class InternalModal extends AbstractModal { } export abstract class InternalModal extends AbstractModal {}

View File

@ -54,27 +54,27 @@ export class RecorderProfile {
record_supported: boolean; record_supported: boolean;
private _ppt_hook: KeyHook; private pptHook: KeyHook;
private _ppt_timeout: number; private pptTimeout: number;
private _ppt_hook_registered: boolean; private pptHookRegistered: boolean;
constructor(name: string, volatile?: boolean) { constructor(name: string, volatile?: boolean) {
this.name = name; this.name = name;
this.volatile = typeof(volatile) === "boolean" ? volatile : false; this.volatile = typeof(volatile) === "boolean" ? volatile : false;
this._ppt_hook = { this.pptHook = {
callback_release: () => { callback_release: () => {
if(this._ppt_timeout) if(this.pptTimeout)
clearTimeout(this._ppt_timeout); clearTimeout(this.pptTimeout);
this._ppt_timeout = setTimeout(() => { this.pptTimeout = setTimeout(() => {
const f = this.input.get_filter(filter.Type.STATE) as filter.StateFilter; const f = this.input.get_filter(filter.Type.STATE) as filter.StateFilter;
if(f) f.set_state(true); if(f) f.set_state(true);
}, Math.min(this.config.vad_push_to_talk.delay, 0)); }, Math.max(this.config.vad_push_to_talk.delay, 0));
}, },
callback_press: () => { callback_press: () => {
if(this._ppt_timeout) if(this.pptTimeout)
clearTimeout(this._ppt_timeout); clearTimeout(this.pptTimeout);
const f = this.input.get_filter(filter.Type.STATE) as filter.StateFilter; const f = this.input.get_filter(filter.Type.STATE) as filter.StateFilter;
if(f) f.set_state(false); if(f) f.set_state(false);
@ -82,7 +82,7 @@ export class RecorderProfile {
cancel: false cancel: false
} as KeyHook; } as KeyHook;
this._ppt_hook_registered = false; this.pptHookRegistered = false;
this.record_supported = true; this.record_supported = true;
} }
@ -152,7 +152,7 @@ export class RecorderProfile {
} }
} }
private save(enforce?: boolean) { private save() {
if(!this.volatile) if(!this.volatile)
settings.changeGlobal(Settings.FN_PROFILE_RECORD(this.name), this.config); settings.changeGlobal(Settings.FN_PROFILE_RECORD(this.name), this.config);
} }
@ -161,9 +161,9 @@ export class RecorderProfile {
if(!this.input) return; if(!this.input) return;
this.input.clear_filter(); this.input.clear_filter();
if(this._ppt_hook_registered) { if(this.pptHookRegistered) {
ppt.unregister_key_hook(this._ppt_hook); ppt.unregister_key_hook(this.pptHook);
this._ppt_hook_registered = false; this.pptHookRegistered = false;
} }
if(this.config.vad_type === "threshold") { if(this.config.vad_type === "threshold") {
@ -174,6 +174,7 @@ export class RecorderProfile {
/* legacy client support */ /* legacy client support */
if('set_attack_smooth' in filter_) if('set_attack_smooth' in filter_)
filter_.set_attack_smooth(.25); filter_.set_attack_smooth(.25);
if('set_release_smooth' in filter_) if('set_release_smooth' in filter_)
filter_.set_release_smooth(.9); filter_.set_release_smooth(.9);
@ -183,9 +184,9 @@ export class RecorderProfile {
await filter_.set_state(true); await filter_.set_state(true);
for(const key of ["key_alt", "key_ctrl", "key_shift", "key_windows", "key_code"]) for(const key of ["key_alt", "key_ctrl", "key_shift", "key_windows", "key_code"])
this._ppt_hook[key] = this.config.vad_push_to_talk[key]; this.pptHook[key] = this.config.vad_push_to_talk[key];
ppt.register_key_hook(this._ppt_hook); ppt.register_key_hook(this.pptHook);
this._ppt_hook_registered = true; this.pptHookRegistered = true;
this.input.enable_filter(filter.Type.STATE); this.input.enable_filter(filter.Type.STATE);
} else if(this.config.vad_type === "active") {} } else if(this.config.vad_type === "active") {}
@ -254,7 +255,7 @@ export class RecorderProfile {
} }
current_device() : InputDevice | undefined { return this.input.current_device(); } current_device() : InputDevice | undefined { return this.input?.current_device(); }
set_device(device: InputDevice | undefined) : Promise<void> { set_device(device: InputDevice | undefined) : Promise<void> {
this.config.device_id = device ? device.unique_id : undefined; this.config.device_id = device ? device.unique_id : undefined;
this.save(); this.save();