Plugin Directory

Changeset 3778385


Ignore:
Timestamp:
11/01/2026 06:10:22 PM (2 hours ago)
Author:
auvrenthstudio
Message:

new version release 1.0.0

Location:
auvrenth-text-to-speech-reader
Files:
35 added
7 edited

Legend:

Unmodified
Added
Removed
  • auvrenth-text-to-speech-reader/trunk/assets/css/player.css

    r3777507 r3778385  
    88        --auvrenth-tts-border: rgba(127, 127, 127, 0.24);
    99        --auvrenth-tts-hover: rgba(127, 127, 127, 0.12);
     10        --auvrenth-tts-floating-bg: Canvas;
     11        --auvrenth-tts-floating-text: CanvasText;
    1012
    1113        box-sizing: border-box;
    … …  
    8486        color: var(--auvrenth-tts-accent-text) !important;
    8587        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;
    8690}
    8791
    … …  
    149153}
    150154
    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 {
    152158        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);
    153176}
    154177
    … …  
    201224}
    202225
    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. */
    204235.auvrenth-tts__progress {
     236        position: relative;
    205237        height: 3px;
    206238        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;
    207247}
    208248
    … …  
    215255}
    216256
     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
    217282@media (max-width: 480px) {
    218283        .auvrenth-tts__bar {
    … …  
    225290                height: 42px;
    226291        }
     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        }
    227301}
    228302
  • auvrenth-text-to-speech-reader/trunk/assets/js/admin.js

    r3777507 r3778385  
    44( function () {
    55        '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        }
    611
    712        const V = window.auvrenthTtsVoices;
    … …  
    1116        const test = document.getElementById( 'auvrenth-tts-test-voice' );
    1217        const note = document.getElementById( 'auvrenth-tts-voice-note' );
     18        const rate = document.getElementById( 'auvrenth-tts-rate' );
     19        const pitch = document.getElementById( 'auvrenth-tts-pitch' );
    1320
    1421        if ( ! language || ! voice ) {
    … …  
    5461                const picked = V.pick( language.value || siteLanguage, voice.value );
    5562                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;
    5666                if ( picked ) {
    5767                        utterance.voice = picked;
  • auvrenth-text-to-speech-reader/trunk/assets/js/player.js

    r3777507 r3778385  
    22 * Auvrenth Text-to-Speech Reader player: reads the post aloud with the browser's speech synthesis.
    33 *
    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().
    79 */
    810( function () {
    … …  
    1618        const synth = window.speechSynthesis;
    1719        const l10n = window.auvrenthTtsL10n || {};
    18         const MAX_CHUNK = 220;
     20        const MAX_SEGMENT = 220;
    1921        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;
    2031        let active = null;
    2132
    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.
    2334        function readChoice() {
    2435                try {
    … …  
    2940        }
    3041
    31         function saveChoice( choice ) {
     42        function saveChoice( changes ) {
    3243                try {
    33                         window.localStorage.setItem( STORE_KEY, JSON.stringify( choice ) );
     44                        window.localStorage.setItem( STORE_KEY, JSON.stringify( Object.assign( readChoice(), changes ) ) );
    3445                } catch ( e ) {}
    3546        }
    … …  
    3849                const parts = [];
    3950                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 );
    4455                        }
    4556                        if ( cut <= 0 ) {
    46                                 cut = MAX_CHUNK;
     57                                cut = MAX_SEGMENT;
    4758                        }
    4859                        parts.push( rest.slice( 0, cut + 1 ).trim() );
    … …  
    5465        }
    5566
    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                                } );
    6877                        } );
    6978                } );
    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        }
    79179
    80180        function fillSelect( select, options, value ) {
    … …  
    90190        function voiceLabel( voice ) {
    91191                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;
    92198        }
    93199
    … …  
    97203                        this.toggle = el.querySelector( '.auvrenth-tts__toggle' );
    98204                        this.meta = el.querySelector( '.auvrenth-tts__meta' );
     205                        this.prevButton = el.querySelector( '.auvrenth-tts__prev' );
     206                        this.nextButton = el.querySelector( '.auvrenth-tts__next' );
    99207                        this.stopButton = el.querySelector( '.auvrenth-tts__stop' );
     208                        this.resumeButton = el.querySelector( '.auvrenth-tts__resume' );
    100209                        this.speedButton = el.querySelector( '.auvrenth-tts__speed' );
    101210                        this.settingsButton = el.querySelector( '.auvrenth-tts__settings' );
    … …  
    103212                        this.languageField = el.querySelector( '.auvrenth-tts__language' );
    104213                        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
    107224                        this.speeds = JSON.parse( el.dataset.speeds || '[1]' ).map( Number );
    108225                        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;
    109231                        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;
    110235                        this.state = 'idle';
     236                        this.stopped = false;
    111237                        this.token = 0;
     238                        this.ticker = null;
     239                        this.anchor = null;
    112240                        this.locale = '';
    113241                        this.voiceName = '';
    114242
    115                         if ( ! this.chunks.length ) {
     243                        if ( ! this.segments.length ) {
    116244                                return;
    117245                        }
    118246
    119247                        this.toggle.addEventListener( 'click', () => this.onToggle() );
     248                        this.prevButton.addEventListener( 'click', () => this.skip( -1 ) );
     249                        this.nextButton.addEventListener( 'click', () => this.skip( 1 ) );
    120250                        this.stopButton.addEventListener( 'click', () => this.stop() );
     251                        this.resumeButton.addEventListener( 'click', () => this.play() );
    121252                        this.speedButton.addEventListener( 'click', () => this.nextSpeed() );
     253                        this.bar.addEventListener( 'click', ( e ) => this.seek( e ) );
     254                        el.addEventListener( 'keydown', ( e ) => this.onKey( e ) );
    122255                        if ( this.panel ) {
    123256                                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                                 } );
    130257                                this.languageField.querySelector( 'select' ).addEventListener( 'change', ( e ) => this.onLanguageChange( e.target.value ) );
    131258                                this.voiceField.querySelector( 'select' ).addEventListener( 'change', ( e ) => this.onVoiceChange( e.target.value ) );
    132259                        }
     260                        if ( el.dataset.sticky === '1' ) {
     261                                this.watchScroll();
     262                        }
    133263                        this.render();
    134264                        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                        }
    135282                }
    136283
    … …  
    145292                        this.panel.hidden = ! open;
    146293                        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 ) );
    153294                }
    154295
    … …  
    211352                }
    212353
     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
    213359                restartIfPlaying() {
    214360                        if ( this.state === 'playing' ) {
    215                                 this.speakFrom( this.index );
     361                                this.speakFrom( this.index, this.resumePoint() );
    216362                        }
    217363                }
    … …  
    223369                        active = this;
    224370                        this.state = 'playing';
    225                         this.render();
    226                         this.speakFrom( this.index );
     371                        this.stopped = false;
     372                        this.speakFrom( this.index, this.offset );
    227373                }
    228374
    229375                pause() {
     376                        this.offset = this.resumePoint();
    230377                        this.cancel();
    231378                        this.state = 'paused';
    … …  
    233380                }
    234381
     382                // Stops where it is: the main button turns into Resume and carries on from the same word.
    235383                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                        }
    238392                }
    239393
    240394                reset() {
    241                         this.index = 0;
    242                         this.state = 'idle';
    243395                        if ( active === this ) {
    244396                                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;
    246404                        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                        }
    247435                }
    248436
    … …  
    252440                }
    253441
    254                 speakFrom( index ) {
     442                speakFrom( index, offset = 0 ) {
    255443                        this.cancel();
    256                         if ( index >= this.chunks.length ) {
     444                        if ( index >= this.segments.length ) {
    257445                                this.reset();
    258446                                return;
    … …  
    260448
    261449                        this.index = index;
     450                        this.offset = offset;
     451                        this.boundary = -1;
     452                        this.started = 0;
    262453                        this.render();
    263454                        const token = this.token;
    264                         const utterance = new window.SpeechSynthesisUtterance( this.chunks[ index ] );
     455                        const utterance = new window.SpeechSynthesisUtterance( this.segments[ index ].slice( offset ) );
    265456                        const voice = this.currentVoice();
    266457
    267458                        utterance.rate = this.rate;
     459                        utterance.pitch = this.pitch;
    268460                        if ( voice ) {
    269461                                utterance.voice = voice;
    … …  
    272464                                utterance.lang = document.documentElement.lang;
    273465                        }
     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                        };
    274478                        utterance.onend = () => {
    275479                                if ( token === this.token ) {
    … …  
    286490                }
    287491
     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
    288596                render() {
    289597                        const labels = {
    … …  
    294602                        const idle = this.state === 'idle';
    295603                        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 );
    297606                        if ( this.state === 'paused' ) {
    298                                 meta = ( l10n.paused || 'Paused' ) + ' · ' + meta;
     607                                meta = ( this.stopped ? l10n.stopped || 'Stopped' : l10n.paused || 'Paused' ) + ' · ' + meta;
    299608                        }
    300609
    301610                        this.el.dataset.state = this.state;
    302611                        this.toggle.setAttribute( 'aria-label', labels[ this.state ] );
     612                        this.toggle.title = labels[ this.state ];
    303613                        this.meta.textContent = meta;
    304614                        this.speedButton.textContent = speed;
    305615                        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                        }
    307628                }
    308629        }
    … …  
    312633                V.onChange( () => players.forEach( ( player ) => player.refreshVoices() ) );
    313634        }
     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 } );
    314649
    315650        // Some browsers keep speaking after the visitor leaves the page.
  • auvrenth-text-to-speech-reader/trunk/auvrenth-text-to-speech-reader.php

    r3777507 r3778385  
    33 * Plugin Name: Auvrenth Text-to-Speech Reader
    44 * 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.0
     5 * Version: 1.0.0
    66 * Requires at least: 6.3
    77 * Requires PHP: 8.0
    … …  
    1818}
    1919
    20 define( 'AUVRENTH_TTS_VERSION', '0.1.0' );
     20define( 'AUVRENTH_TTS_VERSION', '1.0.0' );
    2121define( 'AUVRENTH_TTS_PLUGIN_FILE', __FILE__ );
    2222define( 'AUVRENTH_TTS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
  • auvrenth-text-to-speech-reader/trunk/includes/Player.php

    r3777507 r3778385  
    1919        public const VOICES_HANDLE = 'auvrenth-tts-voices';
    2020
     21        public const BLOCK = 'auvrenth-tts/player';
     22
    2123        private const HANDLE = 'auvrenth-tts';
    2224        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        }
    2337
    2438        /**
    … …  
    3044                        AUVRENTH_TTS_PLUGIN_URL . 'assets/js/voices.js',
    3145                        array(),
    32                         AUVRENTH_TTS_VERSION,
     46                        self::asset_version( 'assets/js/voices.js' ),
    3347                        array(
    3448                                'in_footer' => true,
    … …  
    3953
    4054        /**
    41          * Hooks the shortcode, content filter and assets.
     55         * Hooks the shortcode, block, content filter and assets.
    4256         */
    4357        public function register(): void {
    4458                add_shortcode( 'auvrenth_tts', array( $this, 'shortcode' ) );
     59                add_action( 'init', array( $this, 'register_block' ) );
    4560                add_filter( 'the_content', array( $this, 'filter_content' ), 20 );
    4661                add_action( 'wp_enqueue_scripts', array( $this, 'register_assets' ) );
    … …  
    4863
    4964        /**
     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        /**
    50167         * Registers the player's style and scripts, enqueuing them where the player will show.
    51168         */
    52169        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' ) );
    54171                self::register_voices_script();
    55172                wp_register_script(
    … …  
    57174                        AUVRENTH_TTS_PLUGIN_URL . 'assets/js/player.js',
    58175                        array( self::VOICES_HANDLE ),
    59                         AUVRENTH_TTS_VERSION,
     176                        self::asset_version( 'assets/js/player.js' ),
    60177                        array(
    61178                                'in_footer' => true,
    … …  
    73190                                'resume'    => __( 'Resume', 'auvrenth-text-to-speech-reader' ),
    74191                                'paused'    => __( 'Paused', 'auvrenth-text-to-speech-reader' ),
     192                                'stopped'   => __( 'Stopped', 'auvrenth-text-to-speech-reader' ),
    75193                                /* translators: %s: estimated number of minutes. */
    76194                                'minListen' => __( '%s min listen', 'auvrenth-text-to-speech-reader' ),
    … …  
    103221                $post     = get_post();
    104222                $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 ) ) {
    106224                        return $content;
    107225                }
    … …  
    113231
    114232        /**
    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.
    116243         */
    117244        public function shortcode(): string {
    … …  
    145272         *
    146273         * @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 {
    150278                if ( post_password_required( $post ) ) {
    151279                        return '';
    … …  
    159287                $this->enqueue();
    160288
    161                 $settings = Settings::get();
     289                $settings = array_merge( Settings::get(), $overrides );
    162290                $rate     = (float) $settings['rate'];
    163291                $speeds   = self::SPEEDS;
    … …  
    167295                }
    168296                $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                }
    169312
    170313                ob_start();
    171314                ?>
    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>
    173316                        <div class="auvrenth-tts__bar">
    174317                                <button type="button" class="auvrenth-tts__toggle" aria-label="<?php esc_attr_e( 'Play', 'auvrenth-text-to-speech-reader' ); ?>">
    … …  
    181324                                </div>
    182325                                <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' ); ?>">
    184330                                                <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>
    185337                                        </button>
    186338                                        <button type="button" class="auvrenth-tts__speed"><?php echo esc_html( $rate . '×' ); ?></button>
    187339                                        <?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>
    189341                                                        <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>
    190342                                                </button>
    … …  
    202354                                                <select></select>
    203355                                        </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; ?>
    204359                                </div>
    205360                        <?php endif; ?>
    … …  
    211366
    212367        /**
    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.
    214395         *
    215396         * @param \WP_Post $post Post to read.
    216397         */
    217398        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 ) );
    221404
    222405                /**
    223406                 * Filters the text Auvrenth Text-to-Speech Reader reads aloud for a post.
    224407                 *
    225                  * @param string   $text Plain text to read.
     408                 * @param string   $text Plain text to read. Line breaks separate blocks such as headings.
    226409                 * @param \WP_Post $post Post being read.
    227410                 */
  • auvrenth-text-to-speech-reader/trunk/includes/Settings.php

    r3777507 r3778385  
    2020        public const PAGE   = 'auvrenth-tts';
    2121
     22        public const DEFAULT_ACCENT = '#1e40af';
     23
    2224        private const POSITIONS = array( 'before', 'after', 'manual' );
    2325
    … …  
    3133                        'button_label'  => __( 'Listen to this article', 'auvrenth-text-to-speech-reader' ),
    3234                        '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.
    3342                        'language'      => '',
    3443                        'voice'         => '',
    3544                        'show_switcher' => true,
     45                        'volume_note'   => true,
    3646                );
    3747        }
    … …  
    121131
    122132                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 );
    124135                wp_localize_script(
    125136                        'auvrenth-tts-admin',
    … …  
    166177                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' ) );
    167178                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' ) );
    168184
    169185                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' ) );
    … …  
    196212                $rate = (float) ( $input['rate'] ?? $defaults['rate'] );
    197213                $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 );
    198226
    199227                // A BCP 47 tag such as "bn", "en-US" or "zh-Hant-TW"; empty means follow the site language.
    … …  
    211239                        'button_label'  => $label,
    212240                        '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,
    213248                        'language'      => $language,
    214249                        'voice'         => $voice,
    215250                        'show_switcher' => ! empty( $input['show_switcher'] ),
     251                        'volume_note'   => ! empty( $input['volume_note'] ),
    216252                );
    217253        }
    … …  
    311347
    312348        /**
     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        /**
    313422         * Default language select, filled with browser languages by admin.js.
    314423         */
    … …  
    349458
    350459        /**
    351          * Visitor language switcher checkbox.
     460         * Visitor language switcher and volume note checkboxes.
    352461         */
    353462        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>
    359476                <?php
    360477        }
  • auvrenth-text-to-speech-reader/trunk/readme.txt

    r3777507 r3778385  
    11=== Auvrenth Text-to-Speech Reader ===
    2 Contributors: auvrenthstudio, rlorakib
     2Contributors: auvrenthstudio
    33Tags: text to speech, tts, accessibility, audio, read aloud
    44Requires at least: 6.3
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 0.1.0
     7Stable tag: 1.0.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1515Auvrenth 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.
    1616
    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.
     17It 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.
    2032* 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.
    2537
    26 The player only appears in browsers that support speech synthesis, which covers all current versions of Chrome, Edge, Safari and Firefox.
     38The 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.
    2739
    28 = Shortcode =
     40= Auvrenth Player block and shortcode =
    2941
    30 Add `[auvrenth_tts]` to a post or page, or to a Shortcode block, to show the player exactly where you want it. It reads the post it is placed in.
     42Add 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.
    3143
     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.
    3246* 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 shows nothing 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.
    3650
    3751== Installation ==
    … …  
    39531. Upload the `auvrenth-text-to-speech-reader` folder to `/wp-content/plugins/`, or install it from the Plugins screen.
    40542. Activate the plugin.
    41 3. Open Auvrenth Reader in the admin menu to choose where the player appears.
     553. Open Auvrenth Reader in the admin menu to choose where the player appears and how it looks.
    4256
    4357== Frequently Asked Questions ==
    … …  
    5165Each browser and operating system ships its own voices, so the voice depends on the visitor's device.
    5266
     67= How do visitors change the volume? =
     68
     69With 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
     73The 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
    5375= How do I place the player in a different spot? =
    5476
    55 Add 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.
     77Add 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.
    5678
    5779= Can I change what is read aloud? =
    5880
    59 Yes. Use the `auvrenth_tts_text` filter to change the text for any post.
     81Yes. 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
     85The 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
     89Yes. 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
     93Yes. 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
     97Yes. 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
     101Only 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
     105Deleting 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
     1091. The player above an article, ready to play.
     1102. Reading aloud: the sentence being read is highlighted, with previous, stop, next and speed controls.
     1113. The mini player stays at the bottom of the screen while the visitor scrolls.
     1124. The language and voice switcher in the player.
     1135. The settings screen.
     1146. The Auvrenth Player block in the editor, with its own settings in the sidebar.
    60115
    61116== Changelog ==
    62117
    63 = 0.1.0 =
     118= 1.0.0 =
    64119* 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.