Files
feadulta/mu-plugins/fea-audio-player.php
rafa 4d25a8d2e5 fea-audio-player: limpiar el aviso de error cuando el audio sí llega a reproducir
Si audio.play() fallaba tras verificar el hash (p.ej. política de autoplay
del navegador tras el await de fetch+digest), el aviso "No se pudo verificar
el audio" quedaba fijo en pantalla aunque el usuario reprodujera el audio a
mano desde los controles nativos que ya estaban visibles. El evento 'play'
real del <audio> ahora limpia ese aviso y oculta el botón, sea cual sea el
camino por el que arrancó la reproducción.

Reportado por Rafa en el canal feadulta, viendo el estado atascado en
"Más allá de la trinchera..." (post 56196) tras el bloqueo 403 de Cloudflare.
2026-08-28 05:29:23 -04:00

152 lines
8.0 KiB
PHP

<?php
/**
* Plugin Name: Fea Audio Player (#76)
* Description: Reproductor TTS compacto en la fila del autor (a la derecha) cuando
* existe el meta fea_audio_url (voz Nico, MiniMax HD). Se inyecta como
* último hijo del grupo flex del autor (template FSE) vía render_block.
*/
if (!defined('ABSPATH')) exit;
/** Devuelve el HTML del reproductor para el post actual, o '' si no hay audio. */
function fea_audio_player_html(): string {
$id = get_the_ID();
$url = get_post_meta($id, 'fea_audio_url', true);
if (!$url) return '';
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
$sha256 = get_post_meta($id, 'fea_audio_sha256', true);
$label = '<span class="fea-audio-label">'
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
. '</svg> Escucha</span>';
// Para un asset con hash registrado, descarga y verifica el fichero completo
// antes de delegar playback al navegador. Evita que un stream Range mezclado
// por una caché reproduzca fragmentos de otro MP3.
if (preg_match('/^[a-f0-9]{64}$/i', $sha256)) {
return '<div class="fea-audio">' . $label
. '<button type="button" class="fea-audio-verified-play" data-fea-audio-verified-play'
. ' data-src="' . esc_url($url) . '" data-sha256="' . esc_attr(strtolower($sha256)) . '"'
. ' data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '">Reproducir audio</button>'
. '<audio controls preload="none" hidden data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '<span class="fea-audio-status" role="status" aria-live="polite"></span></div>';
}
return '<div class="fea-audio">' . $label
. '<audio controls preload="none" src="' . esc_url($url) . '"'
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '</div>';
}
// Inyecta el player como último hijo de la fila flex del autor (el grupo que
// contiene el core/avatar en la cabecera FSE del single).
add_filter('render_block', function ($html, $block) {
if (!is_singular('post')) return $html;
if (($block['blockName'] ?? '') !== 'core/group') return $html;
$has_avatar = false;
foreach ($block['innerBlocks'] ?? [] as $ib) {
if (($ib['blockName'] ?? '') === 'core/avatar') { $has_avatar = true; break; }
}
if (!$has_avatar) return $html;
$player = fea_audio_player_html();
if (!$player) return $html;
$pos = strrpos($html, '</div>');
return $pos === false ? $html . $player : substr($html, 0, $pos) . $player . substr($html, $pos);
}, 10, 2);
add_action('wp_head', function () {
if (!is_singular('post')) return;
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
?>
<style>
/* El player se inyecta en el grupo flex del autor. En el single FSE ese
contenedor es el wp-block-group (no .fea-byline, que solo existe en Astra),
así que el wrap debe apuntar también a él o en móvil vertical el <audio>
no encuentra ancho y no muestra controles (#142). */
.fea-byline,
.wp-block-group:has(> .wp-block-avatar):has(.wp-block-post-author-name){flex-wrap:wrap}
.fea-audio{display:flex;align-items:center;gap:.5rem;margin-left:auto;
padding:.3rem .55rem;background:#faf6f7;border:1px solid #e7d6da;
border-left:3px solid #8b1a2e;border-radius:8px}
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
.fea-audio audio{height:32px;width:230px;max-width:44vw}
.fea-audio-verified-play{border:1px solid #8b1a2e;border-radius:6px;background:#fff;color:#8b1a2e;padding:.35rem .6rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer}
.fea-audio-verified-play:disabled{opacity:.65;cursor:wait}
.fea-audio-status{font-size:.76rem;color:#6b5c60}
@media(max-width:600px){
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
}
</style>
<?php
});
// Evento GA4 audio_play (issue tracking uso TTS). Un único evento por <audio>
// y carga de página, disparado en el primer 'play' (no en cada resume tras
// pausa/seek). gtag ya está definido por fea-analytics.php en wp_head prio 1,
// así que este script (footer) siempre lo encuentra disponible.
add_action('wp_footer', function () {
if (!is_singular('post')) return;
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
?>
<script>
(function () {
function hex(buffer) {
return Array.from(new Uint8Array(buffer)).map(function (n) {
return n.toString(16).padStart(2, '0');
}).join('');
}
document.querySelectorAll('[data-fea-audio-verified-play]').forEach(function (button) {
button.addEventListener('click', async function () {
var wrap = button.closest('.fea-audio');
var audio = wrap.querySelector('audio[data-fea-audio-track]');
var status = wrap.querySelector('.fea-audio-status');
button.disabled = true;
status.textContent = 'Verificando audio…';
try {
// Se descarga el objeto entero una vez y se valida su hash. Sólo
// entonces se crea un blob local: el <audio> no hará Range contra
// CDN y jamás podrá mezclar fragmentos de otro MP3.
var response = await fetch(button.dataset.src, {cache: 'no-store'});
if (!response.ok) throw new Error('HTTP ' + response.status);
var bytes = await response.arrayBuffer();
var digest = hex(await crypto.subtle.digest('SHA-256', bytes));
if (digest !== button.dataset.sha256) throw new Error('La copia recibida no coincide con el audio verificado');
audio.src = URL.createObjectURL(new Blob([bytes], {type: 'audio/mpeg'}));
audio.hidden = false;
button.hidden = true;
status.textContent = '';
await audio.play();
} catch (error) {
status.textContent = 'No se pudo verificar el audio. Inténtalo de nuevo.';
button.disabled = false;
console.error('Fe Adulta audio verification failed:', error);
}
});
});
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
var fired = false;
audio.addEventListener('play', function () {
// La verificación pudo fallar en el primer intento (p.ej. audio.play()
// rechazado por política de autoplay tras el await de verificación) y
// dejar el mensaje de error en pantalla. Si el audio realmente rompe a
// reproducir -en este intento o en uno posterior desde los controles
// nativos-, ese es el estado real: limpiar el aviso y ocultar el botón.
var wrap = audio.closest('.fea-audio');
if (wrap) {
var status = wrap.querySelector('.fea-audio-status');
if (status) status.textContent = '';
var button = wrap.querySelector('[data-fea-audio-verified-play]');
if (button) button.hidden = true;
}
if (fired || typeof gtag !== 'function') return;
fired = true;
gtag('event', 'audio_play', {
post_id: audio.dataset.postId,
voice: audio.dataset.voice
});
});
});
}());
</script>
<?php
});