| """
|
| 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|
|
|
|
|
|
|
| gr.set_static_paths([str(ASSETS_DIR)])
|
|
|
|
|
|
|
|
|
| _tts_warmup()
|
| _llm_warmup()
|
|
|
| with gr.Blocks(title="Lesky's Shop", css=CSS, head=HEAD) as demo:
|
|
|
| app = gr.State(ViewModel())
|
|
|
| @gr.render(inputs=[app])
|
| def router(vm: ViewModel):
|
| gr.HTML(music_tag(vm.screen), elem_classes="music-host")
|
| gr.HTML(voice_tag(vm.voice_url, vm.voice_seq), elem_classes="music-host")
|
| gr.HTML(f'<div class="music-host" data-screen="{vm.screen}"></div>')
|
| if vm.voice_loading:
|
| 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)])
|
|
|