Leskys_Shop / ui /app.py
siigrid's picture
first push
9a097d5
Raw
History Blame Contribute Delete
7.32 kB
"""
ui.app — Router Gradio. Lee vm.screen y monta la pantalla activa con @gr.render.
"""
from __future__ import annotations
from pathlib import Path
import gradio as gr
from ui.assets import ASSETS_DIR, music_tag, voice_tag, MUSIC_VOLUME
from ui.state import ViewModel
from ui.screens import SCREENS, title
from ui.tts import warmup as _tts_warmup
from game.llm import warmup as _llm_warmup
CSS = (Path(__file__).resolve().parent / "style.css").read_text(encoding="utf-8")
# Forzar SIEMPRE modo oscuro: Gradio lo controla con el query param ?__theme=dark.
# Va en el <head> (no en js=): un <script> en head corre AL INSTANTE, antes de que
# Gradio monte y decida el tema. js= corre tras montar -> tarde y poco fiable
# (en Chrome no llegaba a redirigir). Así el redirect ocurre antes de pintar nada.
# <head>: dos cosas independientes, modo oscuro y música. La música es UNA sola
# pieza (IIFE) con responsabilidades claras:
# ensure() crea el <audio> + botón (una vez) · start() arranca (tras gesto) ·
# tick() lee el <div data-music> de la pantalla y cambia de pista.
# Va en el <head> porque carga en todos los navegadores (Safari incluido) y el
# <audio> vive en window -> sobrevive a los re-render de @gr.render.
HEAD = """
<script>
// --- Modo oscuro: redirige a ?__theme=dark si falta (antes de pintar). ---
(function () {
var u = new URL(window.location.href);
if (u.searchParams.get('__theme') !== 'dark') {
u.searchParams.set('__theme', 'dark');
window.location.replace(u.href);
}
})();
// --- Música de fondo + VOZ del enemigo: dos <audio> singleton por ID. ---
// Cualquier re-ejecución de este script (el redirect del modo oscuro recarga la
// página) reusa los mismos <audio> por su id -> NUNCA hay dos sonando a la vez.
// La voz baja la música (duck) mientras habla y la restaura al terminar.
(function () {
var DUCK = 0.18; // música mientras habla el enemigo
function audio() { // el <audio> de música (lo crea si no está)
var a = document.getElementById('__bgaudio');
if (a) return a;
a = document.createElement('audio');
a.id = '__bgaudio'; a.loop = true; a.volume = __VOL__;
document.body.appendChild(a);
var btn = document.createElement('button');
btn.id = '__bgbtn'; btn.textContent = '🔊'; btn.title = 'Sound';
btn.style.cssText = 'position:fixed;bottom:10px;right:10px;z-index:99999;' +
'background:rgba(0,0,0,.55);color:#fff;border:none;border-radius:8px;' +
'font-size:18px;line-height:1;padding:6px 9px;cursor:pointer';
btn.onclick = function () {
if (!window.__bgstarted) { start(); return; } // 1er toque: arranca, no mutea
a.muted = !a.muted; btn.textContent = a.muted ? '🔇' : '🔊';
voiceEl().muted = a.muted; // mute conjunto música+voz
if (a.muted) a.volume = __VOL__; // si estaba ducked, restaura nivel base
if (!a.muted && a.paused) a.play().catch(function () {});
};
document.body.appendChild(btn);
return a;
}
function voiceEl() { // el <audio> de voz (lo crea si no está)
var v = document.getElementById('__bgvoice');
if (v) return v;
v = document.createElement('audio');
v.id = '__bgvoice'; v.volume = 1.0;
var restore = function () { audio().volume = __VOL__; }; // sube la música otra vez
v.addEventListener('ended', restore);
v.addEventListener('error', restore);
document.body.appendChild(v);
return v;
}
function playVoice(url) { // habla el enemigo: duck + play
var v = voiceEl(), bg = audio();
v.src = url;
if (window.__bgstarted && !v.muted) {
bg.volume = __VOL__ * DUCK;
v.play().catch(function () { bg.volume = __VOL__; });
}
}
function start() { // tras un gesto del usuario
window.__bgstarted = true;
var a = audio();
if (!a.muted && a.paused) a.play().catch(function () {});
}
function tick() { // aplica pista de música + voz pendiente
var mn = document.querySelectorAll('[data-music]');
var url = mn.length ? mn[mn.length - 1].dataset.music : null; // la más reciente
var a = audio();
if (url && a.dataset.url !== url) {
a.dataset.url = url; a.src = url;
if (window.__bgstarted && !a.muted) a.play().catch(function () {});
}
// Cambio de pantalla -> corta la voz que siguiera sonando (ya es de otra página)
// y restaura el volumen de la música. ANTES de lanzar la voz nueva.
var sn = document.querySelectorAll('[data-screen]');
var screen = sn.length ? sn[sn.length - 1].dataset.screen : null;
if (screen && window.__screen !== screen) {
window.__screen = screen;
var ve = voiceEl();
if (!ve.paused) { ve.pause(); ve.currentTime = 0; }
a.volume = __VOL__; // un-duck
}
var vn = document.querySelectorAll('[data-voice]');
if (vn.length) {
var node = vn[vn.length - 1];
var seq = node.dataset.voiceSeq, vurl = node.dataset.voice;
if (vurl && window.__voiceSeq !== seq) { // seq nuevo -> frase nueva, suena 1 vez
window.__voiceSeq = seq;
playVoice(vurl);
}
}
setTimeout(tick, 300);
}
tick();
// Desbloquear en el primer gesto (autoplay bloqueado hasta entonces). Idempotente.
document.addEventListener('click', start);
})();
</script>
""".replace("__VOL__", str(MUSIC_VOLUME))
# Global: vale para `python app.py` y para `gradio app.py` (que ignora el
# allowed_paths de launch porque no ejecuta el bloque __main__).
gr.set_static_paths([str(ASSETS_DIR)])
# Warmup: arranca los contenedores de Modal (voz + LLM) al lanzar la app, en hilos,
# para que la 1ª voz/legislación del juego salga caliente (~2s) y no espere el cold
# start (~60s). Fire-and-forget; si los endpoints no responden, no pasa nada.
_tts_warmup()
_llm_warmup()
with gr.Blocks(title="Lesky's Shop", css=CSS, head=HEAD) as demo:
# Gradio deep-copia el valor por defecto del gr.State por sesión.
app = gr.State(ViewModel())
@gr.render(inputs=[app])
def router(vm: ViewModel):
gr.HTML(music_tag(vm.screen), elem_classes="music-host") # música de fondo (oculto)
gr.HTML(voice_tag(vm.voice_url, vm.voice_seq), elem_classes="music-host") # voz enemigo
gr.HTML(f'<div class="music-host" data-screen="{vm.screen}"></div>') # corta voz al cambiar
if vm.voice_loading: # avisa: avanzar bloqueado mientras genera
gr.HTML('<div style="position:fixed;bottom:10px;left:10px;z-index:99999;'
'background:rgba(0,0,0,.6);color:#fff;padding:6px 12px;border-radius:8px;'
'font-size:14px">🔊 Generating voice…</div>')
SCREENS.get(vm.screen, title.build)(app, vm)
if __name__ == "__main__":
demo.launch(allowed_paths=[str(ASSETS_DIR)])