Changeset 3778385
- Timestamp:
- 11/01/2026 06:10:22 PM (2 hours ago)
- Location:
- auvrenth-text-to-speech-reader
- Files:
-
- 35 added
- 7 edited
-
assets/screenshot-1.png (added)
-
assets/screenshot-2.png (added)
-
assets/screenshot-3.png (added)
-
assets/screenshot-4.png (added)
-
assets/screenshot-5.png (added)
-
assets/screenshot-6.png (added)
-
tags/1.0.0 (added)
-
tags/1.0.0/assets (added)
-
tags/1.0.0/assets/css (added)
-
tags/1.0.0/assets/css/player.css (added)
-
tags/1.0.0/assets/images (added)
-
tags/1.0.0/assets/images/menu-icon.svg (added)
-
tags/1.0.0/assets/js (added)
-
tags/1.0.0/assets/js/admin.js (added)
-
tags/1.0.0/assets/js/player.js (added)
-
tags/1.0.0/assets/js/voices.js (added)
-
tags/1.0.0/auvrenth-text-to-speech-reader.php (added)
-
tags/1.0.0/blocks (added)
-
tags/1.0.0/blocks/player (added)
-
tags/1.0.0/blocks/player/block.json (added)
-
tags/1.0.0/blocks/player/index.asset.php (added)
-
tags/1.0.0/blocks/player/index.js (added)
-
tags/1.0.0/includes (added)
-
tags/1.0.0/includes/MetaBox.php (added)
-
tags/1.0.0/includes/Player.php (added)
-
tags/1.0.0/includes/Plugin.php (added)
-
tags/1.0.0/includes/Settings.php (added)
-
tags/1.0.0/includes/autoload.php (added)
-
tags/1.0.0/readme.txt (added)
-
tags/1.0.0/uninstall.php (added)
-
trunk/assets/css/player.css (modified) (6 diffs)
-
trunk/assets/js/admin.js (modified) (3 diffs)
-
trunk/assets/js/player.js (modified) (18 diffs)
-
trunk/auvrenth-text-to-speech-reader.php (modified) (2 diffs)
-
trunk/blocks (added)
-
trunk/blocks/player (added)
-
trunk/blocks/player/block.json (added)
-
trunk/blocks/player/index.asset.php (added)
-
trunk/blocks/player/index.js (added)
-
trunk/includes/Player.php (modified) (14 diffs)
-
trunk/includes/Settings.php (modified) (8 diffs)
-
trunk/readme.txt (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
auvrenth-text-to-speech-reader/trunk/assets/css/player.css
r3777507 r3778385 8 8 --auvrenth-tts-border: rgba(127, 127, 127, 0.24); 9 9 --auvrenth-tts-hover: rgba(127, 127, 127, 0.12); 10 --auvrenth-tts-floating-bg: Canvas; 11 --auvrenth-tts-floating-text: CanvasText; 10 12 11 13 box-sizing: border-box; … … 84 86 color: var(--auvrenth-tts-accent-text) !important; 85 87 box-shadow: 0 2px 8px rgba(30, 64, 175, 0.25) !important; 88 /* Follows the chosen color where the browser can mix colors; the line above is the fallback. */ 89 box-shadow: 0 2px 8px color-mix(in srgb, var(--auvrenth-tts-accent) 30%, transparent) !important; 86 90 } 87 91 … … 149 153 } 150 154 151 .auvrenth-tts[data-state="idle"] .auvrenth-tts__stop { 155 .auvrenth-tts[data-state="idle"] .auvrenth-tts__prev, 156 .auvrenth-tts[data-state="idle"] .auvrenth-tts__stop, 157 .auvrenth-tts[data-state="idle"] .auvrenth-tts__next { 152 158 display: none; 159 } 160 161 /* Stopped or paused: the Stop button gives its place to a Resume button. */ 162 .auvrenth-tts .auvrenth-tts__resume, 163 .auvrenth-tts[data-state="paused"] .auvrenth-tts__stop { 164 display: none; 165 } 166 167 .auvrenth-tts[data-state="paused"] .auvrenth-tts__resume { 168 display: inline-flex; 169 color: var(--auvrenth-tts-accent); 170 } 171 172 /* Skip icons point the reading direction. */ 173 [dir="rtl"] .auvrenth-tts__prev svg, 174 [dir="rtl"] .auvrenth-tts__next svg { 175 transform: scaleX(-1); 153 176 } 154 177 … … 201 224 } 202 225 203 /* Reading progress along the bottom edge */ 226 /* Spans the whole panel under the lists. */ 227 .auvrenth-tts__note { 228 grid-column: 1 / -1; 229 margin: 0; 230 font-size: 0.8125em; 231 opacity: 0.68; 232 } 233 234 /* Reading progress along the bottom edge; click to jump to a sentence. */ 204 235 .auvrenth-tts__progress { 236 position: relative; 205 237 height: 3px; 206 238 background: var(--auvrenth-tts-hover); 239 cursor: pointer; 240 } 241 242 /* A taller hit area than the 3px bar itself. */ 243 .auvrenth-tts__progress::before { 244 content: ""; 245 position: absolute; 246 inset: -8px 0 0; 207 247 } 208 248 … … 215 255 } 216 256 257 /* The block editor's picture of the player: looks like the real one, but clicks select the block. */ 258 .auvrenth-tts--preview { 259 margin-bottom: 0; 260 pointer-events: none; 261 } 262 263 /* Mini player pinned to the bottom of the screen once the original scrolls out of view. */ 264 .auvrenth-tts--floating { 265 position: fixed; 266 z-index: 99999; 267 bottom: 16px; 268 left: 50%; 269 width: min(560px, calc(100% - 32px)); 270 margin: 0; 271 transform: translateX(-50%); 272 background: var(--auvrenth-tts-floating-bg); 273 color: var(--auvrenth-tts-floating-text); 274 box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); 275 } 276 277 /* The sentence being read. Themes can set --auvrenth-tts-highlight on the content. */ 278 ::highlight(auvrenth-tts) { 279 background-color: var(--auvrenth-tts-highlight, rgba(59, 130, 246, 0.25)); 280 } 281 217 282 @media (max-width: 480px) { 218 283 .auvrenth-tts__bar { … … 225 290 height: 42px; 226 291 } 292 293 .auvrenth-tts__actions button { 294 min-width: 32px; 295 } 296 297 .auvrenth-tts--floating { 298 bottom: 12px; 299 width: calc(100% - 24px); 300 } 227 301 } 228 302 -
auvrenth-text-to-speech-reader/trunk/assets/js/admin.js
r3777507 r3778385 4 4 ( function () { 5 5 'use strict'; 6 7 // WordPress's own color picker for the play button color. 8 if ( window.jQuery && window.jQuery.fn.wpColorPicker ) { 9 window.jQuery( '.auvrenth-tts-color-field' ).wpColorPicker(); 10 } 6 11 7 12 const V = window.auvrenthTtsVoices; … … 11 16 const test = document.getElementById( 'auvrenth-tts-test-voice' ); 12 17 const note = document.getElementById( 'auvrenth-tts-voice-note' ); 18 const rate = document.getElementById( 'auvrenth-tts-rate' ); 19 const pitch = document.getElementById( 'auvrenth-tts-pitch' ); 13 20 14 21 if ( ! language || ! voice ) { … … 54 61 const picked = V.pick( language.value || siteLanguage, voice.value ); 55 62 const utterance = new window.SpeechSynthesisUtterance( l10n.sample || 'Hello' ); 63 // Preview with the speed and pitch currently in the form, saved or not. 64 utterance.rate = parseFloat( rate && rate.value ) || 1; 65 utterance.pitch = parseFloat( pitch && pitch.value ) || 1; 56 66 if ( picked ) { 57 67 utterance.voice = picked; -
auvrenth-text-to-speech-reader/trunk/assets/js/player.js
r3777507 r3778385 2 2 * Auvrenth Text-to-Speech Reader player: reads the post aloud with the browser's speech synthesis. 3 3 * 4 * Text is spoken in short chunks because Chrome silently stops long utterances 5 * after about 15 seconds. Pausing cancels speech and resuming restarts the 6 * current chunk, which behaves the same in every browser unlike pause(). 4 * Text is spoken one sentence at a time because Chrome silently stops long 5 * utterances after about 15 seconds, and sentences give natural points for 6 * highlighting and skipping. Pausing cancels speech and resuming starts again 7 * from the last word the voice reported, which behaves the same in every 8 * browser unlike pause(). 7 9 */ 8 10 ( function () { … … 16 18 const synth = window.speechSynthesis; 17 19 const l10n = window.auvrenthTtsL10n || {}; 18 const MAX_ CHUNK= 220;20 const MAX_SEGMENT = 220; 19 21 const STORE_KEY = 'auvrenthTtsVoice'; 22 const HIGHLIGHT = 'auvrenth-tts'; 23 const canHighlight = !! ( window.CSS && window.CSS.highlights && window.Highlight ); 24 // Letters, marks and digits only, so curly quotes, dashes and spacing that WordPress adds never break a match. 25 const WORD_CHAR = /[\p{L}\p{M}\p{N}]/u; 26 // Roughly how many characters a browser voice reads per second at 1× speed. 27 const CHARS_PER_SECOND = 14; 28 // Auto-scroll steps aside for this long after the visitor scrolls, so it never fights their hand. 29 const USER_SCROLL_PAUSE = 4000; 30 let lastInput = -USER_SCROLL_PAUSE; 20 31 let active = null; 21 32 22 // The visitor's last language and voice choice. Storage can be blocked, so never rely on it.33 // The visitor's last language and voice. Storage can be blocked, so never rely on it. 23 34 function readChoice() { 24 35 try { … … 29 40 } 30 41 31 function saveChoice( ch oice) {42 function saveChoice( changes ) { 32 43 try { 33 window.localStorage.setItem( STORE_KEY, JSON.stringify( choice) );44 window.localStorage.setItem( STORE_KEY, JSON.stringify( Object.assign( readChoice(), changes ) ) ); 34 45 } catch ( e ) {} 35 46 } … … 38 49 const parts = []; 39 50 let rest = sentence; 40 while ( rest.length > MAX_ CHUNK) {41 let cut = rest.lastIndexOf( ',', MAX_ CHUNK);42 if ( cut < MAX_ CHUNK/ 2 ) {43 cut = rest.lastIndexOf( ' ', MAX_ CHUNK);51 while ( rest.length > MAX_SEGMENT ) { 52 let cut = rest.lastIndexOf( ',', MAX_SEGMENT ); 53 if ( cut < MAX_SEGMENT / 2 ) { 54 cut = rest.lastIndexOf( ' ', MAX_SEGMENT ); 44 55 } 45 56 if ( cut <= 0 ) { 46 cut = MAX_ CHUNK;57 cut = MAX_SEGMENT; 47 58 } 48 59 parts.push( rest.slice( 0, cut + 1 ).trim() ); … … 54 65 } 55 66 56 function chunk( text ) { 57 const sentences = text.match( /[^.!?।]+[.!?।]*\s*/g ) || []; 58 const chunks = []; 59 let current = ''; 60 61 sentences.forEach( ( sentence ) => { 62 splitLong( sentence ).forEach( ( piece ) => { 63 if ( current && current.length + piece.length + 1 > MAX_CHUNK ) { 64 chunks.push( current ); 65 current = ''; 66 } 67 current = current ? current + ' ' + piece : piece; 67 // Line breaks separate blocks such as headings and list items; sentences end at punctuation followed by a space. 68 function segments( text ) { 69 const list = []; 70 text.split( /\n+/ ).forEach( ( line ) => { 71 line.split( /(?<=[.!?।])\s+/ ).forEach( ( sentence ) => { 72 splitLong( sentence.trim() ).forEach( ( piece ) => { 73 if ( piece ) { 74 list.push( piece ); 75 } 76 } ); 68 77 } ); 69 78 } ); 70 if ( current ) { 71 chunks.push( current ); 72 } 73 74 return chunks.filter( Boolean ); 75 } 76 77 // Roughly how many characters a browser voice reads per second at 1× speed. 78 const CHARS_PER_SECOND = 14; 79 80 return list; 81 } 82 83 function fold( ch ) { 84 const lower = ch.toLowerCase(); 85 86 return lower.length === 1 ? lower : ch; 87 } 88 89 function keyOf( text ) { 90 let key = ''; 91 for ( const ch of text ) { 92 if ( ch.length === 1 && WORD_CHAR.test( ch ) ) { 93 key += fold( ch ); 94 } 95 } 96 97 return key; 98 } 99 100 // Every letter on the page with the text node it came from, so a sentence can be turned into a DOM range. 101 function indexText( root ) { 102 const walker = document.createTreeWalker( root, window.NodeFilter.SHOW_TEXT, { 103 acceptNode: ( node ) => 104 node.parentElement && ! node.parentElement.closest( 'script, style, noscript, textarea, .auvrenth-tts' ) 105 ? window.NodeFilter.FILTER_ACCEPT 106 : window.NodeFilter.FILTER_REJECT, 107 } ); 108 const index = { chars: '', nodes: [], offsets: [] }; 109 110 for ( let node = walker.nextNode(); node; node = walker.nextNode() ) { 111 const value = node.nodeValue; 112 for ( let i = 0; i < value.length; i++ ) { 113 if ( WORD_CHAR.test( value[ i ] ) ) { 114 index.chars += fold( value[ i ] ); 115 index.nodes.push( node ); 116 index.offsets.push( i ); 117 } 118 } 119 } 120 121 return index; 122 } 123 124 // Finds each sentence on the page in reading order. Sentences that are not on the page (the title when the 125 // theme shows it elsewhere, or text changed by a filter) get no range and simply are not highlighted. 126 function locate( index, list ) { 127 const ranges = []; 128 let cursor = 0; 129 let previous = null; // The last match: which segment, where its search began and where it was found. 130 131 // A short sentence found far ahead is more likely a repeat than the real one. 132 const find = ( key, from ) => { 133 const at = index.chars.indexOf( key, from ); 134 135 return at < 0 || ( at - from > 2000 && key.length < 24 ) ? -1 : at; 136 }; 137 138 list.forEach( ( text, i ) => { 139 const key = keyOf( text ); 140 let from = cursor; 141 let at = key ? find( key, from ) : -1; 142 // A short segment such as the title can match words inside the next sentence ("about summer scarves") 143 // and push the search past it. When a segment is missing, look again from before that match and, 144 // if this segment overlaps it, the earlier match was wrong. 145 if ( key && at < 0 && previous ) { 146 const retry = find( key, previous.from ); 147 if ( retry >= 0 && retry < previous.at + previous.length ) { 148 ranges[ previous.i ] = null; 149 from = previous.from; 150 at = retry; 151 } 152 } 153 if ( at < 0 ) { 154 ranges.push( null ); 155 return; 156 } 157 const last = at + key.length - 1; 158 const range = document.createRange(); 159 range.setStart( index.nodes[ at ], index.offsets[ at ] ); 160 range.setEnd( index.nodes[ last ], index.offsets[ last ] + 1 ); 161 ranges.push( range ); 162 previous = { i, from, at, length: key.length }; 163 cursor = at + key.length; 164 } ); 165 166 return ranges; 167 } 168 169 function highlight( range ) { 170 if ( ! canHighlight ) { 171 return; 172 } 173 if ( range ) { 174 window.CSS.highlights.set( HIGHLIGHT, new window.Highlight( range ) ); 175 } else { 176 window.CSS.highlights.delete( HIGHLIGHT ); 177 } 178 } 79 179 80 180 function fillSelect( select, options, value ) { … … 90 190 function voiceLabel( voice ) { 91 191 return voice.name.split( ' - ' )[ 0 ]; 192 } 193 194 function clamp( value, min, max, fallback ) { 195 value = parseFloat( value ); 196 197 return Number.isFinite( value ) ? Math.min( max, Math.max( min, value ) ) : fallback; 92 198 } 93 199 … … 97 203 this.toggle = el.querySelector( '.auvrenth-tts__toggle' ); 98 204 this.meta = el.querySelector( '.auvrenth-tts__meta' ); 205 this.prevButton = el.querySelector( '.auvrenth-tts__prev' ); 206 this.nextButton = el.querySelector( '.auvrenth-tts__next' ); 99 207 this.stopButton = el.querySelector( '.auvrenth-tts__stop' ); 208 this.resumeButton = el.querySelector( '.auvrenth-tts__resume' ); 100 209 this.speedButton = el.querySelector( '.auvrenth-tts__speed' ); 101 210 this.settingsButton = el.querySelector( '.auvrenth-tts__settings' ); … … 103 212 this.languageField = el.querySelector( '.auvrenth-tts__language' ); 104 213 this.voiceField = el.querySelector( '.auvrenth-tts__voice' ); 105 this.progress = el.querySelector( '.auvrenth-tts__progress span' ); 106 this.chunks = chunk( el.dataset.text || '' ); 214 this.bar = el.querySelector( '.auvrenth-tts__progress' ); 215 this.progress = this.bar.querySelector( 'span' ); 216 this.segments = segments( el.dataset.text || '' ); 217 // Character offset where each segment starts, so progress follows the text rather than the sentence count. 218 this.offsets = []; 219 this.total = this.segments.reduce( ( sum, s ) => { 220 this.offsets.push( sum ); 221 return sum + s.length; 222 }, 0 ); 223 107 224 this.speeds = JSON.parse( el.dataset.speeds || '[1]' ).map( Number ); 108 225 this.rate = parseFloat( el.dataset.rate ) || 1; 226 // Set by the site owner only; volume is left to the visitor's device, which every voice respects. 227 this.pitch = clamp( el.dataset.pitch, 0.5, 2, 1 ); 228 this.useHighlight = canHighlight && el.dataset.highlight === '1'; 229 this.autoscroll = el.dataset.autoscroll === '1'; 230 this.ranges = null; 109 231 this.index = 0; 232 this.offset = 0; // Where speech (re)starts inside the current segment. 233 this.boundary = -1; // Last word position the voice reported, or -1. 234 this.started = 0; 110 235 this.state = 'idle'; 236 this.stopped = false; 111 237 this.token = 0; 238 this.ticker = null; 239 this.anchor = null; 112 240 this.locale = ''; 113 241 this.voiceName = ''; 114 242 115 if ( ! this. chunks.length ) {243 if ( ! this.segments.length ) { 116 244 return; 117 245 } 118 246 119 247 this.toggle.addEventListener( 'click', () => this.onToggle() ); 248 this.prevButton.addEventListener( 'click', () => this.skip( -1 ) ); 249 this.nextButton.addEventListener( 'click', () => this.skip( 1 ) ); 120 250 this.stopButton.addEventListener( 'click', () => this.stop() ); 251 this.resumeButton.addEventListener( 'click', () => this.play() ); 121 252 this.speedButton.addEventListener( 'click', () => this.nextSpeed() ); 253 this.bar.addEventListener( 'click', ( e ) => this.seek( e ) ); 254 el.addEventListener( 'keydown', ( e ) => this.onKey( e ) ); 122 255 if ( this.panel ) { 123 256 this.settingsButton.addEventListener( 'click', () => this.togglePanel() ); 124 this.panel.addEventListener( 'keydown', ( e ) => {125 if ( e.key === 'Escape' ) {126 this.togglePanel( false );127 this.settingsButton.focus();128 }129 } );130 257 this.languageField.querySelector( 'select' ).addEventListener( 'change', ( e ) => this.onLanguageChange( e.target.value ) ); 131 258 this.voiceField.querySelector( 'select' ).addEventListener( 'change', ( e ) => this.onVoiceChange( e.target.value ) ); 132 259 } 260 if ( el.dataset.sticky === '1' ) { 261 this.watchScroll(); 262 } 133 263 this.render(); 134 264 el.hidden = false; 265 } 266 267 onKey( e ) { 268 if ( e.key === 'Escape' && this.panel && ! this.panel.hidden ) { 269 this.togglePanel( false ); 270 this.settingsButton.focus(); 271 return; 272 } 273 // Arrow keys belong to the lists in the panel. 274 if ( this.state === 'idle' || e.target.closest( 'select' ) ) { 275 return; 276 } 277 if ( e.key === 'ArrowLeft' || e.key === 'ArrowRight' ) { 278 e.preventDefault(); 279 const forward = ( e.key === 'ArrowRight' ) !== ( window.getComputedStyle( this.el ).direction === 'rtl' ); 280 this.skip( forward ? 1 : -1 ); 281 } 135 282 } 136 283 … … 145 292 this.panel.hidden = ! open; 146 293 this.settingsButton.setAttribute( 'aria-expanded', String( open ) ); 147 }148 149 minutesFrom( index ) {150 const chars = this.chunks.slice( index ).reduce( ( sum, c ) => sum + c.length, 0 );151 152 return Math.max( 1, Math.round( chars / ( CHARS_PER_SECOND * this.rate ) / 60 ) );153 294 } 154 295 … … 211 352 } 212 353 354 // Where to start again in the current segment: the last word the voice reached, if it reports words. 355 resumePoint() { 356 return this.boundary >= 0 ? this.boundary : this.offset; 357 } 358 213 359 restartIfPlaying() { 214 360 if ( this.state === 'playing' ) { 215 this.speakFrom( this.index );361 this.speakFrom( this.index, this.resumePoint() ); 216 362 } 217 363 } … … 223 369 active = this; 224 370 this.state = 'playing'; 225 this. render();226 this.speakFrom( this.index );371 this.stopped = false; 372 this.speakFrom( this.index, this.offset ); 227 373 } 228 374 229 375 pause() { 376 this.offset = this.resumePoint(); 230 377 this.cancel(); 231 378 this.state = 'paused'; … … 233 380 } 234 381 382 // Stops where it is: the main button turns into Resume and carries on from the same word. 235 383 stop() { 236 this.cancel(); 237 this.reset(); 384 const hadFocus = this.el.contains( document.activeElement ); 385 this.pause(); 386 this.stopped = true; 387 this.render(); 388 // The Stop button is replaced by Resume, so keep keyboard focus in the same place. 389 if ( hadFocus ) { 390 this.resumeButton.focus(); 391 } 238 392 } 239 393 240 394 reset() { 241 this.index = 0;242 this.state = 'idle';243 395 if ( active === this ) { 244 396 active = null; 245 } 397 highlight( null ); 398 } 399 this.index = 0; 400 this.offset = 0; 401 this.boundary = -1; 402 this.state = 'idle'; 403 this.stopped = false; 246 404 this.render(); 405 } 406 407 skip( delta ) { 408 this.goTo( Math.min( Math.max( this.index + delta, 0 ), this.segments.length - 1 ) ); 409 } 410 411 // Clicking the progress bar jumps to the sentence under the pointer. 412 seek( e ) { 413 const rect = this.bar.getBoundingClientRect(); 414 const rtl = window.getComputedStyle( this.bar ).direction === 'rtl'; 415 const fraction = ( rtl ? rect.right - e.clientX : e.clientX - rect.left ) / rect.width; 416 const target = fraction * this.total; 417 const index = this.offsets.findIndex( ( start, i ) => target < start + this.segments[ i ].length ); 418 419 this.goTo( index < 0 ? this.segments.length - 1 : index ); 420 } 421 422 goTo( index ) { 423 if ( this.state === 'playing' ) { 424 this.speakFrom( index ); 425 return; 426 } 427 this.index = index; 428 this.offset = 0; 429 this.boundary = -1; 430 if ( this.state === 'idle' ) { 431 this.play(); 432 } else { 433 this.render(); 434 } 247 435 } 248 436 … … 252 440 } 253 441 254 speakFrom( index ) {442 speakFrom( index, offset = 0 ) { 255 443 this.cancel(); 256 if ( index >= this. chunks.length ) {444 if ( index >= this.segments.length ) { 257 445 this.reset(); 258 446 return; … … 260 448 261 449 this.index = index; 450 this.offset = offset; 451 this.boundary = -1; 452 this.started = 0; 262 453 this.render(); 263 454 const token = this.token; 264 const utterance = new window.SpeechSynthesisUtterance( this. chunks[ index ]);455 const utterance = new window.SpeechSynthesisUtterance( this.segments[ index ].slice( offset ) ); 265 456 const voice = this.currentVoice(); 266 457 267 458 utterance.rate = this.rate; 459 utterance.pitch = this.pitch; 268 460 if ( voice ) { 269 461 utterance.voice = voice; … … 272 464 utterance.lang = document.documentElement.lang; 273 465 } 466 utterance.onstart = () => { 467 if ( token === this.token ) { 468 this.started = window.performance.now(); 469 } 470 }; 471 // Word positions, where the voice reports them (many network voices do not). 472 utterance.onboundary = ( event ) => { 473 if ( token === this.token ) { 474 this.boundary = offset + event.charIndex; 475 this.updateProgress(); 476 } 477 }; 274 478 utterance.onend = () => { 275 479 if ( token === this.token ) { … … 286 490 } 287 491 492 // Characters already read in the current segment: the voice's word position when it reports one, 493 // otherwise an estimate from the time spoken, never past the end of the segment. 494 spoken() { 495 const length = this.segments[ this.index ].length; 496 if ( this.state !== 'playing' ) { 497 return this.offset; 498 } 499 if ( this.boundary >= 0 ) { 500 return Math.min( length, this.boundary ); 501 } 502 if ( ! this.started ) { 503 return this.offset; 504 } 505 const elapsed = ( window.performance.now() - this.started ) / 1000; 506 507 return Math.min( length, this.offset + elapsed * CHARS_PER_SECOND * this.rate ); 508 } 509 510 read() { 511 return this.state === 'idle' ? 0 : this.offsets[ this.index ] + this.spoken(); 512 } 513 514 updateProgress() { 515 this.progress.style.width = ( this.read() / this.total ) * 100 + '%'; 516 } 517 518 // Marks the sentence being read and, when asked, scrolls the page so it stays in view. 519 showHighlight() { 520 if ( ! ( this.useHighlight || this.autoscroll ) || this.state === 'idle' ) { 521 return; 522 } 523 if ( ! this.ranges ) { 524 const root = this.el.closest( '.entry-content, .wp-block-post-content, .post-content, article, main' ) || this.el.parentElement; 525 this.ranges = locate( indexText( root ), this.segments ); 526 } 527 const range = this.ranges[ this.index ]; 528 if ( this.useHighlight ) { 529 highlight( range ); 530 } 531 if ( this.autoscroll && this.state === 'playing' && range ) { 532 this.follow( range ); 533 } 534 } 535 536 // Scrolls only when the sentence has left the screen, and not while the visitor is scrolling themselves. 537 follow( range ) { 538 if ( window.performance.now() - lastInput < USER_SCROLL_PAUSE ) { 539 return; 540 } 541 const rect = range.getBoundingClientRect(); 542 if ( ! rect.width && ! rect.height ) { 543 return; // Hidden text has no place on the page. 544 } 545 // Leave room for the mini player pinned to the bottom edge. 546 const bottomGap = this.anchor ? this.el.offsetHeight + 48 : 48; 547 if ( rect.top >= 48 && rect.bottom <= window.innerHeight - bottomGap ) { 548 return; 549 } 550 const calm = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; 551 window.scrollBy( { top: rect.top - window.innerHeight * 0.3, behavior: calm ? 'auto' : 'smooth' } ); 552 } 553 554 // Keeps a compact player pinned to the bottom of the screen while listening, once the original scrolls away. 555 watchScroll() { 556 this.anchor = document.createElement( 'div' ); 557 this.anchor.className = 'auvrenth-tts-anchor'; 558 this.el.before( this.anchor ); 559 560 let queued = false; 561 const queue = () => { 562 if ( ! queued ) { 563 queued = true; 564 window.requestAnimationFrame( () => { 565 queued = false; 566 this.updateFloating(); 567 } ); 568 } 569 }; 570 // Capture, so a theme that scrolls inside an element instead of the window is noticed too. 571 window.addEventListener( 'scroll', queue, { passive: true, capture: true } ); 572 window.addEventListener( 'resize', queue ); 573 } 574 575 updateFloating() { 576 if ( ! this.anchor ) { 577 return; 578 } 579 const floating = this.el.classList.contains( 'auvrenth-tts--floating' ); 580 // While floating, the anchor holds the player's place in the page. 581 const rect = ( floating ? this.anchor : this.el ).getBoundingClientRect(); 582 const offscreen = rect.bottom < 0 || rect.top > window.innerHeight; 583 const wanted = this.state !== 'idle' && offscreen; 584 if ( wanted === floating ) { 585 return; 586 } 587 if ( wanted ) { 588 const style = window.getComputedStyle( this.el ); 589 this.anchor.style.height = this.el.offsetHeight + parseFloat( style.marginTop ) + parseFloat( style.marginBottom ) + 'px'; 590 } else { 591 this.anchor.style.height = ''; 592 } 593 this.el.classList.toggle( 'auvrenth-tts--floating', wanted ); 594 } 595 288 596 render() { 289 597 const labels = { … … 294 602 const idle = this.state === 'idle'; 295 603 const speed = this.rate + '×'; 296 let meta = format( idle ? l10n.minListen : l10n.minLeft, this.minutesFrom( idle ? 0 : this.index ) ); 604 const minutes = Math.max( 1, Math.round( ( this.total - this.read() ) / ( CHARS_PER_SECOND * this.rate ) / 60 ) ); 605 let meta = format( idle ? l10n.minListen : l10n.minLeft, minutes ); 297 606 if ( this.state === 'paused' ) { 298 meta = ( l10n.paused || 'Paused' ) + ' · ' + meta;607 meta = ( this.stopped ? l10n.stopped || 'Stopped' : l10n.paused || 'Paused' ) + ' · ' + meta; 299 608 } 300 609 301 610 this.el.dataset.state = this.state; 302 611 this.toggle.setAttribute( 'aria-label', labels[ this.state ] ); 612 this.toggle.title = labels[ this.state ]; 303 613 this.meta.textContent = meta; 304 614 this.speedButton.textContent = speed; 305 615 this.speedButton.setAttribute( 'aria-label', format( l10n.speed, speed ) ); 306 this.progress.style.width = idle ? '0' : ( this.index / this.chunks.length ) * 100 + '%'; 616 this.speedButton.title = format( l10n.speed, speed ); 617 this.updateProgress(); 618 this.showHighlight(); 619 this.updateFloating(); 620 621 // Move the bar while a sentence is spoken, not only when the next one starts. 622 if ( this.state === 'playing' && ! this.ticker ) { 623 this.ticker = window.setInterval( () => this.updateProgress(), 250 ); 624 } else if ( this.state !== 'playing' && this.ticker ) { 625 window.clearInterval( this.ticker ); 626 this.ticker = null; 627 } 307 628 } 308 629 } … … 312 633 V.onChange( () => players.forEach( ( player ) => player.refreshVoices() ) ); 313 634 } 635 636 // Hands, wheels and scroll keys mean the visitor is steering the page. 637 [ 'wheel', 'touchmove' ].forEach( ( type ) => { 638 window.addEventListener( type, () => { 639 lastInput = window.performance.now(); 640 }, { passive: true } ); 641 } ); 642 window.addEventListener( 'keydown', ( e ) => { 643 const scrollKeys = [ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ' ]; 644 // On a button or list the key operates that control (Space presses it), it does not scroll. 645 if ( scrollKeys.includes( e.key ) && ! e.target.closest( 'button, select, input, textarea, a' ) ) { 646 lastInput = window.performance.now(); 647 } 648 }, { passive: true } ); 314 649 315 650 // Some browsers keep speaking after the visitor leaves the page. -
auvrenth-text-to-speech-reader/trunk/auvrenth-text-to-speech-reader.php
r3777507 r3778385 3 3 * Plugin Name: Auvrenth Text-to-Speech Reader 4 4 * Description: Add a listen button to your posts so visitors can hear them read aloud, using the browser's built-in voices. 5 * Version: 0.1.05 * Version: 1.0.0 6 6 * Requires at least: 6.3 7 7 * Requires PHP: 8.0 … … 18 18 } 19 19 20 define( 'AUVRENTH_TTS_VERSION', ' 0.1.0' );20 define( 'AUVRENTH_TTS_VERSION', '1.0.0' ); 21 21 define( 'AUVRENTH_TTS_PLUGIN_FILE', __FILE__ ); 22 22 define( 'AUVRENTH_TTS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); -
auvrenth-text-to-speech-reader/trunk/includes/Player.php
r3777507 r3778385 19 19 public const VOICES_HANDLE = 'auvrenth-tts-voices'; 20 20 21 public const BLOCK = 'auvrenth-tts/player'; 22 21 23 private const HANDLE = 'auvrenth-tts'; 22 24 private const SPEEDS = array( 0.75, 1.0, 1.25, 1.5, 2.0 ); 25 26 /** 27 * Cache-busting version for a bundled file: the plugin version plus the file's modified time, so a changed 28 * file is never served from a browser's or CDN's old copy under the same version number. 29 * 30 * @param string $path File path relative to the plugin folder. 31 */ 32 public static function asset_version( string $path ): string { 33 $modified = filemtime( AUVRENTH_TTS_PLUGIN_DIR . $path ); 34 35 return false === $modified ? AUVRENTH_TTS_VERSION : AUVRENTH_TTS_VERSION . '.' . $modified; 36 } 23 37 24 38 /** … … 30 44 AUVRENTH_TTS_PLUGIN_URL . 'assets/js/voices.js', 31 45 array(), 32 AUVRENTH_TTS_VERSION,46 self::asset_version( 'assets/js/voices.js' ), 33 47 array( 34 48 'in_footer' => true, … … 39 53 40 54 /** 41 * Hooks the shortcode, content filter and assets.55 * Hooks the shortcode, block, content filter and assets. 42 56 */ 43 57 public function register(): void { 44 58 add_shortcode( 'auvrenth_tts', array( $this, 'shortcode' ) ); 59 add_action( 'init', array( $this, 'register_block' ) ); 45 60 add_filter( 'the_content', array( $this, 'filter_content' ), 20 ); 46 61 add_action( 'wp_enqueue_scripts', array( $this, 'register_assets' ) ); … … 48 63 49 64 /** 65 * Registers the Auvrenth Player block, rendered on the server like the shortcode. 66 */ 67 public function register_block(): void { 68 // The editor shows the real player styles, so the preview looks like the published page. 69 wp_register_style( self::HANDLE, AUVRENTH_TTS_PLUGIN_URL . 'assets/css/player.css', array(), self::asset_version( 'assets/css/player.css' ) ); 70 71 $type = register_block_type( 72 AUVRENTH_TTS_PLUGIN_DIR . 'blocks/player', 73 array( 'render_callback' => array( $this, 'render_block' ) ) 74 ); 75 if ( ! $type || empty( $type->editor_script_handles ) ) { 76 return; 77 } 78 79 // What the block falls back to when a setting is left on "site setting". 80 $settings = Settings::get(); 81 wp_add_inline_script( 82 $type->editor_script_handles[0], 83 'window.auvrenthTtsBlock = ' . wp_json_encode( 84 array( 85 'label' => $settings['button_label'], 86 'rate' => (float) $settings['rate'], 87 'accent' => strtolower( $settings['accent'] ) !== Settings::DEFAULT_ACCENT ? $settings['accent'] : '', 88 'iconColor' => $settings['icon_color'], 89 'textColor' => $settings['text_color'], 90 'highlight' => (bool) $settings['highlight'], 91 'autoscroll' => (bool) $settings['autoscroll'], 92 'sticky' => (bool) $settings['sticky'], 93 'switcher' => (bool) $settings['show_switcher'], 94 'speeds' => self::SPEEDS, 95 ) 96 ) . ';', 97 'before' 98 ); 99 } 100 101 /** 102 * Output for the Auvrenth Player block: the player wrapped in the block's own classes and spacing. 103 * 104 * @param array $attributes Block settings chosen in the editor. 105 */ 106 public function render_block( array $attributes ): string { 107 $post = get_post(); 108 if ( ! $post || MetaBox::is_disabled( $post->ID ) ) { 109 return ''; 110 } 111 112 $player = $this->render( $post, 'manual', self::overrides_from( $attributes ) ); 113 if ( '' === $player ) { 114 return ''; 115 } 116 117 return '<div ' . get_block_wrapper_attributes() . '>' . $player . '</div>'; 118 } 119 120 /** 121 * Turns block settings into the site settings they replace. A setting left on "site setting" is not included. 122 * 123 * @param array $attributes Block settings chosen in the editor. 124 */ 125 private static function overrides_from( array $attributes ): array { 126 $overrides = array(); 127 128 $label = sanitize_text_field( (string) ( $attributes['label'] ?? '' ) ); 129 if ( '' !== $label ) { 130 $overrides['button_label'] = $label; 131 } 132 133 $rate = (float) ( $attributes['rate'] ?? 0 ); 134 if ( in_array( $rate, self::SPEEDS, true ) ) { 135 $overrides['rate'] = $rate; 136 } 137 138 $colors = array( 139 'accentColor' => 'accent', 140 'iconColor' => 'icon_color', 141 'textColor' => 'text_color', 142 ); 143 foreach ( $colors as $attribute => $setting ) { 144 $color = sanitize_hex_color( (string) ( $attributes[ $attribute ] ?? '' ) ); 145 if ( $color ) { 146 $overrides[ $setting ] = $color; 147 } 148 } 149 150 // The site's icon color was picked for the site's button color, so a button color chosen here 151 // gets an icon that suits it, unless this block picks an icon color too. 152 if ( isset( $overrides['accent'] ) && ! isset( $overrides['icon_color'] ) ) { 153 $overrides['icon_color'] = ''; 154 } 155 156 foreach ( array( 'highlight', 'autoscroll', 'sticky' ) as $setting ) { 157 $choice = $attributes[ $setting ] ?? 'inherit'; 158 if ( 'on' === $choice || 'off' === $choice ) { 159 $overrides[ $setting ] = 'on' === $choice; 160 } 161 } 162 163 return $overrides; 164 } 165 166 /** 50 167 * Registers the player's style and scripts, enqueuing them where the player will show. 51 168 */ 52 169 public function register_assets(): void { 53 wp_register_style( self::HANDLE, AUVRENTH_TTS_PLUGIN_URL . 'assets/css/player.css', array(), AUVRENTH_TTS_VERSION);170 wp_register_style( self::HANDLE, AUVRENTH_TTS_PLUGIN_URL . 'assets/css/player.css', array(), self::asset_version( 'assets/css/player.css' ) ); 54 171 self::register_voices_script(); 55 172 wp_register_script( … … 57 174 AUVRENTH_TTS_PLUGIN_URL . 'assets/js/player.js', 58 175 array( self::VOICES_HANDLE ), 59 AUVRENTH_TTS_VERSION,176 self::asset_version( 'assets/js/player.js' ), 60 177 array( 61 178 'in_footer' => true, … … 73 190 'resume' => __( 'Resume', 'auvrenth-text-to-speech-reader' ), 74 191 'paused' => __( 'Paused', 'auvrenth-text-to-speech-reader' ), 192 'stopped' => __( 'Stopped', 'auvrenth-text-to-speech-reader' ), 75 193 /* translators: %s: estimated number of minutes. */ 76 194 'minListen' => __( '%s min listen', 'auvrenth-text-to-speech-reader' ), … … 103 221 $post = get_post(); 104 222 $position = Settings::get()['position']; 105 if ( ! $post || 'manual' === $position || ! $this->is_enabled_for( $post ) || has_shortcode( $post->post_content, 'auvrenth_tts') ) {223 if ( ! $post || 'manual' === $position || ! $this->is_enabled_for( $post ) || $this->is_placed_manually( $post ) ) { 106 224 return $content; 107 225 } … … 113 231 114 232 /** 115 * Output for the [auvrenth_tts] shortcode. 233 * Whether the post places the player itself with the shortcode or the block. 234 * 235 * @param \WP_Post $post Post to check. 236 */ 237 private function is_placed_manually( \WP_Post $post ): bool { 238 return has_shortcode( $post->post_content, 'auvrenth_tts' ) || has_block( self::BLOCK, $post ); 239 } 240 241 /** 242 * Output for the [auvrenth_tts] shortcode and the Auvrenth Player block. 116 243 */ 117 244 public function shortcode(): string { … … 145 272 * 146 273 * @param \WP_Post $post Post to read. 147 * @param string $position Placement: before, after or manual. 148 */ 149 private function render( \WP_Post $post, string $position ): string { 274 * @param string $position Placement: before, after or manual. 275 * @param array $overrides Settings that replace the site settings for this one player. 276 */ 277 private function render( \WP_Post $post, string $position, array $overrides = array() ): string { 150 278 if ( post_password_required( $post ) ) { 151 279 return ''; … … 159 287 $this->enqueue(); 160 288 161 $settings = Settings::get();289 $settings = array_merge( Settings::get(), $overrides ); 162 290 $rate = (float) $settings['rate']; 163 291 $speeds = self::SPEEDS; … … 167 295 } 168 296 $panel_id = wp_unique_id( 'auvrenth-tts-panel-' ); 297 // Only a color the site owner picked is written out, so a theme can still restyle the default. 298 $accent_style = ''; 299 $custom_accent = strtolower( $settings['accent'] ) !== Settings::DEFAULT_ACCENT; 300 if ( $custom_accent ) { 301 $accent_style = '--auvrenth-tts-accent:' . $settings['accent'] . ';'; 302 } 303 // The play icon follows the chosen color, or switches between white and black to stay readable on the button. 304 if ( '' !== $settings['icon_color'] ) { 305 $accent_style .= '--auvrenth-tts-accent-text:' . $settings['icon_color'] . ';'; 306 } elseif ( $custom_accent ) { 307 $accent_style .= '--auvrenth-tts-accent-text:' . self::readable_text_on( $settings['accent'] ) . ';'; 308 } 309 if ( '' !== $settings['text_color'] ) { 310 $accent_style .= 'color:' . $settings['text_color'] . ';'; 311 } 169 312 170 313 ob_start(); 171 314 ?> 172 <div class="auvrenth-tts auvrenth-tts--<?php echo esc_attr( $position ); ?>" data-auvrenth-tts data-state="idle" data-text="<?php echo esc_attr( $text ); ?>" data-rate="<?php echo esc_attr( (string) $rate ); ?>" data- speeds="<?php echo esc_attr( (string) wp_json_encode( $speeds )); ?>" hidden>315 <div class="auvrenth-tts auvrenth-tts--<?php echo esc_attr( $position ); ?>" data-auvrenth-tts data-state="idle" data-text="<?php echo esc_attr( $text ); ?>" data-rate="<?php echo esc_attr( (string) $rate ); ?>" data-pitch="<?php echo esc_attr( (string) $settings['pitch'] ); ?>" data-speeds="<?php echo esc_attr( (string) wp_json_encode( $speeds ) ); ?>" data-highlight="<?php echo $settings['highlight'] ? '1' : '0'; ?>" data-autoscroll="<?php echo $settings['autoscroll'] ? '1' : '0'; ?>" data-sticky="<?php echo $settings['sticky'] ? '1' : '0'; ?>" style="<?php echo esc_attr( $accent_style ); ?>" hidden> 173 316 <div class="auvrenth-tts__bar"> 174 317 <button type="button" class="auvrenth-tts__toggle" aria-label="<?php esc_attr_e( 'Play', 'auvrenth-text-to-speech-reader' ); ?>"> … … 181 324 </div> 182 325 <div class="auvrenth-tts__actions"> 183 <button type="button" class="auvrenth-tts__stop" aria-label="<?php esc_attr_e( 'Stop', 'auvrenth-text-to-speech-reader' ); ?>"> 326 <button type="button" class="auvrenth-tts__prev" title="<?php esc_attr_e( 'Previous sentence', 'auvrenth-text-to-speech-reader' ); ?>" aria-label="<?php esc_attr_e( 'Previous sentence', 'auvrenth-text-to-speech-reader' ); ?>" aria-keyshortcuts="ArrowLeft"> 327 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="6" y="6" width="2.2" height="12" rx="1"/><path d="M18 7.1v9.8c0 .8-.9 1.3-1.6.8l-7-4.9c-.6-.4-.6-1.2 0-1.6l7-4.9c.7-.5 1.6 0 1.6.8z"/></svg> 328 </button> 329 <button type="button" class="auvrenth-tts__stop" title="<?php esc_attr_e( 'Stop', 'auvrenth-text-to-speech-reader' ); ?>" aria-label="<?php esc_attr_e( 'Stop', 'auvrenth-text-to-speech-reader' ); ?>"> 184 330 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> 331 </button> 332 <button type="button" class="auvrenth-tts__resume" title="<?php esc_attr_e( 'Resume', 'auvrenth-text-to-speech-reader' ); ?>" aria-label="<?php esc_attr_e( 'Resume', 'auvrenth-text-to-speech-reader' ); ?>"> 333 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M8 5.6v12.8c0 .8.9 1.3 1.6.9l10-6.4c.6-.4.6-1.3 0-1.7l-10-6.4C8.9 4.3 8 4.8 8 5.6z"/></svg> 334 </button> 335 <button type="button" class="auvrenth-tts__next" title="<?php esc_attr_e( 'Next sentence', 'auvrenth-text-to-speech-reader' ); ?>" aria-label="<?php esc_attr_e( 'Next sentence', 'auvrenth-text-to-speech-reader' ); ?>" aria-keyshortcuts="ArrowRight"> 336 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="15.8" y="6" width="2.2" height="12" rx="1"/><path d="M6 7.1v9.8c0 .8.9 1.3 1.6.8l7-4.9c.6-.4.6-1.2 0-1.6l-7-4.9C6.9 5.8 6 6.3 6 7.1z"/></svg> 185 337 </button> 186 338 <button type="button" class="auvrenth-tts__speed"><?php echo esc_html( $rate . '×' ); ?></button> 187 339 <?php if ( $settings['show_switcher'] ) : ?> 188 <button type="button" class="auvrenth-tts__settings" aria-label="<?php esc_attr_e( 'Language and voice', 'auvrenth-text-to-speech-reader' ); ?>" aria-expanded="false" aria-controls="<?php echo esc_attr( $panel_id ); ?>" hidden>340 <button type="button" class="auvrenth-tts__settings" title="<?php esc_attr_e( 'Language and voice', 'auvrenth-text-to-speech-reader' ); ?>" aria-label="<?php esc_attr_e( 'Language and voice', 'auvrenth-text-to-speech-reader' ); ?>" aria-expanded="false" aria-controls="<?php echo esc_attr( $panel_id ); ?>" hidden> 189 341 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/></svg> 190 342 </button> … … 202 354 <select></select> 203 355 </label> 356 <?php if ( $settings['volume_note'] ) : ?> 357 <p class="auvrenth-tts__note"><?php esc_html_e( 'To change the volume, use your device\'s volume controls.', 'auvrenth-text-to-speech-reader' ); ?></p> 358 <?php endif; ?> 204 359 </div> 205 360 <?php endif; ?> … … 211 366 212 367 /** 213 * Plain text the browser reads aloud: the title followed by the post body. 368 * White or black, whichever is easier to read on top of a background color (WCAG contrast). 369 * 370 * @param string $hex Background color such as #1e40af or #fff. 371 */ 372 private static function readable_text_on( string $hex ): string { 373 $hex = ltrim( $hex, '#' ); 374 if ( 3 === strlen( $hex ) ) { 375 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; 376 } 377 378 // Red, green and blue as 0-1 values, each weighted by how bright the eye sees it. 379 $luminance = 0.0; 380 foreach ( array( 381 array( 0, 0.2126 ), 382 array( 2, 0.7152 ), 383 array( 4, 0.0722 ), 384 ) as list( $offset, $weight ) ) { 385 $channel = hexdec( substr( $hex, $offset, 2 ) ) / 255; 386 $luminance += $weight * ( $channel <= 0.03928 ? $channel / 12.92 : pow( ( $channel + 0.055 ) / 1.055, 2.4 ) ); 387 } 388 389 // Below this luminance white text has the stronger contrast; above it black does. 390 return $luminance < 0.179 ? '#ffffff' : '#111111'; 391 } 392 393 /** 394 * Plain text the browser reads aloud: the title followed by the post body, one line per block. 214 395 * 215 396 * @param \WP_Post $post Post to read. 216 397 */ 217 398 public static function text_for( \WP_Post $post ): string { 218 $text = get_the_title( $post ) . '. ' . strip_shortcodes( $post->post_content ); 219 $text = html_entity_decode( wp_strip_all_tags( $text ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); 220 $text = trim( (string) preg_replace( '/\s+/u', ' ', $text ) ); 399 // End each block-level element with a line break so headings and list items are read as their own sentences. 400 $content = (string) preg_replace( '#</(?:p|h[1-6]|li|blockquote|figcaption|pre|td|th|dt|dd)>#i', "$0\n", strip_shortcodes( $post->post_content ) ); 401 $text = html_entity_decode( wp_strip_all_tags( get_the_title( $post ) . "\n" . $content ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); 402 $text = (string) preg_replace( '/(?:[^\S\n]|\x{00A0})+/u', ' ', $text ); 403 $text = trim( (string) preg_replace( '/ ?\n[\s\x{00A0}]*/u', "\n", $text ) ); 221 404 222 405 /** 223 406 * Filters the text Auvrenth Text-to-Speech Reader reads aloud for a post. 224 407 * 225 * @param string $text Plain text to read. 408 * @param string $text Plain text to read. Line breaks separate blocks such as headings. 226 409 * @param \WP_Post $post Post being read. 227 410 */ -
auvrenth-text-to-speech-reader/trunk/includes/Settings.php
r3777507 r3778385 20 20 public const PAGE = 'auvrenth-tts'; 21 21 22 public const DEFAULT_ACCENT = '#1e40af'; 23 22 24 private const POSITIONS = array( 'before', 'after', 'manual' ); 23 25 … … 31 33 'button_label' => __( 'Listen to this article', 'auvrenth-text-to-speech-reader' ), 32 34 'rate' => 1.0, 35 'pitch' => 1.0, 36 'highlight' => true, 37 'autoscroll' => true, 38 'sticky' => true, 39 'accent' => self::DEFAULT_ACCENT, 40 'icon_color' => '', // Empty picks white or black to suit the button color. 41 'text_color' => '', // Empty follows the theme's text color. 33 42 'language' => '', 34 43 'voice' => '', 35 44 'show_switcher' => true, 45 'volume_note' => true, 36 46 ); 37 47 } … … 121 131 122 132 Player::register_voices_script(); 123 wp_enqueue_script( 'auvrenth-tts-admin', AUVRENTH_TTS_PLUGIN_URL . 'assets/js/admin.js', array( Player::VOICES_HANDLE ), AUVRENTH_TTS_VERSION, true ); 133 wp_enqueue_style( 'wp-color-picker' ); 134 wp_enqueue_script( 'auvrenth-tts-admin', AUVRENTH_TTS_PLUGIN_URL . 'assets/js/admin.js', array( Player::VOICES_HANDLE, 'wp-color-picker' ), Player::asset_version( 'assets/js/admin.js' ), true ); 124 135 wp_localize_script( 125 136 'auvrenth-tts-admin', … … 166 177 add_settings_field( 'button_label', __( 'Button label', 'auvrenth-text-to-speech-reader' ), array( $this, 'field_button_label' ), self::PAGE, 'auvrenth_tts_general', array( 'label_for' => 'auvrenth-tts-button-label' ) ); 167 178 add_settings_field( 'rate', __( 'Default speed', 'auvrenth-text-to-speech-reader' ), array( $this, 'field_rate' ), self::PAGE, 'auvrenth_tts_general', array( 'label_for' => 'auvrenth-tts-rate' ) ); 179 add_settings_field( 'pitch', __( 'Default pitch', 'auvrenth-text-to-speech-reader' ), array( $this, 'field_pitch' ), self::PAGE, 'auvrenth_tts_general', array( 'label_for' => 'auvrenth-tts-pitch' ) ); 180 add_settings_field( 'reading_aids', __( 'While listening', 'auvrenth-text-to-speech-reader' ), array( $this, 'field_reading_aids' ), self::PAGE, 'auvrenth_tts_general' ); 181 add_settings_field( 'accent', __( 'Play button color', 'auvrenth-text-to-speech-reader' ), array( $this, 'field_accent' ), self::PAGE, 'auvrenth_tts_general', array( 'label_for' => 'auvrenth-tts-accent' ) ); 182 add_settings_field( 'icon_color', __( 'Play icon color', 'auvrenth-text-to-speech-reader' ), array( $this, 'field_icon_color' ), self::PAGE, 'auvrenth_tts_general', array( 'label_for' => 'auvrenth-tts-icon-color' ) ); 183 add_settings_field( 'text_color', __( 'Title and control color', 'auvrenth-text-to-speech-reader' ), array( $this, 'field_text_color' ), self::PAGE, 'auvrenth_tts_general', array( 'label_for' => 'auvrenth-tts-text-color' ) ); 168 184 169 185 add_settings_field( 'language', __( 'Default language', 'auvrenth-text-to-speech-reader' ), array( $this, 'field_language' ), self::PAGE, 'auvrenth_tts_voice', array( 'label_for' => 'auvrenth-tts-language' ) ); … … 196 212 $rate = (float) ( $input['rate'] ?? $defaults['rate'] ); 197 213 $rate = round( min( 2.0, max( 0.5, $rate ) ) * 4 ) / 4; 214 215 $accent = sanitize_hex_color( $input['accent'] ?? '' ); 216 if ( ! $accent ) { 217 $accent = $defaults['accent']; 218 } 219 220 // Empty is allowed for both: the player then picks the icon color itself and uses the theme's text color. 221 $icon_color = (string) sanitize_hex_color( $input['icon_color'] ?? '' ); 222 $text_color = (string) sanitize_hex_color( $input['text_color'] ?? '' ); 223 224 $pitch = (float) ( $input['pitch'] ?? $defaults['pitch'] ); 225 $pitch = round( min( 2.0, max( 0.5, $pitch ) ), 1 ); 198 226 199 227 // A BCP 47 tag such as "bn", "en-US" or "zh-Hant-TW"; empty means follow the site language. … … 211 239 'button_label' => $label, 212 240 'rate' => $rate, 241 'pitch' => $pitch, 242 'highlight' => ! empty( $input['highlight'] ), 243 'autoscroll' => ! empty( $input['autoscroll'] ), 244 'sticky' => ! empty( $input['sticky'] ), 245 'accent' => $accent, 246 'icon_color' => $icon_color, 247 'text_color' => $text_color, 213 248 'language' => $language, 214 249 'voice' => $voice, 215 250 'show_switcher' => ! empty( $input['show_switcher'] ), 251 'volume_note' => ! empty( $input['volume_note'] ), 216 252 ); 217 253 } … … 311 347 312 348 /** 349 * Default pitch input. 350 */ 351 public function field_pitch(): void { 352 printf( 353 '<input type="number" id="auvrenth-tts-pitch" class="small-text" name="%s[pitch]" value="%s" min="0.5" max="2" step="0.1"> <span class="description">%s</span>', 354 esc_attr( self::OPTION ), 355 esc_attr( (string) self::get()['pitch'] ), 356 esc_html__( '1 is the voice\'s normal pitch. Some browser voices ignore pitch and always use their own.', 'auvrenth-text-to-speech-reader' ) 357 ); 358 } 359 360 /** 361 * Play button color picker. 362 */ 363 public function field_accent(): void { 364 printf( 365 '<input type="text" id="auvrenth-tts-accent" class="auvrenth-tts-color-field" name="%s[accent]" value="%s" data-default-color="%s"> <p class="description">%s</p>', 366 esc_attr( self::OPTION ), 367 esc_attr( self::get()['accent'] ), 368 esc_attr( self::DEFAULT_ACCENT ), 369 esc_html__( 'Also used for the progress bar and the keyboard focus outline.', 'auvrenth-text-to-speech-reader' ) 370 ); 371 } 372 373 /** 374 * Play icon color picker. 375 */ 376 public function field_icon_color(): void { 377 printf( 378 '<input type="text" id="auvrenth-tts-icon-color" class="auvrenth-tts-color-field" name="%s[icon_color]" value="%s"> <p class="description">%s</p>', 379 esc_attr( self::OPTION ), 380 esc_attr( self::get()['icon_color'] ), 381 esc_html__( 'The play and pause icon inside the round button. Leave empty to pick white or black automatically to suit the button color.', 'auvrenth-text-to-speech-reader' ) 382 ); 383 } 384 385 /** 386 * Title and control color picker. 387 */ 388 public function field_text_color(): void { 389 printf( 390 '<input type="text" id="auvrenth-tts-text-color" class="auvrenth-tts-color-field" name="%s[text_color]" value="%s"> <p class="description">%s</p>', 391 esc_attr( self::OPTION ), 392 esc_attr( self::get()['text_color'] ), 393 esc_html__( 'The title, the time and the skip, speed and settings icons next to the play button. Leave empty to use your theme\'s text color.', 'auvrenth-text-to-speech-reader' ) 394 ); 395 } 396 397 /** 398 * Highlight and floating player checkboxes. 399 */ 400 public function field_reading_aids(): void { 401 $settings = self::get(); 402 ?> 403 <fieldset> 404 <legend class="screen-reader-text"><?php esc_html_e( 'While listening', 'auvrenth-text-to-speech-reader' ); ?></legend> 405 <label> 406 <input type="checkbox" name="<?php echo esc_attr( self::OPTION ); ?>[highlight]" value="1" <?php checked( $settings['highlight'] ); ?>> 407 <?php esc_html_e( 'Highlight the sentence being read', 'auvrenth-text-to-speech-reader' ); ?> 408 </label><br> 409 <label> 410 <input type="checkbox" name="<?php echo esc_attr( self::OPTION ); ?>[autoscroll]" value="1" <?php checked( $settings['autoscroll'] ); ?>> 411 <?php esc_html_e( 'Scroll the page to follow the sentence being read', 'auvrenth-text-to-speech-reader' ); ?> 412 </label><br> 413 <label> 414 <input type="checkbox" name="<?php echo esc_attr( self::OPTION ); ?>[sticky]" value="1" <?php checked( $settings['sticky'] ); ?>> 415 <?php esc_html_e( 'Keep a mini player at the bottom of the screen after the player scrolls out of view', 'auvrenth-text-to-speech-reader' ); ?> 416 </label> 417 </fieldset> 418 <?php 419 } 420 421 /** 313 422 * Default language select, filled with browser languages by admin.js. 314 423 */ … … 349 458 350 459 /** 351 * Visitor language switcher checkbox.460 * Visitor language switcher and volume note checkboxes. 352 461 */ 353 462 public function field_show_switcher(): void { 354 ?> 355 <label> 356 <input type="checkbox" name="<?php echo esc_attr( self::OPTION ); ?>[show_switcher]" value="1" <?php checked( self::get()['show_switcher'] ); ?>> 357 <?php esc_html_e( 'Let visitors choose the language and voice in the player', 'auvrenth-text-to-speech-reader' ); ?> 358 </label> 463 $settings = self::get(); 464 ?> 465 <fieldset> 466 <legend class="screen-reader-text"><?php esc_html_e( 'Language switcher', 'auvrenth-text-to-speech-reader' ); ?></legend> 467 <label> 468 <input type="checkbox" name="<?php echo esc_attr( self::OPTION ); ?>[show_switcher]" value="1" <?php checked( $settings['show_switcher'] ); ?>> 469 <?php esc_html_e( 'Let visitors choose the language and voice in the player', 'auvrenth-text-to-speech-reader' ); ?> 470 </label><br> 471 <label> 472 <input type="checkbox" name="<?php echo esc_attr( self::OPTION ); ?>[volume_note]" value="1" <?php checked( $settings['volume_note'] ); ?>> 473 <?php esc_html_e( 'Show a note in that panel that volume is set with the device\'s controls', 'auvrenth-text-to-speech-reader' ); ?> 474 </label> 475 </fieldset> 359 476 <?php 360 477 } -
auvrenth-text-to-speech-reader/trunk/readme.txt
r3777507 r3778385 1 1 === Auvrenth Text-to-Speech Reader === 2 Contributors: auvrenthstudio , rlorakib2 Contributors: auvrenthstudio 3 3 Tags: text to speech, tts, accessibility, audio, read aloud 4 4 Requires at least: 6.3 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 0.1.07 Stable tag: 1.0.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 15 15 Auvrenth Text-to-Speech Reader adds a listen button to your posts and pages. Visitors press play and the article is read aloud by the voices already built into their browser. 16 16 17 * No API key, no account and no monthly cost. 18 * No audio files stored on your server. 19 * Play, pause, stop and speed control. 17 It needs no account, no API key and no monthly cost, and it stores no audio files on your server. The speech is made on the visitor's own device, so nothing is sent to an outside service. 18 19 = For visitors = 20 21 * Play, pause and stop. Stopping keeps the visitor's place: the button turns into Resume and carries on from the word where reading stopped, where the voice reports its position. 22 * Skip to the previous or next sentence, or click the progress bar to jump ahead. 23 * Reading speed control, and a language and voice switcher whose choice is remembered. 24 * The sentence being read is highlighted in the article, and the page scrolls along with it. Scrolling by hand pauses the following for a few seconds. 25 * A mini player stays at the bottom of the screen when the player scrolls out of view while listening. 26 * Keyboard friendly: the left and right arrow keys skip sentences while the player has focus, and every button has a tooltip and a screen reader label. 27 28 = For site owners = 29 30 * Choose the post types, and show the player before or after the content. 31 * Set a default language, voice, speed and pitch, and test them right from the settings screen. 20 32 * Uses a voice that matches your site language when the browser has one. 21 * Set a default language and voice, and test it right from the settings screen.22 * Visitors can switch the language and voice in the player, and their choice is remembered.23 * Choose the post types, and show the player before or after the content.24 * Hide the player on single posts, or place it anywhere with the `[auvrenth_tts]` shortcode.33 * Turn sentence highlighting, page scrolling, the mini player and the language switcher on or off. 34 * Match your site with the play button color, the play icon color and the title and control color. 35 * Hide the player on single posts, or place it anywhere with the Auvrenth Player block or the `[auvrenth_tts]` shortcode. 36 * Change what is read aloud with the `auvrenth_tts_text` filter. 25 37 26 The player only appears in browsers that support speech synthesis, which covers all current versions of Chrome, Edge, Safari and Firefox. 38 The player only appears in browsers that support speech synthesis, which covers all current versions of Chrome, Edge, Safari and Firefox. Sentence highlighting needs a browser with the CSS Custom Highlight API: current Chrome, Edge and Safari, and Firefox 140 or later. Everything else works without it. 27 39 28 = Shortcode =40 = Auvrenth Player block and shortcode = 29 41 30 Add `[auvrenth_tts]` to a post or page, or to aShortcode block, to show the player exactly where you want it. It reads the post it is placed in.42 Add the Auvrenth Player block (in the block inserter under Media, or search for "Auvrenth"), or `[auvrenth_tts]` in a post, page or Shortcode block, to show the player exactly where you want it. It reads the post it is placed in. 31 43 44 * The editor shows the player as visitors will see it, and the block sidebar changes that one player only: the button label, default speed, sentence highlighting, page scrolling, the mini player, and the play button, play icon and text colors. Anything left on "site setting" follows the settings screen. 45 * The block also has the usual margin and HTML anchor settings. 32 46 * Works on any post type, even ones not ticked under "Show on" in the settings. 33 * When a post contains the shortcode, the automatic player is skipped there, so the player never shows twice.34 * Set "Player position" to "Only where I add the [auvrenth_tts] shortcode" to turn off automatic placement everywhere and use the shortcode only.35 * The shortcode showsnothing when the player is hidden for that post in the editor, or when the post is password protected.47 * When a post contains the block or the shortcode, the automatic player is skipped there, so the player never shows twice. 48 * Set "Player position" to "Only where I add the [auvrenth_tts] shortcode" to turn off automatic placement everywhere and use the block or shortcode only. 49 * The block and shortcode show nothing when the player is hidden for that post in the editor, or when the post is password protected. 36 50 37 51 == Installation == … … 39 53 1. Upload the `auvrenth-text-to-speech-reader` folder to `/wp-content/plugins/`, or install it from the Plugins screen. 40 54 2. Activate the plugin. 41 3. Open Auvrenth Reader in the admin menu to choose where the player appears .55 3. Open Auvrenth Reader in the admin menu to choose where the player appears and how it looks. 42 56 43 57 == Frequently Asked Questions == … … 51 65 Each browser and operating system ships its own voices, so the voice depends on the visitor's device. 52 66 67 = How do visitors change the volume? = 68 69 With their device's own volume controls. Browsers do not let a website set the volume for every voice, so the player does not have a volume slider. 70 71 = Why does the pitch setting not change some voices? = 72 73 The default pitch is passed to the browser, but some voices, such as many online or "natural" voices, ignore it and always use their own pitch. The speed setting works with all voices. 74 53 75 = How do I place the player in a different spot? = 54 76 55 Add the `[auvrenth_tts]` shortcodewhere the player should appear. To use it in a theme template, call `<?php echo do_shortcode( '[auvrenth_tts]' ); ?>` inside the loop and set "Player position" to the shortcode-only option, so the automatic player does not show as well.77 Add the Auvrenth Player block, or the `[auvrenth_tts]` shortcode, where the player should appear. To use it in a theme template, call `<?php echo do_shortcode( '[auvrenth_tts]' ); ?>` inside the loop and set "Player position" to the shortcode-only option, so the automatic player does not show as well. 56 78 57 79 = Can I change what is read aloud? = 58 80 59 Yes. Use the `auvrenth_tts_text` filter to change the text for any post. 81 Yes. Use the `auvrenth_tts_text` filter to change the text for any post. Line breaks in the text separate blocks such as headings and list items. 82 83 = Why is a sentence not highlighted? = 84 85 The player highlights a sentence when it finds the same words on the page. Text that is not shown on the page, such as a title the theme displays elsewhere or text changed with the `auvrenth_tts_text` filter, is still read but not highlighted. 86 87 = Can I turn off the highlighting or the page scrolling? = 88 89 Yes. Under "While listening" in the settings you can turn off sentence highlighting, page scrolling and the mini player separately. 90 91 = Can I change the colors? = 92 93 Yes. The settings screen has color pickers for the play button, the icon inside it, and the title and control icons. The Auvrenth Player block can set its own colors for one player. Themes can also restyle the player with the CSS custom properties `--auvrenth-tts-accent`, `--auvrenth-tts-accent-text` and `--auvrenth-tts-highlight`. 94 95 = Can one post look or behave differently from the rest? = 96 97 Yes. Add the Auvrenth Player block to that post and change its settings in the block sidebar. Anything left on "site setting" keeps following the settings screen. To remove the player from a post altogether, tick "Hide the listen button on this page" in the "Auvrenth Text-to-Speech Reader" box in the editor. 98 99 = What does it store about visitors? = 100 101 Only the language and voice a visitor picks in the player, kept in their own browser's local storage so the choice is remembered. No cookies are set and nothing is sent to your server or to any outside service. 102 103 = What happens to my settings if I delete the plugin? = 104 105 Deleting the plugin from the Plugins screen removes its settings and the per-post "hide the player" switches. Deactivating it keeps them. 106 107 == Screenshots == 108 109 1. The player above an article, ready to play. 110 2. Reading aloud: the sentence being read is highlighted, with previous, stop, next and speed controls. 111 3. The mini player stays at the bottom of the screen while the visitor scrolls. 112 4. The language and voice switcher in the player. 113 5. The settings screen. 114 6. The Auvrenth Player block in the editor, with its own settings in the sidebar. 60 115 61 116 == Changelog == 62 117 63 = 0.1.0 =118 = 1.0.0 = 64 119 * First release. 120 * Listen button for posts and pages that reads the article aloud with the browser's own voices. No API key and no audio files. 121 * Player controls: play, pause, stop (keeps the visitor's place and turns into Resume, which carries on from the word where reading stopped), previous and next sentence, click the progress bar to jump, and reading speed. 122 * Sentence highlighting, with page scrolling that follows the reading and steps aside when the visitor scrolls by hand. 123 * Mini player pinned to the bottom of the screen when the player scrolls out of view while listening. 124 * Language and voice switcher in the player, remembered for the visitor's next visit. 125 * Left and right arrow keys skip sentences, and every button has a tooltip and a screen reader label. 126 * Settings screen: post types, player position, button label, default speed, pitch, language and voice with a test button, highlighting, page scrolling, mini player, language switcher, and a volume note. 127 * Color settings for the play button, the play icon, and the title and control icons. 128 * Auvrenth Player block with a live preview in the editor and its own settings for the label, speed, highlighting, scrolling, mini player and colors, plus the `[auvrenth_tts]` shortcode to place the player anywhere, and a per-post switch to hide it. 129 * `auvrenth_tts_text` filter to change what is read aloud.
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)