mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 17:05:48 +00:00
Move the voice reminder (domina mode) feature from a built-in Angular feature into a self-contained plugin with its own manifest, i18n, and config dialog. Add registerConfigHandler plugin API for settings button on plugin cards. Include migration logic to auto-enable the plugin and transfer config for users who had domina mode enabled. - Fix currentTaskChange hook to dispatch full Task object instead of ID - Extract shared BUNDLED_PLUGIN_PATHS constant to eliminate duplication - Use firstValueFrom instead of deprecated .toPromise() - Add voice name fallback matching for migrated configs - Mark DominaModeConfig and selector as @deprecated - Add unit tests for onCurrentTaskChange and config handler
282 lines
8.9 KiB
JavaScript
282 lines
8.9 KiB
JavaScript
// Voice Reminder Plugin - Periodically speaks the current task title via Web Speech API
|
|
|
|
if (!window.speechSynthesis) {
|
|
console.warn('[voice-reminder] Web Speech API not available. Plugin disabled.');
|
|
} else {
|
|
// --- plugin body (runs only when speechSynthesis is available) ---
|
|
|
|
var _vrInterval = null;
|
|
var _vrCurrentTask = null;
|
|
var _vrDefaultTtsRate = 0.7;
|
|
|
|
var _vrDefaults = {
|
|
isEnabled: false,
|
|
text: 'Your current task is: ${currentTaskTitle}',
|
|
interval: 300000,
|
|
volume: 75,
|
|
voice: '',
|
|
};
|
|
|
|
function _vrT(key, params) {
|
|
return PluginAPI.translate(key, params);
|
|
}
|
|
|
|
async function _vrLoadConfig() {
|
|
var raw = await PluginAPI.loadSyncedData();
|
|
if (!raw) return Object.assign({}, _vrDefaults);
|
|
try {
|
|
return Object.assign({}, _vrDefaults, JSON.parse(raw));
|
|
} catch (e) {
|
|
return Object.assign({}, _vrDefaults);
|
|
}
|
|
}
|
|
|
|
async function _vrSaveConfig(cfg) {
|
|
await PluginAPI.persistDataSynced(JSON.stringify(cfg));
|
|
}
|
|
|
|
function _vrSpeak(text, volume, voiceName) {
|
|
var synth = window.speechSynthesis;
|
|
if (!synth) {
|
|
console.error('[voice-reminder] No window.speechSynthesis available.');
|
|
return;
|
|
}
|
|
|
|
synth.cancel();
|
|
var voices = synth.getVoices();
|
|
var utter = new SpeechSynthesisUtterance();
|
|
utter.text = text;
|
|
utter.voice =
|
|
voices.find(function (v) {
|
|
return voiceName && v.voiceURI === voiceName;
|
|
}) ||
|
|
voices.find(function (v) {
|
|
return voiceName && v.name === voiceName;
|
|
}) ||
|
|
voices.find(function (v) {
|
|
return v.default;
|
|
}) ||
|
|
null;
|
|
utter.volume = volume / 100;
|
|
utter.rate = _vrDefaultTtsRate;
|
|
synth.speak(utter);
|
|
}
|
|
|
|
function _vrStopTimer() {
|
|
if (_vrInterval) {
|
|
clearInterval(_vrInterval);
|
|
_vrInterval = null;
|
|
}
|
|
}
|
|
|
|
async function _vrStartTimer() {
|
|
_vrStopTimer();
|
|
var cfg = await _vrLoadConfig();
|
|
if (!cfg.isEnabled) return;
|
|
|
|
var intervalMs = Math.max(cfg.interval || 300000, 5000);
|
|
_vrInterval = setInterval(function () {
|
|
if (!_vrCurrentTask) return;
|
|
var txt = cfg.text.replace('${currentTaskTitle}', _vrCurrentTask.title);
|
|
if (txt.length <= 1) {
|
|
txt = _vrCurrentTask.title;
|
|
}
|
|
_vrSpeak(txt, cfg.volume, cfg.voice);
|
|
}, intervalMs);
|
|
}
|
|
|
|
// Track current task via hook
|
|
PluginAPI.registerHook('currentTaskChange', function (task) {
|
|
_vrCurrentTask = task;
|
|
});
|
|
|
|
// Initial load and start
|
|
_vrLoadConfig().then(function (cfg) {
|
|
if (cfg.isEnabled) {
|
|
_vrStartTimer();
|
|
}
|
|
});
|
|
|
|
// Config dialog helpers
|
|
function _vrEscapeHtml(str) {
|
|
var div = document.createElement('div');
|
|
div.appendChild(document.createTextNode(str));
|
|
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
function _vrBuildVoiceOptions(voiceList, selectedVoice) {
|
|
var opts =
|
|
'<option value="">' + _vrEscapeHtml(_vrT('SETTINGS.VOICE_DEFAULT')) + '</option>';
|
|
for (var i = 0; i < voiceList.length; i++) {
|
|
var v = voiceList[i];
|
|
var selected = v.voiceURI === selectedVoice ? ' selected' : '';
|
|
opts +=
|
|
'<option value="' +
|
|
_vrEscapeHtml(v.voiceURI) +
|
|
'"' +
|
|
selected +
|
|
'>' +
|
|
_vrEscapeHtml(v.name) +
|
|
'</option>';
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
async function openVoiceReminderSettings() {
|
|
var cfg = await _vrLoadConfig();
|
|
|
|
var voices = [];
|
|
if (window.speechSynthesis) {
|
|
voices = window.speechSynthesis.getVoices();
|
|
}
|
|
var voiceOptions = _vrBuildVoiceOptions(voices, cfg.voice);
|
|
|
|
var html =
|
|
'<div style="padding:4px 0">' +
|
|
'<p style="margin:0 0 16px;font-size:13px;opacity:0.7">' +
|
|
_vrEscapeHtml(_vrT('SETTINGS.DESCRIPTION')) +
|
|
'</p>' +
|
|
'<div style="margin-bottom:16px">' +
|
|
'<label style="display:flex;align-items:center;gap:8px;cursor:pointer">' +
|
|
'<input type="checkbox" id="vr-enabled"' +
|
|
(cfg.isEnabled ? ' checked' : '') +
|
|
'>' +
|
|
' ' +
|
|
_vrEscapeHtml(_vrT('SETTINGS.ENABLE')) +
|
|
'</label>' +
|
|
'</div>' +
|
|
'<div style="margin-bottom:12px">' +
|
|
'<label for="vr-text" style="display:block;margin-bottom:4px;font-size:12px;opacity:0.7">' +
|
|
_vrEscapeHtml(_vrT('SETTINGS.TEXT_LABEL')) +
|
|
'</label>' +
|
|
'<input type="text" id="vr-text" value="' +
|
|
_vrEscapeHtml(cfg.text) +
|
|
'" style="width:100%;box-sizing:border-box">' +
|
|
'</div>' +
|
|
'<div style="margin-bottom:12px">' +
|
|
'<label for="vr-interval" style="display:block;margin-bottom:4px;font-size:12px;opacity:0.7">' +
|
|
_vrEscapeHtml(_vrT('SETTINGS.INTERVAL_LABEL')) +
|
|
'</label>' +
|
|
'<input type="number" id="vr-interval" value="' +
|
|
_vrEscapeHtml(String(Math.round(cfg.interval / 1000))) +
|
|
'" min="5" style="width:100px">' +
|
|
'</div>' +
|
|
'<div style="margin-bottom:12px">' +
|
|
'<label for="vr-volume" style="display:block;margin-bottom:4px;font-size:12px;opacity:0.7">' +
|
|
_vrEscapeHtml(_vrT('SETTINGS.VOLUME_LABEL')) +
|
|
'</label>' +
|
|
'<div style="display:flex;align-items:center;gap:12px">' +
|
|
'<input type="range" id="vr-volume" min="0" max="100" value="' +
|
|
_vrEscapeHtml(String(cfg.volume)) +
|
|
'" style="flex:1">' +
|
|
'<span id="vr-volume-val" style="min-width:30px;text-align:right">' +
|
|
_vrEscapeHtml(String(cfg.volume)) +
|
|
'</span>' +
|
|
'<button type="button" id="vr-test-btn" style="cursor:pointer">' +
|
|
_vrEscapeHtml(_vrT('SETTINGS.TEST')) +
|
|
'</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div style="margin-bottom:12px">' +
|
|
'<label for="vr-voice" style="display:block;margin-bottom:4px;font-size:12px;opacity:0.7">' +
|
|
_vrEscapeHtml(_vrT('SETTINGS.VOICE_LABEL')) +
|
|
'</label>' +
|
|
'<select id="vr-voice" style="width:100%">' +
|
|
voiceOptions +
|
|
'</select>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
PluginAPI.openDialog({
|
|
title: _vrT('SETTINGS.TITLE'),
|
|
htmlContent: html,
|
|
buttons: [
|
|
{
|
|
label: _vrT('SETTINGS.CANCEL'),
|
|
onClick: function () {},
|
|
},
|
|
{
|
|
label: _vrT('SETTINGS.SAVE'),
|
|
color: 'primary',
|
|
icon: 'save',
|
|
raised: true,
|
|
onClick: function () {
|
|
return saveSettings();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
// Post-render setup
|
|
setTimeout(function () {
|
|
var volumeSlider = document.getElementById('vr-volume');
|
|
var volumeVal = document.getElementById('vr-volume-val');
|
|
if (volumeSlider && volumeVal) {
|
|
volumeSlider.addEventListener('input', function () {
|
|
volumeVal.textContent = volumeSlider.value;
|
|
});
|
|
}
|
|
|
|
var testBtn = document.getElementById('vr-test-btn');
|
|
if (testBtn) {
|
|
testBtn.addEventListener('click', function () {
|
|
var textInput = document.getElementById('vr-text');
|
|
var voiceSelect = document.getElementById('vr-voice');
|
|
var txt = textInput
|
|
? textInput.value.replace(
|
|
'${currentTaskTitle}',
|
|
_vrT('SETTINGS.TEST_PLACEHOLDER'),
|
|
)
|
|
: 'Test';
|
|
if (txt.length <= 1) txt = _vrT('SETTINGS.TEST_NO_TEXT');
|
|
var vol = volumeSlider ? parseInt(volumeSlider.value, 10) : 75;
|
|
var voice = voiceSelect ? voiceSelect.value : '';
|
|
_vrSpeak(txt, vol, voice);
|
|
});
|
|
}
|
|
|
|
// Handle async voice loading on some platforms
|
|
if (window.speechSynthesis && voices.length === 0) {
|
|
window.speechSynthesis.addEventListener(
|
|
'voiceschanged',
|
|
function () {
|
|
var newVoices = window.speechSynthesis.getVoices();
|
|
var voiceSelect = document.getElementById('vr-voice');
|
|
if (voiceSelect) {
|
|
voiceSelect.innerHTML = _vrBuildVoiceOptions(newVoices, cfg.voice);
|
|
}
|
|
},
|
|
{ once: true },
|
|
);
|
|
}
|
|
}, 100);
|
|
|
|
async function saveSettings() {
|
|
var enabledCb = document.getElementById('vr-enabled');
|
|
var textInput = document.getElementById('vr-text');
|
|
var intervalInput = document.getElementById('vr-interval');
|
|
var volumeSlider = document.getElementById('vr-volume');
|
|
var voiceSelect = document.getElementById('vr-voice');
|
|
|
|
var newCfg = {
|
|
isEnabled: enabledCb ? enabledCb.checked : false,
|
|
text: textInput ? textInput.value : cfg.text,
|
|
interval: intervalInput ? parseInt(intervalInput.value, 10) * 1000 : cfg.interval,
|
|
volume: volumeSlider ? parseInt(volumeSlider.value, 10) : cfg.volume,
|
|
voice: voiceSelect ? voiceSelect.value : cfg.voice,
|
|
};
|
|
|
|
await _vrSaveConfig(newCfg);
|
|
await _vrStartTimer();
|
|
|
|
PluginAPI.showSnack({
|
|
msg: _vrT('SETTINGS.SAVED_MSG'),
|
|
type: 'SUCCESS',
|
|
ico: 'check',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Register config handler for settings button on plugin card
|
|
PluginAPI.registerConfigHandler(openVoiceReminderSettings);
|
|
} // end speechSynthesis guard
|