9:41
jue, 11 jun
9:41
💬
3
Whispr
✉️
2
Vesta
📞
1
Llamadas
🖼️
Galería
🔍
Lumino
📝
Notas
Cámara
🗺️
Mapeo
💬
✉️
📞
⚙️
Whispr
🏠
Chats
Llamadas
Estados
S
Sam
en línea
📞
😊 📎
Vesta Mail
🏠✏️
Entrada (4) Enviados Borradores Spam
Bandeja de entrada
🗑️ ↩️
S
Galería
🏠
Recientes
Álbumes
Personas
↗️Compartir
❤️Me gusta
✏️Editar
🗑️Eliminar
Llamadas
🏠
Recientes
Contactos
Buzón
Whispr — llamada entrante
S
Sam
Whispr · Llamada de voz
Rechazar
Mensaje
Aceptar
S
Sam
00:00
🔇
Silenciar
⌨️
Teclado
🔊
Altavoz
Agregar
⏸️
En espera
📹
FaceTime
Finalizar
// ═══════════════════════════════════════ // DIRECTUS CONFIG // ═══════════════════════════════════════ const DIRECTUS_URL = 'https://portal.estudiolyra.es:8443'; const DIRECTUS_TOKEN = 'lyra_admin_token_2026'; // ── pantallas de estado ── function showBlockedScreen(tipo) { document.querySelectorAll('.view').forEach(v => { v.style.display='none'; }); const icons = { cerrada:'🔒', expirada:'⏱️', error:'📵' }; const titles = { cerrada:'Escena cerrada', expirada:'QR expirado', error:'Escena no disponible' }; const descs = { cerrada: 'Esta escena ha sido cerrada por el equipo de producción.
Pide un nuevo QR al director de arte.', expirada: 'El código QR de esta escena ha expirado.
El equipo de producción debe generar uno nuevo.', error: 'No se pudo cargar la configuración.
Comprueba la conexión o solicita un nuevo QR.' }; const el = document.createElement('div'); el.style.cssText = 'position:fixed;inset:0;background:#0d0d0f;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;z-index:998;padding:32px;text-align:center'; el.innerHTML = `
${icons[tipo]||'📵'}
${titles[tipo]||'No disponible'}
${descs[tipo]||''}
FilmPhone Studio
`; document.body.appendChild(el); } // ── cargar escena desde Directus (QR con ?scene=UUID) ── async function loadSceneFromDirectus(sceneId) { const res = await fetch( `${DIRECTUS_URL}/items/fp_escenas/${sceneId}?fields=id,nombre,estado,config,expira_en,accesos`, { headers: { 'Content-Type': 'application/json' } } ); if (!res.ok) throw new Error('HTTP ' + res.status); const { data } = await res.json(); if (!data) throw new Error('empty'); if (data.estado === 'cerrada' || data.estado === 'archivada') { showBlockedScreen('cerrada'); return null; } if (data.expira_en && new Date(data.expira_en) < new Date()) { showBlockedScreen('expirada'); return null; } // registrar acceso (sin bloquear) fetch(`${DIRECTUS_URL}/items/fp_escenas/${sceneId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accesos: (data.accesos||0)+1, ultimo_acceso: new Date().toISOString() }) }).catch(()=>{}); return data.config; } // ── normalizar config JSON de Studio ── function normalizeConfig(raw) { const contactos = {}; Object.entries(raw.contactos || {}).forEach(([k,v]) => { contactos[k] = { nombre: v.nombre, inicial: v.inicial, color: v.color }; }); const chats = {}; Object.entries(raw.chats || {}).forEach(([key, chat]) => { const cKey = String(chat.contacto); chats[key] = { contacto: cKey, unread: chat.unread || 0, hora: chat.hora || '---', preview: chat.preview || (chat.msgs?.length ? chat.msgs[chat.msgs.length-1].text : ''), msgs: (chat.msgs||[]).map(m => ({ out:!!m.out, text:m.text||'', time:m.time||'' })) }; }); CONFIG.personaje = raw.personaje || CONFIG.personaje; CONFIG.os = raw.os || CONFIG.os; CONFIG.wallpaper = raw.wallpaper || CONFIG.wallpaper; CONFIG.theme = raw.theme || CONFIG.theme; CONFIG.contactos = contactos; CONFIG.chats = chats; CONFIG.emails = (raw.emails||[]).map((e,i) => ({...e, id:'e'+i})); CONFIG.fotos = (raw.fotos ||[]).map((f,i) => ({...f, id:'f'+i})); CONFIG.llamadas = (raw.llamadas||[]).map((l,i) => ({ ...l, contacto: String(l.contactoIdx??0), id:'l'+i })); applyWallpaper(CONFIG.wallpaper, CONFIG.theme); } // ── cargar config desde ?c=BASE64 (legacy / offline) ── function loadConfigFromBase64(encoded) { try { const raw = JSON.parse(decodeURIComponent(escape(atob(encoded)))); normalizeConfig(raw); return true; } catch(e) { console.warn('[FilmPhone] base64 parse error:', e); return false; } } const WALLPAPERS = { 'gradient-blue': 'linear-gradient(160deg,#1a1a2e 0%,#16213e 55%,#0f3460 100%)', 'gradient-purple': 'linear-gradient(160deg,#1a0030 0%,#2d0060 55%,#4a0080 100%)', 'gradient-warm': 'linear-gradient(160deg,#1a0a00 0%,#3d1a00 55%,#5c2d00 100%)', 'dark-solid': '#000000', 'light-solid': 'linear-gradient(160deg,#e8eaf0 0%,#d0d4e0 100%)', }; function applyWallpaper(wp, theme) { const home = document.getElementById('view-home'); if (!home) return; home.style.background = WALLPAPERS[wp] || WALLPAPERS['gradient-blue']; const sb = document.getElementById('sb'); if (theme === 'light') sb.classList.add('light'); else sb.classList.remove('light'); } // ── splash ── function showSplash(osName, cb) { const splash = document.createElement('div'); splash.id = 'splash'; splash.style.cssText = 'position:fixed;inset:0;background:#000;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:999;gap:12px'; splash.innerHTML = `
📱
${osName||'HELIX OS'}
Cargando escena...
`; document.body.appendChild(splash); setTimeout(()=>{ const b=document.getElementById('splash-bar'); if(b) b.style.width='100%'; }, 50); setTimeout(()=>{ splash.style.transition='opacity .3s'; splash.style.opacity='0'; setTimeout(()=>{ splash.remove(); cb(); }, 320); }, 1000); } // ── render ── function renderAll() { renderChats(); renderMails(); renderGaleria(); renderLlamadas(); updateClock(); setInterval(updateClock, 30000); if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(()=>{}); } // ═══════════════════════════════════════ // REPLAY ENGINE // ═══════════════════════════════════════ // ── Poll Directus every 3s for replay_estado changes ── function startReplayPolling(sceneId) { replaySceneId = sceneId; if (replayPollInt) clearInterval(replayPollInt); replayPollInt = setInterval(() => pollReplayStatus(sceneId), 3000); pollReplayStatus(sceneId); // immediate first check } async function pollReplayStatus(sceneId) { try { const res = await fetch( `${DIRECTUS_URL}/items/fp_escenas/${sceneId}?fields=replay_estado`, { headers: { 'Content-Type': 'application/json' } } ); if (!res.ok) return; const { data } = await res.json(); if (!data) return; handleReplayStateChange(data.replay_estado || 'idle'); } catch(e) { /* silent — no network shouldn't crash the app */ } } function handleReplayStateChange(remoteState) { if (remoteState === 'requested' && replayState === 'idle') { // Coordinator requested recording — show banner to auxiliar replayState = 'requested'; showReplayRequest(); } else if (remoteState === 'stop' && replayState === 'recording') { // Coordinator stopped recording stopRecording(); } else if (remoteState === 'idle' && replayState === 'requested') { // Coordinator cancelled request before auxiliar confirmed replayState = 'idle'; hideBanner(); } } // ── Show the request banner ── function showReplayRequest() { const banner = document.getElementById('replayBanner'); const req = document.getElementById('replayRequest'); const bar = document.getElementById('replayRecBar'); req.style.display = 'flex'; bar.style.display = 'none'; banner.classList.add('visible'); } function hideBanner() { const banner = document.getElementById('replayBanner'); banner.classList.remove('visible'); setTimeout(() => { document.getElementById('replayRequest').style.display = 'none'; document.getElementById('replayRecBar').style.display = 'none'; }, 400); } // ── Auxiliar confirms recording ── async function userStartRecording() { if (!navigator.mediaDevices?.getUserMedia) { alert('Tu navegador no soporta grabación de vídeo. Usa Chrome o Safari actualizado.'); return; } try { // Request camera + screen (we record camera pointing at screen for iOS compatibility) // On Android Chrome, try getDisplayMedia first, fall back to getUserMedia let stream; if (navigator.mediaDevices.getDisplayMedia) { try { stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: false }); } catch(e) { // iOS or denied — fall back to front camera stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 1080 }, height: { ideal: 1920 } }, audio: false }); } } else { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false }); } // Determine best codec const mimeType = ['video/webm;codecs=vp9','video/webm;codecs=vp8','video/webm','video/mp4'] .find(t => MediaRecorder.isTypeSupported(t)) || ''; replayChunks = []; replayMediaRec = new MediaRecorder(stream, mimeType ? { mimeType } : {}); replayMediaRec.ondataavailable = e => { if (e.data?.size > 0) replayChunks.push(e.data); }; replayMediaRec.onstop = () => { stream.getTracks().forEach(t => t.stop()); replayBlob = new Blob(replayChunks, { type: mimeType || 'video/webm' }); showUploadModal(); }; replayMediaRec.start(1000); // collect chunks every 1s replayState = 'recording'; replaySeconds = 0; // Update banner to show recording bar document.getElementById('replayRequest').style.display = 'none'; const bar = document.getElementById('replayRecBar'); bar.style.display = 'flex'; // Update Directus state to 'recording' updateReplayStateOnServer('recording'); // Start timer replayTimerInt = setInterval(() => { replaySeconds++; const m = String(Math.floor(replaySeconds / 60)).padStart(2,'0'); const s = String(replaySeconds % 60).padStart(2,'0'); const el = document.getElementById('recTimer'); if (el) el.textContent = m + ':' + s; }, 1000); } catch(err) { console.error('[Replay] getUserMedia error:', err); alert('No se pudo iniciar la grabación: ' + err.message); replayState = 'idle'; hideBanner(); } } function stopRecording() { if (replayMediaRec && replayMediaRec.state !== 'inactive') { replayMediaRec.stop(); } clearInterval(replayTimerInt); replayState = 'stopped'; hideBanner(); updateReplayStateOnServer('idle'); } async function updateReplayStateOnServer(state) { if (!replaySceneId) return; try { await fetch(`${DIRECTUS_URL}/items/fp_escenas/${replaySceneId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ replay_estado: state }) }); } catch(e) {} } // ── Upload modal ── function showUploadModal() { const m = String(Math.floor(replaySeconds / 60)).padStart(2,'0'); const s = String(replaySeconds % 60).padStart(2,'0'); document.getElementById('recDuration').textContent = m + ':' + s; document.getElementById('replayUpload').style.display = 'flex'; document.getElementById('uploadBar').style.width = '0%'; document.getElementById('replayUploadMsg').innerHTML = `Duración: ${m}:${s}
Tamaño: ${(replayBlob.size / 1024 / 1024).toFixed(1)} MB

¿Subir al servidor para postproducción?`; } async function uploadRecording() { if (!replayBlob || !replaySceneId) return; const btn = document.getElementById('replayUploadBtn'); btn.disabled = true; btn.textContent = 'Subiendo...'; const ext = replayBlob.type.includes('mp4') ? 'mp4' : 'webm'; const filename = `replay_${replaySceneId}_${Date.now()}.${ext}`; const bar = document.getElementById('uploadBar'); try { // Step 1: Upload file to Directus files const formData = new FormData(); formData.append('folder', 'replay'); formData.append('file', replayBlob, filename); // Simulate progress (XHR for real progress tracking) await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', `${DIRECTUS_URL}/files`); xhr.setRequestHeader('Authorization', 'Bearer ' + DIRECTUS_TOKEN); xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (e.loaded / e.total * 80) + '%'; }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { bar.style.width = '90%'; const data = JSON.parse(xhr.responseText); // Step 2: Link file ID to scene fetch(`${DIRECTUS_URL}/items/fp_escenas/${replaySceneId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + DIRECTUS_TOKEN }, body: JSON.stringify({ replay_file_id: data.data?.id, replay_duration: replaySeconds, replay_recorded_at: new Date().toISOString(), replay_estado: 'uploaded' }) }).then(() => { bar.style.width = '100%'; resolve(); }); } else { reject(new Error('Upload failed: ' + xhr.status)); } }; xhr.onerror = () => reject(new Error('Network error')); xhr.send(formData); }); btn.textContent = '✓ Subido correctamente'; setTimeout(() => dismissUpload(), 2000); } catch(err) { console.error('[Replay] Upload error:', err); // Fallback: offer local download btn.disabled = false; btn.textContent = 'Error — Descargar localmente'; btn.onclick = downloadLocally; } } function downloadLocally() { if (!replayBlob) return; const ext = replayBlob.type.includes('mp4') ? 'mp4' : 'webm'; const url = URL.createObjectURL(replayBlob); const a = document.createElement('a'); a.href = url; a.download = `replay_${Date.now()}.${ext}`; a.click(); URL.revokeObjectURL(url); dismissUpload(); } function dismissUpload() { document.getElementById('replayUpload').style.display = 'none'; replayBlob = null; replayChunks = []; replayState = 'idle'; } // ═══════════════════════════════════════ // INIT — soporta tres modos: // 1. ?scene=UUID → Directus (revocable) // 2. ?c=BASE64 → config embebida (legacy) // 3. Sin params → demo hardcodeada // ═══════════════════════════════════════ (async function init() { const params = new URLSearchParams(window.location.search); const sceneId = params.get('scene'); const encoded = params.get('c'); if (sceneId) { // ── MODO DIRECTUS ── showSplash('HELIX OS', ()=>{}); // splash inmediato try { const config = await loadSceneFromDirectus(sceneId); if (!config) return; // blocked screen already shown normalizeConfig(config); const splash = document.getElementById('splash'); if (splash) { splash.style.opacity='0'; setTimeout(()=>splash.remove(),320); } renderAll(); // ── start Replay polling ── startReplayPolling(sceneId); } catch(err) { console.error('[FilmPhone] Directus error:', err); const splash = document.getElementById('splash'); if (splash) splash.remove(); showBlockedScreen('error'); } } else if (encoded) { // ── MODO BASE64 (legacy) ── showSplash('HELIX OS', () => { loadConfigFromBase64(encoded); renderAll(); }); } else { // ── MODO DEMO ── renderAll(); } })(); document.addEventListener('touchmove', e => { if (!e.target.closest('.chat-list,.msgs,.mail-list,.call-list,.photo-grid,.mail-detail-body')) { e.preventDefault(); } }, { passive: false });