Skip to content
Merged
3 changes: 3 additions & 0 deletions ui/@types/lichess/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ interface SoundI {
countdown(count: number, intervalMs?: number): Promise<void>;
getVolume(): number;
setVolume(v: number): void;
getVoice(): SpeechSynthesisVoice | undefined;
getVoiceMap(): Map<string, SpeechSynthesisVoice>;
setVoice(v: { name: string; lang: string }): void;
speech(v?: boolean): boolean;
changeSet(s: string): void;
sayLazy(text: () => string, cut?: boolean, force?: boolean, translated?: boolean): boolean;
Expand Down
10 changes: 8 additions & 2 deletions ui/dasher/css/_dasher.scss
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,10 @@

@include transition(background);

&:hover {
background: $c-dasher-light;
@media (hover: hover) and (pointer: fine) {
&:hover {
background: $c-dasher-light;
}
}

&.active {
Expand Down Expand Up @@ -179,6 +181,10 @@
&.silent input[type='range'] {
opacity: 0.2;
}

.dialog-content {
min-width: 240px;
}
}

.background {
Expand Down
72 changes: 63 additions & 9 deletions ui/dasher/src/sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { text as xhrText, form as xhrForm } from 'lib/xhr';
import { throttle, throttlePromiseDelay } from 'lib/async';
import { h, type VNode } from 'snabbdom';
import { header } from './util';
import { bind } from 'lib/snabbdom';
import { bind, dataIcon } from 'lib/snabbdom';
import { type DasherCtrl, PaneCtrl } from './interfaces';
import { pubsub } from 'lib/pubsub';
import { isSafari } from 'lib/device';
import { snabDialog } from 'lib/view/dialog';

type Key = string;

Expand All @@ -19,16 +20,16 @@ export interface SoundData {

export class SoundCtrl extends PaneCtrl {
private list: Sound[];
private showVoiceSelection = false;

constructor(root: DasherCtrl) {
super(root);
this.list = this.root.data.sound.list.map(s => s.split(' '));
}

render = (): VNode => {
const current = site.sound.speech() ? 'speech' : site.sound.theme;

return h(
'div.sub.sound.' + current,
'div.sub.sound.' + this.getCurrent(),
{
hook: {
insert: () => {
Expand Down Expand Up @@ -64,18 +65,70 @@ export class SoundCtrl extends PaneCtrl {
'button.text',
{
hook: bind('click', () => this.set(s[0])),
class: { active: current === s[0] },
attrs: { 'data-icon': licon.Checkmark, type: 'button' },
class: { active: this.getCurrent() === s[0] },
attrs: { ...dataIcon(licon.Checkmark), type: 'button' },
},
s[1],
[s[1], s[0] === 'speech' ? '...' : ''],
),
),
),
]),
this.voiceSelectionDialog(),
],
);
};

private voiceSelectionDialog = () => {
if (!this.showVoiceSelection) return;
const content = this.renderVoiceSelection();
if (!content) return;
return snabDialog({
onClose: () => {
this.showVoiceSelection = false;
this.redraw();
},
modal: true,
vnodes: [content],
onInsert: dlg => {
dlg.show();
dlg.view.querySelector('.active')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
},
});
};

private getCurrent = (): Key => (site.sound.speech() ? 'speech' : site.sound.theme);

private renderVoiceSelection(): VNode | false {
const selectedVoice = site.sound.getVoice();
const voiceMap = site.sound.getVoiceMap();
return voiceMap.size < 2
? false
: h(
'div.selector',
[...voiceMap.keys()]
.sort((a, b) => a.localeCompare(b))
.map(name =>
h(
'button.text',
{
hook: bind('click', event => {
const target = event.target as HTMLElement;
site.sound.setVoice(voiceMap.get(target.textContent!)!);
site.sound.say('Speech synthesis ready');
this.redraw();
}),
class: { active: name === selectedVoice?.name },
attrs: {
...dataIcon(name === selectedVoice?.name ? licon.Checkmark : ''),
type: 'button',
},
},
name,
),
),
);
}

private postSet = throttlePromiseDelay(
() => 1000,
(soundSet: string) =>
Expand All @@ -91,16 +144,17 @@ export class SoundCtrl extends PaneCtrl {

private set = (k: Key) => {
site.sound.speech(k === 'speech');
pubsub.emit('speech.enabled', site.sound.speech());
if (site.sound.speech()) {
this.showVoiceSelection = true;
site.sound.say('Speech synthesis ready');
site.sound.changeSet('standard');
this.postSet('standard');
site.sound.say('Speech synthesis ready');
} else {
site.sound.changeSet(k);
site.sound.play('genericNotify');
this.postSet(k);
}
pubsub.emit('speech.enabled', site.sound.speech());
this.redraw();
};

Expand Down
2 changes: 1 addition & 1 deletion ui/lobby/src/view/setup/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function setupModal(ctrl: LobbyController): MaybeVNode {
const { setupCtrl } = ctrl;
if (!setupCtrl.gameType) return null;
return snabDialog({
attrs: { dialog: { role: 'dialog', 'aria-labelledBy': 'lobby-setup-modal-title', 'aria-modal': 'true' } },
attrs: { dialog: { 'aria-labelledBy': 'lobby-setup-modal-title', 'aria-modal': 'true' } },
class: 'game-setup',
css: [{ hashed: 'lobby.setup' }],
onClose: () => {
Expand Down
39 changes: 38 additions & 1 deletion ui/site/src/sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default new (class implements SoundI {
paths = new Map<Name, Path>(); // sound names to paths
theme = document.body.dataset.soundSet!;
speechStorage = storage.boolean('speech.enabled');
voiceStorage = storage.make('speech.voice');
volumeStorage = storage.make('sound-volume');
music?: SoundMove;
primerEvents = ['touchend', 'pointerup', 'pointerdown', 'mousedown', 'keydown'];
Expand All @@ -25,6 +26,7 @@ export default new (class implements SoundI {

constructor() {
this.primerEvents.forEach(e => window.addEventListener(e, this.primer, { capture: true }));
window.speechSynthesis.getVoices(); // preload
}

async load(name: Name, path?: Path): Promise<Sound | undefined> {
Expand Down Expand Up @@ -129,6 +131,36 @@ export default new (class implements SoundI {
return v >= 0 ? v : 0.7;
};

getVoice = (): SpeechSynthesisVoice | undefined => {
let o: { name: string; lang: string } = { name: '', lang: document.documentElement.lang.split('-')[0] };
try {
o = JSON.parse(this.voiceStorage.get() ?? JSON.stringify(o));
} catch {}
const voiceMap = this.getVoiceMap();
const voice = voiceMap.get(o.name) ?? [...voiceMap.values()].find(v => v.lang.startsWith(o.lang));
return voice;
};

getVoiceMap = (): Map<string, SpeechSynthesisVoice> => {
const voices = speechSynthesis.getVoices();
const voiceMap = new Map<string, SpeechSynthesisVoice>();

for (const code of ['en', document.documentElement.lang.split('-')[0], document.documentElement.lang]) {
voices
.filter(v => v.lang.startsWith(code))
.sort((a, b) => a.lang.localeCompare(b.lang))
.forEach(v => voiceMap.set(v.name, v));
// populate map with preferred regional language pronunciations taking precedence. if not matched
// exactly by documentElement.lang, the chosen region will be the last one lexicographically
}
return voiceMap;
};

setVoice = (o?: { name: string; lang: string }) => {
if (!o) this.voiceStorage.remove();
else this.voiceStorage.set(JSON.stringify({ name: o.name, lang: o.lang }));
};

enabled = () => this.theme !== 'silent';

speech = (v?: boolean): boolean => {
Expand All @@ -145,8 +177,13 @@ export default new (class implements SoundI {
if (cut) speechSynthesis.cancel();
if (!this.speech() && !force) return false;
const msg = new SpeechSynthesisUtterance(text());
const selectedVoice = this.getVoice();
if (selectedVoice) {
msg.voice = selectedVoice;
} else {
msg.lang = translated ? document.documentElement.lang : 'en-GB';
}
msg.volume = this.getVolume();
msg.lang = translated ? document.documentElement.lang : 'en-GB';
if (!isIos()) {
// speech events are unreliable on iOS, but iphones do their own cancellation
msg.onstart = () => this.listeners.forEach(l => l('start', text()));
Expand Down