narrator support
This commit is contained in:
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import * as narrator from '../narrator'
|
||||||
|
|
||||||
|
function formatTime(secs: number): string {
|
||||||
|
const m = Math.floor(secs / 60)
|
||||||
|
const s = secs % 60
|
||||||
|
if (m === 0) return `${s} second${s !== 1 ? 's' : ''}`
|
||||||
|
if (s === 0) return `${m} minute${m !== 1 ? 's' : ''}`
|
||||||
|
return `${m} minute${m !== 1 ? 's' : ''} ${s} second${s !== 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNarrator() {
|
||||||
|
const prevCell = useRef<{ row: number; col: number } | null>(null)
|
||||||
|
const prevDirection = useRef<string | null>(null)
|
||||||
|
const prevEntries = useRef<string[][] | null>(null)
|
||||||
|
const prevActiveClue = useRef<number | null>(null)
|
||||||
|
const prevSolved = useRef(false)
|
||||||
|
|
||||||
|
// sync settings to narrator module whenever they change
|
||||||
|
const narratorSettings = useStore(s => s.narratorSettings)
|
||||||
|
useEffect(() => {
|
||||||
|
narrator.updateSettings(narratorSettings)
|
||||||
|
}, [narratorSettings])
|
||||||
|
|
||||||
|
// subscribe to store changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = useStore.subscribe((state, prev) => {
|
||||||
|
if (!state.narratorEnabled || !state.puzzle) return
|
||||||
|
const v = state.narratorSettings.verbosity
|
||||||
|
|
||||||
|
// direction change
|
||||||
|
if (state.direction !== prevDirection.current && prevDirection.current !== null) {
|
||||||
|
narrator.speak(`Now solving ${state.direction}`)
|
||||||
|
}
|
||||||
|
prevDirection.current = state.direction
|
||||||
|
|
||||||
|
// cell selection
|
||||||
|
if (state.selectedCell &&
|
||||||
|
(state.selectedCell.row !== prevCell.current?.row ||
|
||||||
|
state.selectedCell.col !== prevCell.current?.col)) {
|
||||||
|
|
||||||
|
const { row, col } = state.selectedCell
|
||||||
|
const letter = state.entries[row]?.[col] || ''
|
||||||
|
const cellContent = letter ? letter : 'empty'
|
||||||
|
|
||||||
|
if (v === 'brief') {
|
||||||
|
narrator.speak(`Row ${row + 1}, Column ${col + 1}`)
|
||||||
|
} else if (v === 'normal') {
|
||||||
|
narrator.speak(`Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}`)
|
||||||
|
} else {
|
||||||
|
const puzzle = state.puzzle
|
||||||
|
let neighbors = ''
|
||||||
|
if (col > 0 && puzzle.cells[row][col - 1] !== 'black') {
|
||||||
|
const l = state.entries[row][col - 1]
|
||||||
|
neighbors += l ? `${l} to the left` : 'blank to the left'
|
||||||
|
}
|
||||||
|
if (row > 0 && puzzle.cells[row - 1]?.[col] !== 'black') {
|
||||||
|
const l = state.entries[row - 1]?.[col]
|
||||||
|
if (neighbors) neighbors += ', '
|
||||||
|
neighbors += l ? `${l} above` : 'blank above'
|
||||||
|
}
|
||||||
|
let msg = `Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}`
|
||||||
|
if (neighbors) msg += `. Neighboring: ${neighbors}`
|
||||||
|
narrator.speak(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevCell.current = state.selectedCell
|
||||||
|
|
||||||
|
// letter entry - detect new letter in the grid
|
||||||
|
if (prevEntries.current && state.entries !== prevEntries.current) {
|
||||||
|
for (let r = 0; r < state.entries.length; r++) {
|
||||||
|
for (let c = 0; c < state.entries[r].length; c++) {
|
||||||
|
const cur = state.entries[r][c]
|
||||||
|
const old = prevEntries.current[r]?.[c] || ''
|
||||||
|
if (cur && !old) {
|
||||||
|
if (v === 'brief') narrator.speak(cur, false)
|
||||||
|
else if (v === 'normal') narrator.speak(`Entered ${cur}`, false)
|
||||||
|
else narrator.speak(`Entered ${cur} at Row ${r + 1}, Column ${c + 1}`, false)
|
||||||
|
|
||||||
|
// check word completion
|
||||||
|
const span = state.wordSpans.find(s =>
|
||||||
|
s.cells.some(([sr, sc]) => sr === r && sc === c)
|
||||||
|
)
|
||||||
|
if (span) {
|
||||||
|
const allFilled = span.cells.every(([wr, wc]) => state.entries[wr]?.[wc])
|
||||||
|
if (allFilled) {
|
||||||
|
const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc])
|
||||||
|
if (!wasFilled) {
|
||||||
|
const word = span.cells.map(([wr, wc]) => state.entries[wr][wc]).join('')
|
||||||
|
setTimeout(() => narrator.speak(`Word complete: ${word.split('').join(' ')}`, false), 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevEntries.current = state.entries
|
||||||
|
|
||||||
|
// clue focus change
|
||||||
|
if (state.activeClue !== null && state.activeClue !== prevActiveClue.current) {
|
||||||
|
const clues = state.direction === 'across' ? state.puzzle.clues_across : state.puzzle.clues_down
|
||||||
|
const clue = clues.find(c => c.number === state.activeClue)
|
||||||
|
if (clue) {
|
||||||
|
const span = state.wordSpans.find(s => s.number === state.activeClue && s.direction === state.direction)
|
||||||
|
const len = span?.cells.length || 0
|
||||||
|
const filled = span ? span.cells.filter(([r, c]) => state.entries[r]?.[c]).length : 0
|
||||||
|
|
||||||
|
if (v === 'brief') {
|
||||||
|
narrator.speak(`${state.activeClue} ${state.direction}: ${clue.text}`, false)
|
||||||
|
} else if (v === 'normal') {
|
||||||
|
narrator.speak(`${state.activeClue} ${state.direction}, ${len} letters: ${clue.text}`, false)
|
||||||
|
} else {
|
||||||
|
narrator.speak(`${state.activeClue} ${state.direction}, ${len} letters, ${filled} filled: ${clue.text}`, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevActiveClue.current = state.activeClue
|
||||||
|
|
||||||
|
// hint reveal - detect newly revealed cells
|
||||||
|
if (prev.cellStatus !== state.cellStatus && prevEntries.current) {
|
||||||
|
for (let r = 0; r < state.cellStatus.length; r++) {
|
||||||
|
for (let c = 0; c < state.cellStatus[r].length; c++) {
|
||||||
|
if (state.cellStatus[r][c] === 'revealed' && prev.cellStatus[r]?.[c] !== 'revealed') {
|
||||||
|
const letter = state.entries[r][c]
|
||||||
|
const span = state.wordSpans.find(s =>
|
||||||
|
s.cells.some(([sr, sc]) => sr === r && sc === c)
|
||||||
|
)
|
||||||
|
const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0
|
||||||
|
if (letter && pos) {
|
||||||
|
narrator.speak(`Revealed letter ${letter} at position ${pos}`, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// puzzle solved
|
||||||
|
if (state.solved && !prevSolved.current) {
|
||||||
|
const time = formatTime(state.elapsed)
|
||||||
|
narrator.speak(`Puzzle complete. Time: ${time}`)
|
||||||
|
}
|
||||||
|
prevSolved.current = state.solved
|
||||||
|
})
|
||||||
|
|
||||||
|
return unsub
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export interface NarratorSettings {
|
||||||
|
enabled: boolean
|
||||||
|
rate: number
|
||||||
|
pitch: number
|
||||||
|
voice: string
|
||||||
|
verbosity: 'brief' | 'normal' | 'verbose'
|
||||||
|
sounds: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultNarratorSettings: NarratorSettings = {
|
||||||
|
enabled: false,
|
||||||
|
rate: 1.0,
|
||||||
|
pitch: 1.0,
|
||||||
|
voice: '',
|
||||||
|
verbosity: 'normal',
|
||||||
|
sounds: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings: NarratorSettings = { ...defaultNarratorSettings }
|
||||||
|
|
||||||
|
export function updateSettings(s: NarratorSettings) {
|
||||||
|
settings = s
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupported(): boolean {
|
||||||
|
return 'speechSynthesis' in window
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVoices(): SpeechSynthesisVoice[] {
|
||||||
|
if (!isSupported()) return []
|
||||||
|
return window.speechSynthesis.getVoices()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancel() {
|
||||||
|
if (!isSupported()) return
|
||||||
|
window.speechSynthesis.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speak(text: string, interrupt = true) {
|
||||||
|
if (!isSupported() || !settings.enabled) return
|
||||||
|
if (interrupt) cancel()
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text)
|
||||||
|
utterance.rate = settings.rate
|
||||||
|
utterance.pitch = settings.pitch
|
||||||
|
|
||||||
|
if (settings.voice) {
|
||||||
|
const voices = getVoices()
|
||||||
|
const match = voices.find(v => v.name === settings.voice)
|
||||||
|
if (match) utterance.voice = match
|
||||||
|
}
|
||||||
|
|
||||||
|
window.speechSynthesis.speak(utterance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// voices load async - call this early so they're ready
|
||||||
|
export function preloadVoices() {
|
||||||
|
if (!isSupported()) return
|
||||||
|
getVoices()
|
||||||
|
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
||||||
|
window.speechSynthesis.onvoiceschanged = () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preloadVoices()
|
||||||
Reference in New Issue
Block a user