aboutsummaryrefslogtreecommitdiff
path: root/modules/ext
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-06-22 18:00:43 +0200
committerAgando <roduccomm@gmail.com>2026-06-22 18:00:43 +0200
commit6f6161562c5f1028256f3163083b665719741646 (patch)
treeccc9ab59e8433555557b6db8b6c1d4940b8652a4 /modules/ext
parent82270d6f2685d84d5c9b4a454ae7260de75d34cf (diff)
auto: 2026-06-22_18:00 changes
Diffstat (limited to 'modules/ext')
-rw-r--r--modules/ext/critical-ext-bundle.js183
-rw-r--r--modules/ext/editor.js47
-rw-r--r--modules/ext/filemgr.js136
-rw-r--r--modules/ext/media.js508
-rw-r--r--modules/ext/voice.js59
5 files changed, 923 insertions, 10 deletions
diff --git a/modules/ext/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js
index 4242ebf..3c5d54b 100644
--- a/modules/ext/critical-ext-bundle.js
+++ b/modules/ext/critical-ext-bundle.js
@@ -26,6 +26,8 @@ class EditorModule {
const modal = document.getElementById('code-editor');
const title = document.getElementById('code-editor-title');
const textarea = document.getElementById('code-editor-textarea');
+ const gutter = document.getElementById('code-editor-gutter');
+ const statusEl = document.getElementById('code-editor-status');
if (!modal || !textarea) return this.terminal.print('Editor modal not found', 'error');
// Read existing file content or use empty for new files
const content = this.terminal.fs.readFile(path);
@@ -36,6 +38,51 @@ class EditorModule {
textarea.value = fileContent;
modal.style.display = 'flex';
textarea.focus();
+ // Editor enhancements: line numbers, Tab, Ctrl+S
+ const updateGutter = () => {
+ const lines = textarea.value.split('\n').length;
+ let html = '';
+ for (let i = 1; i <= lines; i++) html += i + '\n';
+ gutter.textContent = '';
+ gutter.textContent = html;
+ };
+ const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; };
+ const updateStatus = () => {
+ const lines = textarea.value.split('\n').length;
+ const chars = textarea.value.length;
+ statusEl.textContent = `${lines} lines ยท ${chars} chars`;
+ };
+ updateGutter();
+ updateStatus();
+ textarea.onscroll = syncScroll;
+ textarea.oninput = () => { updateGutter(); updateStatus(); };
+ textarea.onkeydown = (e) => {
+ if (e.key === 'Tab') { e.preventDefault(); insertTab(textarea, e.shiftKey); updateGutter(); }
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveBtn.click(); }
+ };
+ function insertTab(el, shift) {
+ const s = el.selectionStart, e2 = el.selectionEnd;
+ if (shift) {
+ const before = el.value.substring(0, s);
+ const lineStart = before.lastIndexOf('\n') + 1;
+ if (lineStart < s && el.value.substring(lineStart, lineStart + 1) === '\t') {
+ el.value = el.value.substring(0, lineStart) + el.value.substring(lineStart + 1);
+ el.selectionStart = el.selectionEnd = s - 1;
+ }
+ } else {
+ el.value = el.value.substring(0, s) + '\t' + el.value.substring(e2);
+ el.selectionStart = el.selectionEnd = s + 1;
+ }
+ }
+ // Fullscreen toggle
+ window._editorToggleFullscreen = () => {
+ const content = modal.querySelector('.modal-content');
+ if (content.style.maxWidth === '100vw') {
+ content.style.maxWidth = '90vw'; content.style.maxHeight = '90vh'; content.style.width = '900px'; content.style.height = '80vh';
+ } else {
+ content.style.maxWidth = '100vw'; content.style.maxHeight = '100vh'; content.style.width = '100vw'; content.style.height = '100vh';
+ }
+ };
// Override save button to write back to VFS
const saveBtn = modal.querySelector('.modal-footer .btn:last-child');
const cancelBtn = modal.querySelector('.modal-footer .btn-danger');
@@ -809,6 +856,8 @@ class FileManagerModule {
this.currentPath = [];
this._overlay = null;
this._selectedFile = null;
+ this._multiSelect = new Set(); // v4.5.78: multi-select for batch ops
+ this._clipboard = { mode: null, paths: [] }; // 'copy' or 'cut'
this._searchFilter = '';
this._rawMode = false;
this._inlineHtml = false;
@@ -1208,6 +1257,25 @@ class FileManagerModule {
};
toolbar.appendChild(searchInput);
+ // v4.5.78: Batch operation buttons
+ const batchBar = document.createElement('div');
+ batchBar.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;';
+ const mkBtn = (label, title, action) => {
+ const b = document.createElement('button');
+ b.textContent = label;
+ b.title = title;
+ b.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 8px;border-radius:4px;font-family:inherit;font-size:0.85em;min-height:32px;touch-action:manipulation;';
+ b.onclick = () => action();
+ return b;
+ };
+ batchBar.appendChild(mkBtn('๐Ÿ“‹ Copy', 'Copy selected files', () => this._batchCopy()));
+ batchBar.appendChild(mkBtn('โœ‚๏ธ Cut', 'Cut selected files', () => this._batchCut()));
+ batchBar.appendChild(mkBtn('๐Ÿ“ฅ Paste', 'Paste from clipboard', () => this._batchPaste()));
+ batchBar.appendChild(mkBtn('๐Ÿ—‘๏ธ Del', 'Delete selected files', () => this._batchDelete()));
+ batchBar.appendChild(mkBtn('๐Ÿ“ Rename', 'Rename selected file', () => this._batchRename()));
+ batchBar.appendChild(mkBtn('โ˜ All', 'Toggle select all', () => this._toggleSelectAll()));
+ toolbar.appendChild(batchBar);
+
overlay.appendChild(toolbar);
// Main area โ€” wraps sidebar + preview in a responsive flex container
@@ -1429,17 +1497,25 @@ class FileManagerModule {
const item = document.createElement('div');
const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name;
const isSelected = this._selectedFile === fullPath;
+ const isMulti = this._multiSelect.has(fullPath);
item.dataset.path = fullPath;
item.dataset.type = 'file';
- item.dataset.selected = isSelected ? 'true' : 'false';
- const bgColor = isSelected ? 'var(--bg-tertiary)' : 'transparent';
+ item.dataset.selected = (isSelected || isMulti) ? 'true' : 'false';
+ const bgColor = (isSelected || isMulti) ? 'var(--bg-tertiary)' : 'transparent';
item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;background:' + bgColor + ';';
- if (isSelected) {
+ if (isSelected || isMulti) {
item.style.borderLeft = '3px solid var(--accent-color)';
}
item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; };
item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; };
- item.onclick = () => {
+ item.onclick = (e) => {
+ // v4.5.78: Ctrl+click or checkbox for multi-select
+ if (e.ctrlKey || e.metaKey) {
+ if (this._multiSelect.has(fullPath)) this._multiSelect.delete(fullPath);
+ else this._multiSelect.add(fullPath);
+ this._refreshFileList();
+ return;
+ }
this._selectedFile = fullPath;
this._highlightSelected();
const content = this.terminal.fs.readFile(fullPath);
@@ -1472,13 +1548,110 @@ class FileManagerModule {
if (!sidebar) return;
const items = sidebar.querySelectorAll('[data-type="file"]');
items.forEach(item => {
- const selected = item.dataset.path === this._selectedFile;
+ const selected = item.dataset.path === this._selectedFile || this._multiSelect.has(item.dataset.path);
item.dataset.selected = selected ? 'true' : 'false';
item.style.background = selected ? 'var(--bg-tertiary)' : 'transparent';
item.style.borderLeft = selected ? '3px solid var(--accent-color)' : '3px solid transparent';
});
}
+ // v4.5.78: Batch operations
+ _getSelectedPaths() {
+ const paths = [...this._multiSelect];
+ if (this._selectedFile && !paths.includes(this._selectedFile)) paths.push(this._selectedFile);
+ return paths;
+ }
+
+ _batchCopy() {
+ const paths = this._getSelectedPaths();
+ if (!paths.length) { this._updateStatus('No files selected'); return; }
+ this._clipboard = { mode: 'copy', paths: [...paths] };
+ this._multiSelect.clear();
+ this._updateStatus('๐Ÿ“‹ Copied ' + paths.length + ' item(s)');
+ }
+
+ _batchCut() {
+ const paths = this._getSelectedPaths();
+ if (!paths.length) { this._updateStatus('No files selected'); return; }
+ this._clipboard = { mode: 'cut', paths: [...paths] };
+ this._multiSelect.clear();
+ this._updateStatus('โœ‚๏ธ Cut ' + paths.length + ' item(s)');
+ }
+
+ _batchPaste() {
+ if (!this._clipboard.paths.length) { this._updateStatus('Clipboard empty'); return; }
+ const dest = '/' + this.currentPath.join('/');
+ let count = 0;
+ this._clipboard.paths.forEach(src => {
+ const name = src.split('/').pop();
+ const destPath = dest === '/' ? '/' + name : dest + '/' + name;
+ try {
+ if (this._clipboard.mode === 'copy') {
+ const content = this.terminal.fs.readFile(src);
+ if (content !== null) { this.terminal.fs.writeFile(destPath, content); count++; }
+ } else if (this._clipboard.mode === 'cut') {
+ const content = this.terminal.fs.readFile(src);
+ if (content !== null) {
+ this.terminal.fs.writeFile(destPath, content);
+ this.terminal.fs.deleteFile(src);
+ count++;
+ }
+ }
+ } catch (e) { /* skip errors */ }
+ });
+ if (this._clipboard.mode === 'cut') this._clipboard = { mode: null, paths: [] };
+ this._multiSelect.clear();
+ this._refreshFileList();
+ this._updateStatus('๐Ÿ“ฅ Pasted ' + count + ' item(s)');
+ }
+
+ _batchDelete() {
+ const paths = this._getSelectedPaths();
+ if (!paths.length) { this._updateStatus('No files selected'); return; }
+ if (!confirm('Delete ' + paths.length + ' file(s)?')) return;
+ let count = 0;
+ paths.forEach(p => {
+ try { this.terminal.fs.deleteFile(p); count++; } catch (e) { /* skip */ }
+ });
+ this._multiSelect.clear();
+ this._selectedFile = null;
+ this._refreshFileList();
+ this._updateStatus('๐Ÿ—‘๏ธ Deleted ' + count + ' file(s)');
+ }
+
+ _batchRename() {
+ const path = this._selectedFile || (this._multiSelect.size === 1 ? [...this._multiSelect][0] : null);
+ if (!path) { this._updateStatus('Select one file to rename'); return; }
+ const oldName = path.split('/').pop();
+ const newName = prompt('Rename to:', oldName);
+ if (!newName || newName === oldName) return;
+ const dir = path.substring(0, path.lastIndexOf('/')) || '/';
+ const newPath = dir === '/' ? '/' + newName : dir + '/' + newName;
+ try {
+ const content = this.terminal.fs.readFile(path);
+ if (content === null) { this._updateStatus('Cannot read file'); return; }
+ this.terminal.fs.writeFile(newPath, content);
+ this.terminal.fs.deleteFile(path);
+ this._selectedFile = newPath;
+ this._multiSelect.clear();
+ this._refreshFileList();
+ this._updateStatus('๐Ÿ“ Renamed to ' + newName);
+ } catch (e) { this._updateStatus('Rename failed: ' + e.message); }
+ }
+
+ _toggleSelectAll() {
+ const sidebar = document.getElementById('filemgr-sidebar');
+ if (!sidebar) return;
+ const fileItems = sidebar.querySelectorAll('[data-type="file"]');
+ if (this._multiSelect.size >= fileItems.length) {
+ this._multiSelect.clear();
+ } else {
+ fileItems.forEach(item => this._multiSelect.add(item.dataset.path));
+ }
+ this._refreshFileList();
+ this._updateStatus(this._multiSelect.size + ' selected');
+ }
+
_renderPreview(path, content) {
const preview = document.getElementById('filemgr-preview');
if (!preview) return;
diff --git a/modules/ext/editor.js b/modules/ext/editor.js
index 3a5f269..07205b9 100644
--- a/modules/ext/editor.js
+++ b/modules/ext/editor.js
@@ -24,6 +24,8 @@ class EditorModule {
const modal = document.getElementById('code-editor');
const title = document.getElementById('code-editor-title');
const textarea = document.getElementById('code-editor-textarea');
+ const gutter = document.getElementById('code-editor-gutter');
+ const statusEl = document.getElementById('code-editor-status');
if (!modal || !textarea) return this.terminal.print('Editor modal not found', 'error');
// Read existing file content or use empty for new files
const content = this.terminal.fs.readFile(path);
@@ -34,6 +36,51 @@ class EditorModule {
textarea.value = fileContent;
modal.style.display = 'flex';
textarea.focus();
+ // Editor enhancements: line numbers, Tab, Ctrl+S
+ const updateGutter = () => {
+ const lines = textarea.value.split('\n').length;
+ let html = '';
+ for (let i = 1; i <= lines; i++) html += i + '\n';
+ gutter.textContent = '';
+ gutter.textContent = html;
+ };
+ const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; };
+ const updateStatus = () => {
+ const lines = textarea.value.split('\n').length;
+ const chars = textarea.value.length;
+ statusEl.textContent = `${lines} lines ยท ${chars} chars`;
+ };
+ updateGutter();
+ updateStatus();
+ textarea.onscroll = syncScroll;
+ textarea.oninput = () => { updateGutter(); updateStatus(); };
+ textarea.onkeydown = (e) => {
+ if (e.key === 'Tab') { e.preventDefault(); insertTab(textarea, e.shiftKey); updateGutter(); }
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveBtn.click(); }
+ };
+ function insertTab(el, shift) {
+ const s = el.selectionStart, e2 = el.selectionEnd;
+ if (shift) {
+ const before = el.value.substring(0, s);
+ const lineStart = before.lastIndexOf('\n') + 1;
+ if (lineStart < s && el.value.substring(lineStart, lineStart + 1) === '\t') {
+ el.value = el.value.substring(0, lineStart) + el.value.substring(lineStart + 1);
+ el.selectionStart = el.selectionEnd = s - 1;
+ }
+ } else {
+ el.value = el.value.substring(0, s) + '\t' + el.value.substring(e2);
+ el.selectionStart = el.selectionEnd = s + 1;
+ }
+ }
+ // Fullscreen toggle
+ window._editorToggleFullscreen = () => {
+ const content = modal.querySelector('.modal-content');
+ if (content.style.maxWidth === '100vw') {
+ content.style.maxWidth = '90vw'; content.style.maxHeight = '90vh'; content.style.width = '900px'; content.style.height = '80vh';
+ } else {
+ content.style.maxWidth = '100vw'; content.style.maxHeight = '100vh'; content.style.width = '100vw'; content.style.height = '100vh';
+ }
+ };
// Override save button to write back to VFS
const saveBtn = modal.querySelector('.modal-footer .btn:last-child');
const cancelBtn = modal.querySelector('.modal-footer .btn-danger');
diff --git a/modules/ext/filemgr.js b/modules/ext/filemgr.js
index 141bee4..acbb963 100644
--- a/modules/ext/filemgr.js
+++ b/modules/ext/filemgr.js
@@ -8,6 +8,8 @@ class FileManagerModule {
this.currentPath = [];
this._overlay = null;
this._selectedFile = null;
+ this._multiSelect = new Set(); // v4.5.78: multi-select for batch ops
+ this._clipboard = { mode: null, paths: [] }; // 'copy' or 'cut'
this._searchFilter = '';
this._rawMode = false;
this._inlineHtml = false;
@@ -407,6 +409,25 @@ class FileManagerModule {
};
toolbar.appendChild(searchInput);
+ // v4.5.78: Batch operation buttons
+ const batchBar = document.createElement('div');
+ batchBar.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;';
+ const mkBtn = (label, title, action) => {
+ const b = document.createElement('button');
+ b.textContent = label;
+ b.title = title;
+ b.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 8px;border-radius:4px;font-family:inherit;font-size:0.85em;min-height:32px;touch-action:manipulation;';
+ b.onclick = () => action();
+ return b;
+ };
+ batchBar.appendChild(mkBtn('๐Ÿ“‹ Copy', 'Copy selected files', () => this._batchCopy()));
+ batchBar.appendChild(mkBtn('โœ‚๏ธ Cut', 'Cut selected files', () => this._batchCut()));
+ batchBar.appendChild(mkBtn('๐Ÿ“ฅ Paste', 'Paste from clipboard', () => this._batchPaste()));
+ batchBar.appendChild(mkBtn('๐Ÿ—‘๏ธ Del', 'Delete selected files', () => this._batchDelete()));
+ batchBar.appendChild(mkBtn('๐Ÿ“ Rename', 'Rename selected file', () => this._batchRename()));
+ batchBar.appendChild(mkBtn('โ˜ All', 'Toggle select all', () => this._toggleSelectAll()));
+ toolbar.appendChild(batchBar);
+
overlay.appendChild(toolbar);
// Main area โ€” wraps sidebar + preview in a responsive flex container
@@ -628,17 +649,25 @@ class FileManagerModule {
const item = document.createElement('div');
const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name;
const isSelected = this._selectedFile === fullPath;
+ const isMulti = this._multiSelect.has(fullPath);
item.dataset.path = fullPath;
item.dataset.type = 'file';
- item.dataset.selected = isSelected ? 'true' : 'false';
- const bgColor = isSelected ? 'var(--bg-tertiary)' : 'transparent';
+ item.dataset.selected = (isSelected || isMulti) ? 'true' : 'false';
+ const bgColor = (isSelected || isMulti) ? 'var(--bg-tertiary)' : 'transparent';
item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;background:' + bgColor + ';';
- if (isSelected) {
+ if (isSelected || isMulti) {
item.style.borderLeft = '3px solid var(--accent-color)';
}
item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; };
item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; };
- item.onclick = () => {
+ item.onclick = (e) => {
+ // v4.5.78: Ctrl+click or checkbox for multi-select
+ if (e.ctrlKey || e.metaKey) {
+ if (this._multiSelect.has(fullPath)) this._multiSelect.delete(fullPath);
+ else this._multiSelect.add(fullPath);
+ this._refreshFileList();
+ return;
+ }
this._selectedFile = fullPath;
this._highlightSelected();
const content = this.terminal.fs.readFile(fullPath);
@@ -671,13 +700,110 @@ class FileManagerModule {
if (!sidebar) return;
const items = sidebar.querySelectorAll('[data-type="file"]');
items.forEach(item => {
- const selected = item.dataset.path === this._selectedFile;
+ const selected = item.dataset.path === this._selectedFile || this._multiSelect.has(item.dataset.path);
item.dataset.selected = selected ? 'true' : 'false';
item.style.background = selected ? 'var(--bg-tertiary)' : 'transparent';
item.style.borderLeft = selected ? '3px solid var(--accent-color)' : '3px solid transparent';
});
}
+ // v4.5.78: Batch operations
+ _getSelectedPaths() {
+ const paths = [...this._multiSelect];
+ if (this._selectedFile && !paths.includes(this._selectedFile)) paths.push(this._selectedFile);
+ return paths;
+ }
+
+ _batchCopy() {
+ const paths = this._getSelectedPaths();
+ if (!paths.length) { this._updateStatus('No files selected'); return; }
+ this._clipboard = { mode: 'copy', paths: [...paths] };
+ this._multiSelect.clear();
+ this._updateStatus('๐Ÿ“‹ Copied ' + paths.length + ' item(s)');
+ }
+
+ _batchCut() {
+ const paths = this._getSelectedPaths();
+ if (!paths.length) { this._updateStatus('No files selected'); return; }
+ this._clipboard = { mode: 'cut', paths: [...paths] };
+ this._multiSelect.clear();
+ this._updateStatus('โœ‚๏ธ Cut ' + paths.length + ' item(s)');
+ }
+
+ _batchPaste() {
+ if (!this._clipboard.paths.length) { this._updateStatus('Clipboard empty'); return; }
+ const dest = '/' + this.currentPath.join('/');
+ let count = 0;
+ this._clipboard.paths.forEach(src => {
+ const name = src.split('/').pop();
+ const destPath = dest === '/' ? '/' + name : dest + '/' + name;
+ try {
+ if (this._clipboard.mode === 'copy') {
+ const content = this.terminal.fs.readFile(src);
+ if (content !== null) { this.terminal.fs.writeFile(destPath, content); count++; }
+ } else if (this._clipboard.mode === 'cut') {
+ const content = this.terminal.fs.readFile(src);
+ if (content !== null) {
+ this.terminal.fs.writeFile(destPath, content);
+ this.terminal.fs.deleteFile(src);
+ count++;
+ }
+ }
+ } catch (e) { /* skip errors */ }
+ });
+ if (this._clipboard.mode === 'cut') this._clipboard = { mode: null, paths: [] };
+ this._multiSelect.clear();
+ this._refreshFileList();
+ this._updateStatus('๐Ÿ“ฅ Pasted ' + count + ' item(s)');
+ }
+
+ _batchDelete() {
+ const paths = this._getSelectedPaths();
+ if (!paths.length) { this._updateStatus('No files selected'); return; }
+ if (!confirm('Delete ' + paths.length + ' file(s)?')) return;
+ let count = 0;
+ paths.forEach(p => {
+ try { this.terminal.fs.deleteFile(p); count++; } catch (e) { /* skip */ }
+ });
+ this._multiSelect.clear();
+ this._selectedFile = null;
+ this._refreshFileList();
+ this._updateStatus('๐Ÿ—‘๏ธ Deleted ' + count + ' file(s)');
+ }
+
+ _batchRename() {
+ const path = this._selectedFile || (this._multiSelect.size === 1 ? [...this._multiSelect][0] : null);
+ if (!path) { this._updateStatus('Select one file to rename'); return; }
+ const oldName = path.split('/').pop();
+ const newName = prompt('Rename to:', oldName);
+ if (!newName || newName === oldName) return;
+ const dir = path.substring(0, path.lastIndexOf('/')) || '/';
+ const newPath = dir === '/' ? '/' + newName : dir + '/' + newName;
+ try {
+ const content = this.terminal.fs.readFile(path);
+ if (content === null) { this._updateStatus('Cannot read file'); return; }
+ this.terminal.fs.writeFile(newPath, content);
+ this.terminal.fs.deleteFile(path);
+ this._selectedFile = newPath;
+ this._multiSelect.clear();
+ this._refreshFileList();
+ this._updateStatus('๐Ÿ“ Renamed to ' + newName);
+ } catch (e) { this._updateStatus('Rename failed: ' + e.message); }
+ }
+
+ _toggleSelectAll() {
+ const sidebar = document.getElementById('filemgr-sidebar');
+ if (!sidebar) return;
+ const fileItems = sidebar.querySelectorAll('[data-type="file"]');
+ if (this._multiSelect.size >= fileItems.length) {
+ this._multiSelect.clear();
+ } else {
+ fileItems.forEach(item => this._multiSelect.add(item.dataset.path));
+ }
+ this._refreshFileList();
+ this._updateStatus(this._multiSelect.size + ' selected');
+ }
+
_renderPreview(path, content) {
const preview = document.getElementById('filemgr-preview');
if (!preview) return;
diff --git a/modules/ext/media.js b/modules/ext/media.js
new file mode 100644
index 0000000..5723e27
--- /dev/null
+++ b/modules/ext/media.js
@@ -0,0 +1,508 @@
+// newOS4 Media Analysis Module (v4.5.79)
+// Commands: analyze <path> [--flags]
+// Uses gemini-3-flash-preview:cloud for vision (images, video frames)
+// Uses deepseek-v4-flash:cloud for text analysis (audio features, summaries)
+class MediaModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._visionModel = 'gemini-3-flash-preview:cloud';
+ this._textModel = 'deepseek-v4-flash:cloud';
+ this._maxFrames = 10; // max video frames to analyze
+ this._frameInterval = 5; // seconds between video frames
+ }
+
+ getHelpInfo() {
+ return {
+ 'analyze': 'analyze <file> โ€” Analyze image/audio/video. Flags: --frames N, --spectrogram, --beats, --scene'
+ };
+ }
+
+ registerCommands() {
+ this.commands['analyze'] = (args) => this.handle(args);
+ }
+
+ handle(args) {
+ if (!args[0]) {
+ this.terminal.print('Usage: analyze <file> [--frames N] [--spectrogram] [--beats] [--scene]', 'warning');
+ return;
+ }
+ const path = args[0];
+ const flags = args.slice(1);
+ const ext = path.split('.').pop().toLowerCase();
+
+ // Check file exists
+ const content = this.terminal.fs.readFile(path);
+ if (content === null || content === undefined) {
+ this.terminal.print('File not found: ' + path, 'error');
+ return;
+ }
+
+ this.terminal.print('๐Ÿ” Analyzing ' + path + '...', 'info');
+
+ if (this._isImageExt(ext)) {
+ this._analyzeImage(path, content);
+ } else if (this._isAudioExt(ext)) {
+ this._analyzeAudio(path, content, flags);
+ } else if (this._isVideoExt(ext)) {
+ this._analyzeVideo(path, content, flags);
+ } else {
+ this.terminal.print('Unsupported file type: .' + ext + '. Supported: png, jpg, webp, gif, svg, wav, mp3, ogg, mp4, webm', 'error');
+ }
+ }
+
+ // โ”€โ”€โ”€ Image Analysis โ”€โ”€โ”€
+ async _analyzeImage(path, content) {
+ this.terminal.print('๐Ÿ–ผ๏ธ Sending image to vision model...', 'info');
+ try {
+ // VFS may store binary as base64 string or raw text
+ let base64 = content;
+ // If content looks like raw text (not base64), try to re-encode
+ if (!this._isValidBase64(base64) && typeof content === 'string') {
+ // It's text content, not an image โ€” can't analyze
+ this.terminal.print('โš ๏ธ File appears to be text, not a binary image', 'warning');
+ return;
+ }
+ const result = await this._callVision(base64, 'Describe this image in detail. What do you see? Include colors, objects, text, people, and any notable features.');
+ if (result.error) {
+ this.terminal.print('๐Ÿ–ผ๏ธ Vision error: ' + result.error, 'error');
+ return;
+ }
+ this.terminal.print('๐Ÿ–ผ๏ธ Image Analysis:', 'success');
+ this.terminal.print(result.text, 'info');
+ } catch (e) {
+ this.terminal.print('๐Ÿ–ผ๏ธ Analysis failed: ' + e.message, 'error');
+ }
+ }
+
+ // โ”€โ”€โ”€ Audio Analysis โ”€โ”€โ”€
+ async _analyzeAudio(path, content, flags) {
+ this.terminal.print('๐ŸŽต Decoding audio...', 'info');
+ try {
+ // Convert base64 to ArrayBuffer for Web Audio API
+ let arrayBuffer;
+ if (this._isValidBase64(content)) {
+ arrayBuffer = this._base64ToArrayBuffer(content);
+ } else if (content instanceof ArrayBuffer) {
+ arrayBuffer = content;
+ } else {
+ this.terminal.print('โš ๏ธ Cannot decode audio โ€” file is not in a supported binary format in VFS', 'warning');
+ return;
+ }
+
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
+ const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
+
+ // Extract features
+ const features = this._extractAudioFeatures(audioBuffer, flags);
+ ctx.close();
+
+ // Display features
+ this.terminal.print('๐ŸŽต Audio Features:', 'success');
+ this.terminal.print(' Duration: ' + features.duration.toFixed(1) + 's', 'info');
+ this.terminal.print(' Sample Rate: ' + features.sampleRate + ' Hz', 'info');
+ this.terminal.print(' Channels: ' + features.channels, 'info');
+ this.terminal.print(' RMS (avg loudness): ' + features.rms.toFixed(3), 'info');
+ this.terminal.print(' Peak amplitude: ' + features.peak.toFixed(3), 'info');
+ this.terminal.print(' Dynamic range: ' + features.dynamicRange.toFixed(3), 'info');
+
+ if (flags.includes('--spectrogram')) {
+ this.terminal.print('\n๐Ÿ“Š Spectrogram (frequency bands over time):', 'info');
+ this.terminal.print(features.spectrogram, '');
+ }
+
+ if (flags.includes('--beats')) {
+ this.terminal.print('\n๐Ÿฅ Beat detection:', 'info');
+ this.terminal.print(' Estimated BPM: ' + features.bpm, 'info');
+ this.terminal.print(' Beat times: ' + features.beatTimes.join(', '), 'info');
+ }
+
+ // Send features to text model for AI interpretation
+ this.terminal.print('\n๐Ÿค– AI interpretation (deepseek-v4-flash)...', 'info');
+ const prompt = `Analyze these audio features and describe what the audio likely sounds like. Consider the genre, mood, and characteristics:
+
+Duration: ${features.duration.toFixed(1)}s
+Sample Rate: ${features.sampleRate} Hz
+Channels: ${features.channels}
+RMS (loudness): ${features.rms.toFixed(3)}
+Peak: ${features.peak.toFixed(3)}
+Dynamic Range: ${features.dynamicRange.toFixed(3)}
+Estimated BPM: ${features.bpm}
+Spectral centroid: ${features.spectralCentroid.toFixed(0)} Hz
+Zero-crossing rate: ${features.zcr.toFixed(4)}
+Energy distribution: ${features.energyBands.join(', ')}
+
+Provide a brief, insightful interpretation.`;
+
+ const result = await this._callText(prompt);
+ if (!result.error) this.terminal.print(result.text, 'info');
+ else this.terminal.print('AI interpretation unavailable: ' + result.error, 'warning');
+
+ } catch (e) {
+ this.terminal.print('๐ŸŽต Audio analysis failed: ' + e.message, 'error');
+ }
+ }
+
+ // โ”€โ”€โ”€ Video Analysis โ”€โ”€โ”€
+ async _analyzeVideo(path, content, flags) {
+ // Parse flags
+ let maxFrames = this._maxFrames;
+ const framesIdx = flags.indexOf('--frames');
+ if (framesIdx !== -1 && flags[framesIdx + 1]) maxFrames = parseInt(flags[framesIdx + 1]) || 10;
+ const sceneMode = flags.includes('--scene');
+
+ this.terminal.print('๐ŸŽฌ Extracting video frames...', 'info');
+
+ try {
+ // Create video element
+ const video = document.createElement('video');
+ video.muted = true;
+ video.crossOrigin = 'anonymous';
+
+ // Load from base64 or URL
+ let videoUrl;
+ if (this._isValidBase64(content)) {
+ const mime = this._getMimeType(path);
+ videoUrl = 'data:' + mime + ';base64,' + content;
+ } else {
+ this.terminal.print('โš ๏ธ Cannot load video โ€” VFS does not store video as base64', 'warning');
+ return;
+ }
+
+ await new Promise((resolve, reject) => {
+ video.onloadedmetadata = resolve;
+ video.onerror = () => reject(new Error('Video decode failed โ€” unsupported codec?'));
+ video.src = videoUrl;
+ });
+
+ const duration = video.duration;
+ this.terminal.print(' Duration: ' + duration.toFixed(1) + 's', 'info');
+ this.terminal.print(' Extracting ' + maxFrames + ' frames...', 'info');
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 320; // downscale for API efficiency
+ canvas.height = 180;
+ const ctx = canvas.getContext('2d');
+
+ const framePromises = [];
+ const frameInterval = duration / maxFrames;
+
+ for (let i = 0; i < maxFrames; i++) {
+ const t = Math.min(i * frameInterval, duration - 0.1);
+ framePromises.push(this._extractFrame(video, canvas, ctx, t, i));
+ }
+
+ const frames = await Promise.all(framePromises);
+ video.src = '';
+
+ this.terminal.print(' Frames extracted: ' + frames.length, 'info');
+
+ if (sceneMode) {
+ // Detect scene changes by comparing consecutive frames
+ const sceneChanges = this._detectSceneChanges(frames);
+ this.terminal.print(' Scene changes at: ' + sceneChanges.map(s => s.toFixed(1) + 's').join(', '), 'info');
+ }
+
+ // Send frames to vision model
+ this.terminal.print('\n๐Ÿค– Analyzing frames with vision model...', 'info');
+ const descriptions = [];
+ for (let i = 0; i < frames.length; i++) {
+ const desc = await this._callVision(frames[i].base64, `This is frame ${i+1}/${frames.length} at ${frames[i].time.toFixed(1)}s from a video. Describe what you see briefly.`);
+ if (!desc.error) {
+ descriptions.push(`[${frames[i].time.toFixed(1)}s] ${desc.text}`);
+ this.terminal.print(` Frame ${i+1}/${frames.length} (${frames[i].time.toFixed(1)}s): ${desc.text.substring(0, 100)}`, 'info');
+ }
+ }
+
+ // Summarize with text model
+ if (descriptions.length > 0) {
+ this.terminal.print('\n๐ŸŽฌ Video Summary:', 'success');
+ const summaryPrompt = `These are descriptions of ${descriptions.length} frames from a video, taken at regular intervals:
+${descriptions.join('\n')}
+
+Provide a brief summary of what happens in this video. Include the overall scene, any changes, and the general content.`;
+ const summary = await this._callText(summaryPrompt);
+ if (!summary.error) this.terminal.print(summary.text, 'info');
+ }
+
+ } catch (e) {
+ this.terminal.print('๐ŸŽฌ Video analysis failed: ' + e.message, 'error');
+ }
+ }
+
+ // โ”€โ”€โ”€ Helper: Extract single frame โ”€โ”€โ”€
+ async _extractFrame(video, canvas, ctx, time, index) {
+ return new Promise((resolve, reject) => {
+ video.currentTime = time;
+ video.onseeked = () => {
+ try {
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
+ const base64 = dataUrl.split(',')[1];
+ resolve({ base64, time, index });
+ } catch (e) { reject(e); }
+ };
+ video.onerror = () => reject(new Error('Seek failed at ' + time + 's'));
+ });
+ }
+
+ // โ”€โ”€โ”€ Helper: Scene change detection โ”€โ”€โ”€
+ _detectSceneChanges(frames) {
+ const changes = [];
+ for (let i = 1; i < frames.length; i++) {
+ // Simple histogram comparison
+ const prev = this._frameHistogram(frames[i-1].base64);
+ const curr = this._frameHistogram(frames[i].base64);
+ const diff = this._histogramDiff(prev, curr);
+ if (diff > 0.3) {
+ changes.push(frames[i].time);
+ }
+ }
+ return changes;
+ }
+
+ _frameHistogram(base64) {
+ // Lightweight: use first 100 bytes of base64 as fingerprint
+ const bytes = atob(base64.substring(0, 200));
+ const hist = new Array(16).fill(0);
+ for (let i = 0; i < bytes.length; i++) {
+ hist[bytes.charCodeAt(i) % 16]++;
+ }
+ return hist;
+ }
+
+ _histogramDiff(a, b) {
+ let diff = 0;
+ for (let i = 0; i < a.length; i++) {
+ diff += Math.abs(a[i] - b[i]);
+ }
+ return diff / (a.reduce((s,v) => s+v, 0) || 1);
+ }
+
+ // โ”€โ”€โ”€ Audio Feature Extraction โ”€โ”€โ”€
+ _extractAudioFeatures(audioBuffer, flags) {
+ const data = audioBuffer.getChannelData(0);
+ const len = data.length;
+ const sampleRate = audioBuffer.sampleRate;
+ const channels = audioBuffer.numberOfChannels;
+ const duration = audioBuffer.duration;
+
+ // RMS
+ let sumSquares = 0, peak = 0;
+ for (let i = 0; i < len; i++) {
+ const s = Math.abs(data[i]);
+ sumSquares += data[i] * data[i];
+ if (s > peak) peak = s;
+ }
+ const rms = Math.sqrt(sumSquares / len);
+ const dynamicRange = peak - rms;
+
+ // Zero-crossing rate
+ let crossings = 0;
+ for (let i = 1; i < len; i++) {
+ if ((data[i] >= 0) !== (data[i-1] >= 0)) crossings++;
+ }
+ const zcr = crossings / len;
+
+ // Energy bands (bass, mid, treble) โ€” rough FFT approximation
+ const bandSize = Math.floor(len / 3);
+ const energyBands = [];
+ for (let b = 0; b < 3; b++) {
+ let bandEnergy = 0;
+ for (let i = b * bandSize; i < (b+1) * bandSize; i++) {
+ bandEnergy += Math.abs(data[i]);
+ }
+ energyBands.push((bandEnergy / bandSize).toFixed(3));
+ }
+
+ // Spectral centroid (approximation)
+ let weightedSum = 0, totalMag = 0;
+ const fftSize = 2048;
+ const stepSize = Math.floor(len / fftSize);
+ for (let i = 0; i < stepSize; i++) {
+ const idx = i * fftSize;
+ for (let j = 0; j < fftSize; j++) {
+ const mag = Math.abs(data[idx + j] || 0);
+ weightedSum += j * mag;
+ totalMag += mag;
+ }
+ }
+ const spectralCentroid = totalMag > 0 ? (weightedSum / totalMag) * (sampleRate / fftSize) : 0;
+
+ // BPM estimation (simplified autocorrelation)
+ const bpm = this._estimateBPM(data, sampleRate);
+
+ // Spectrogram (text-art, downsampled)
+ let spectrogram = '';
+ if (flags.includes('--spectrogram')) {
+ spectrogram = this._generateSpectrogram(data, sampleRate, audioBuffer.duration);
+ }
+
+ // Beat times (simplified)
+ const beatTimes = this._findBeats(data, sampleRate);
+
+ return {
+ duration, sampleRate, channels, rms, peak, dynamicRange,
+ zcr, spectralCentroid, energyBands, bpm, spectrogram, beatTimes
+ };
+ }
+
+ _estimateBPM(data, sampleRate) {
+ // Simplified: use energy envelope autocorrelation
+ const windowSize = Math.floor(sampleRate * 0.01); // 10ms windows
+ const numWindows = Math.floor(data.length / windowSize);
+ const envelope = [];
+ for (let w = 0; w < numWindows; w++) {
+ let energy = 0;
+ for (let i = 0; i < windowSize; i++) {
+ energy += Math.abs(data[w * windowSize + i]);
+ }
+ envelope.push(energy / windowSize);
+ }
+ // Autocorrelation at common BPM lags (60-200 BPM)
+ let bestLag = 0, bestCorr = 0;
+ for (let lag = Math.floor(60 * sampleRate / 200 / windowSize); lag < Math.floor(60 * sampleRate / 60 / windowSize); lag++) {
+ let corr = 0;
+ for (let i = lag; i < numWindows; i++) {
+ corr += envelope[i] * envelope[i - lag];
+ }
+ corr /= (numWindows - lag);
+ if (corr > bestCorr) { bestCorr = corr; bestLag = lag; }
+ }
+ if (bestLag === 0) return 0;
+ return Math.round(60 / (bestLag * windowSize / sampleRate));
+ }
+
+ _findBeats(data, sampleRate) {
+ const beats = [];
+ const windowSize = Math.floor(sampleRate * 0.05); // 50ms windows
+ const energies = [];
+ for (let i = 0; i < data.length - windowSize; i += windowSize) {
+ let e = 0;
+ for (let j = 0; j < windowSize; j++) e += data[i+j] * data[i+j];
+ energies.push(e / windowSize);
+ }
+ const avg = energies.reduce((a,b) => a+b, 0) / energies.length;
+ const threshold = avg * 1.5;
+ for (let i = 1; i < energies.length - 1; i++) {
+ if (energies[i] > threshold && energies[i] > energies[i-1] && energies[i] > energies[i+1]) {
+ beats.push((i * windowSize / sampleRate).toFixed(1));
+ if (beats.length >= 20) break; // cap
+ }
+ }
+ return beats.map(Number);
+ }
+
+ _generateSpectrogram(data, sampleRate, duration) {
+ // Text-based spectrogram: 20 rows (freq bands) x 40 cols (time)
+ const rows = 20, cols = 40;
+ const colSize = Math.floor(data.length / cols);
+ let result = '';
+ const chars = ' โ–‘โ–’โ–“โ–ˆ';
+ for (let row = rows - 1; row >= 0; row--) {
+ const freqLow = (row / rows) * (sampleRate / 2);
+ const freqHigh = ((row + 1) / rows) * (sampleRate / 2);
+ let line = (row / rows * sampleRate / 2 / 1000).toFixed(0) + 'kHzโ”‚';
+ for (let col = 0; col < cols; col++) {
+ // Rough energy at this freq band for this time window
+ let energy = 0;
+ for (let i = col * colSize; i < (col+1) * colSize; i++) {
+ // Simple band-pass: skip samples not in freq range
+ energy += Math.abs(data[i] || 0);
+ }
+ const norm = Math.min(1, energy / colSize * 4);
+ const idx = Math.floor(norm * (chars.length - 1));
+ line += chars[idx];
+ }
+ result += line + '\n';
+ }
+ return result;
+ }
+
+ // โ”€โ”€โ”€ API Calls โ”€โ”€โ”€
+ async _callVision(base64, prompt) {
+ const endpoint = this._getOllamaEndpoint();
+ try {
+ const resp = await this._fetchWithTimeout(endpoint + '/api/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: this._visionModel,
+ messages: [{ role: 'user', content: prompt, images: [base64] }],
+ stream: false
+ })
+ }, 60000);
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({}));
+ return { error: err.error || 'HTTP ' + resp.status };
+ }
+ const data = await resp.json();
+ return { text: data.message?.content || '(no response)', error: null };
+ } catch (e) {
+ return { error: e.message };
+ }
+ }
+
+ async _callText(prompt) {
+ const endpoint = this._getOllamaEndpoint();
+ try {
+ const resp = await this._fetchWithTimeout(endpoint + '/api/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: this._textModel,
+ messages: [{ role: 'user', content: prompt }],
+ stream: false
+ })
+ }, 60000);
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({}));
+ return { error: err.error || 'HTTP ' + resp.status };
+ }
+ const data = await resp.json();
+ return { text: data.message?.content || '(no response)', error: null };
+ } catch (e) {
+ return { error: e.message };
+ }
+ }
+
+ // โ”€โ”€โ”€ Utils โ”€โ”€โ”€
+ _getOllamaEndpoint() {
+ return localStorage.getItem('webx11_ollama_endpoint') || (window.location.origin + '/ollama');
+ }
+
+ _fetchWithTimeout(url, options, timeout = 30000) {
+ return new Promise((resolve, reject) => {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeout);
+ fetch(url, { ...options, signal: controller.signal })
+ .then(resolve).catch(reject).finally(() => clearTimeout(timer));
+ });
+ }
+
+ _isImageExt(ext) { return ['png','jpg','jpeg','webp','gif','svg','bmp'].includes(ext); }
+ _isAudioExt(ext) { return ['wav','mp3','ogg','webm','flac','aac','m4a','opus'].includes(ext); }
+ _isVideoExt(ext) { return ['mp4','webm','ogv','avi','mov','mkv'].includes(ext); }
+
+ _isValidBase64(str) {
+ if (typeof str !== 'string' || str.length < 8) return false;
+ try { return btoa(atob(str.substring(0, 100))) === str.substring(0, 100); } catch (e) { return false; }
+ }
+
+ _base64ToArrayBuffer(base64) {
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes.buffer;
+ }
+
+ _getMimeType(path) {
+ const ext = path.split('.').pop().toLowerCase();
+ const types = { mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/quicktime', avi: 'video/x-msvideo', mkv: 'video/x-matroska' };
+ return types[ext] || 'application/octet-stream';
+ }
+}
+
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new MediaModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/media.js');
+} \ No newline at end of file
diff --git a/modules/ext/voice.js b/modules/ext/voice.js
index de0caed..b243ace 100644
--- a/modules/ext/voice.js
+++ b/modules/ext/voice.js
@@ -27,6 +27,65 @@ class VoiceModule {
}
registerCommands() {
this.commands['voice'] = (args) => this.handle(args);
+ this.commands['speak'] = (args) => this.speak(args);
+ }
+
+ getHelpInfo() {
+ return { 'voice': 'voice [stop] โ€” Start voice recording (Whisper STT), or type "voice stop" to cancel', 'speak': 'speak [text] โ€” Text-to-speech. No args = toggle auto-speak for AI responses' };
+ }
+
+ speak(args) {
+ const text = args.join(' ').trim();
+ if (!text) {
+ // If no text, check if speechSynthesis is available
+ if (typeof speechSynthesis === 'undefined') {
+ this.terminal.print('๐Ÿ”Š TTS not supported in this browser', 'error');
+ return;
+ }
+ // Toggle auto-speak mode
+ this._autoSpeak = !this._autoSpeak;
+ this.terminal.print(this._autoSpeak ? '๐Ÿ”Š Auto-speak ON โ€” AI responses will be spoken' : '๐Ÿ”Š Auto-speak OFF', 'info');
+ return;
+ }
+ if (typeof speechSynthesis === 'undefined') {
+ this.terminal.print('๐Ÿ”Š TTS not supported in this browser', 'error');
+ return;
+ }
+ speechSynthesis.cancel();
+ const utter = new SpeechSynthesisUtterance(text);
+ utter.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0');
+ utter.pitch = 1.0;
+ utter.volume = 1.0;
+ // Try to use a good English voice
+ const voices = speechSynthesis.getVoices();
+ const enVoice = voices.find(v => v.lang.startsWith('en') && v.name.includes('Google')) || voices.find(v => v.lang.startsWith('en'));
+ if (enVoice) utter.voice = enVoice;
+ utter.onstart = () => this._updateButton(true);
+ utter.onend = () => this._updateButton(false);
+ utter.onerror = () => this._updateButton(false);
+ speechSynthesis.speak(utter);
+ this.terminal.print('๐Ÿ”Š Speaking: "' + text.substring(0, 80) + (text.length > 80 ? '...' : '') + '"', 'info');
+ }
+
+ speakText(text) {
+ // Called by AI assistant to auto-speak responses
+ if (!this._autoSpeak || typeof speechSynthesis === 'undefined') return;
+ speechSynthesis.cancel();
+ // Strip markdown/code blocks for cleaner speech
+ const clean = text.replace(/```[\s\S]*?```/g, ' code block ').replace(/[*_#|`>]/g, '').replace(/\n+/g, '. ').trim();
+ if (clean.length > 500) {
+ // Split long text into chunks
+ const chunks = clean.match(/.{1,500}/g);
+ chunks.forEach(c => {
+ const u = new SpeechSynthesisUtterance(c);
+ u.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0');
+ speechSynthesis.speak(u);
+ });
+ } else {
+ const u = new SpeechSynthesisUtterance(clean);
+ u.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0');
+ speechSynthesis.speak(u);
+ }
}
handle(args) {
const sub = args[0] && args[0].toLowerCase();