767 lines
32 KiB
Go
767 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type deviceSimulatorDevice struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
SW int `json:"sw"`
|
|
SH int `json:"sh"`
|
|
SCR int `json:"scr"`
|
|
Notch string `json:"notch"`
|
|
NW int `json:"nw,omitempty"`
|
|
NH int `json:"nh,omitempty"`
|
|
NR int `json:"nr,omitempty"`
|
|
PS int `json:"ps,omitempty"`
|
|
SAT int `json:"sat"`
|
|
SAB int `json:"sab"`
|
|
}
|
|
|
|
var deviceSimulatorDevices = []deviceSimulatorDevice{
|
|
{ID: "pixel-8", Name: "Google Pixel 8", SW: 411, SH: 914, SCR: 22, Notch: "punch", PS: 14, SAT: 28, SAB: 24},
|
|
{ID: "iphone-14", Name: "iPhone 14", SW: 390, SH: 844, SCR: 38, Notch: "notch", NW: 130, NH: 34, SAT: 47, SAB: 34},
|
|
{ID: "iphone-14-plus", Name: "iPhone 14 Plus", SW: 428, SH: 926, SCR: 40, Notch: "notch", NW: 130, NH: 34, SAT: 47, SAB: 34},
|
|
{ID: "iphone-14-pro", Name: "iPhone 14 Pro", SW: 393, SH: 852, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
|
{ID: "iphone-14-pro-max", Name: "iPhone 14 Pro Max", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
|
{ID: "iphone-15", Name: "iPhone 15", SW: 390, SH: 844, SCR: 38, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
|
{ID: "iphone-15-plus", Name: "iPhone 15 Plus", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
|
{ID: "iphone-15-pro", Name: "iPhone 15 Pro", SW: 393, SH: 852, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
|
{ID: "iphone-15-pro-max", Name: "iPhone 15 Pro Max", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
|
{ID: "iphone-16", Name: "iPhone 16", SW: 390, SH: 844, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
|
{ID: "iphone-16-plus", Name: "iPhone 16 Plus", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
|
{ID: "iphone-16-pro", Name: "iPhone 16 Pro", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
|
{ID: "iphone-16-pro-max", Name: "iPhone 16 Pro Max", SW: 440, SH: 956, SCR: 44, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
|
{ID: "iphone-17", Name: "iPhone 17", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
|
{ID: "iphone-17-pro", Name: "iPhone 17 Pro", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
|
{ID: "iphone-17-pro-max", Name: "iPhone 17 Pro Max", SW: 440, SH: 956, SCR: 44, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
|
{ID: "iphone-air", Name: "iPhone Air", SW: 420, SH: 912, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
|
{ID: "iphone-se3", Name: "iPhone SE (3rd gen)", SW: 375, SH: 667, SCR: 4, Notch: "none", SAT: 20, SAB: 0},
|
|
{ID: "galaxy-s24", Name: "Samsung Galaxy S24", SW: 360, SH: 780, SCR: 20, Notch: "punch", PS: 12, SAT: 28, SAB: 24},
|
|
}
|
|
|
|
func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
|
devicesJSON, _ := json.Marshal(deviceSimulatorDevices)
|
|
body := `
|
|
<header class="tool-header">
|
|
<h2 class="tool-title">Device Simulator</h2>
|
|
<p class="tool-description">Preview HTML playables inside mobile device frames with notch and Dynamic Island overlays.</p>
|
|
</header>
|
|
|
|
<section class="tool-panel input-panel">
|
|
<div class="panel-header device-settings-header">
|
|
<h3 class="panel-title">Input</h3>
|
|
<button id="settingsBtn" class="icon-btn" title="Settings" aria-label="Settings" onclick="openSettingsModal('device-simulator')">⚙</button>
|
|
</div>
|
|
<div class="panel-body">
|
|
<div class="control-label">Playable HTML</div>
|
|
<div id="dropZone" class="drop-zone">
|
|
<div class="file-row">
|
|
<button id="pickBtn" class="secondary">Select File</button>
|
|
</div>
|
|
<div class="drop-zone-text">or drop an HTML file here</div>
|
|
</div>
|
|
<div class="file-name" id="fileName" style="margin-top:8px;">(no file selected)</div>
|
|
<div id="status" class="status-panel"></div>
|
|
</div>
|
|
</section>
|
|
|
|
|
|
<section class="sim-panel" id="simPanel">
|
|
<div class="panel-header sim-header">
|
|
<h3 class="panel-title">Playable</h3>
|
|
<div class="sim-meta">
|
|
<span id="fileLabel"></span>
|
|
<span id="dimLabel"></span>
|
|
</div>
|
|
<div class="title-actions">
|
|
<select id="deviceSelect" class="title-select" title="Device"></select>
|
|
<select id="languageSelect" class="title-select" title="Language" aria-label="Language"></select>
|
|
<button id="orientBtn" class="icon-btn title-orient" title="Switch to Landscape" aria-label="Switch to Landscape"><span class="orient-icon">📱</span></button>
|
|
<button id="muteBtn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">🔇</button>
|
|
<button id="reloadBtn" class="icon-btn" title="Reload" aria-label="Reload">↻</button>
|
|
</div>
|
|
</div>
|
|
<div id="previewArea"><div id="screenScaler"></div></div>
|
|
</section>
|
|
|
|
<style>
|
|
.sim-panel {
|
|
display: none;
|
|
height: min(720px, calc(100vh - 280px));
|
|
min-height: 520px;
|
|
border: 1px solid var(--tool-border);
|
|
border-radius: var(--tool-radius);
|
|
background: rgba(128,128,128,0.08);
|
|
overflow: hidden;
|
|
flex-direction: column;
|
|
}
|
|
.sim-panel.active { display: flex; }
|
|
.sim-header { display:flex; align-items:center; gap:8px; }
|
|
.sim-meta {
|
|
min-width: 0;
|
|
margin-left: auto;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: #a7a7a7;
|
|
font-size: 11px;
|
|
}
|
|
#fileLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
#dimLabel { white-space: nowrap; flex-shrink: 0; }
|
|
.title-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
|
.title-select { width:180px; min-width:120px; }
|
|
.title-orient { min-height:28px; }
|
|
.orient-icon { display:block; transition:transform 120ms ease; }
|
|
.title-orient.landscape .orient-icon { transform:rotate(90deg); }
|
|
.icon-btn {
|
|
width:28px;
|
|
height:28px;
|
|
min-height:28px;
|
|
padding:0;
|
|
display:grid;
|
|
place-items:center;
|
|
font-size:15px;
|
|
line-height:1;
|
|
background:#3a3d41;
|
|
color:#ddd;
|
|
border:1px solid #444;
|
|
}
|
|
.icon-btn:hover { background:#45494e; }
|
|
.icon-btn[aria-pressed="true"] { color:#89d185; }
|
|
.device-settings-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
#previewArea {
|
|
flex: 1 1 0;
|
|
min-height: 0;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 20px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
#screenScaler { transform-origin: top center; flex: 0 0 auto; }
|
|
#deviceScreen {
|
|
--cutout-bg:#000;
|
|
position:relative;
|
|
overflow:hidden;
|
|
background:#000;
|
|
box-shadow:0 0 0 1px rgba(255,255,255,.08),0 0 0 3px #1a1a1a,0 0 0 4px rgba(255,255,255,.05),0 8px 32px rgba(0,0,0,.5);
|
|
}
|
|
#deviceScreen iframe { display:block; border:0; position:absolute; inset:0; background:#000; }
|
|
.cutout { position:absolute; pointer-events:none; z-index:10; background:var(--cutout-bg); }
|
|
@media (max-width: 760px) {
|
|
.sim-panel { height:auto; min-height:420px; }
|
|
.sim-header { flex-wrap:wrap; }
|
|
.sim-meta { order:3; width:100%; margin-left:0; }
|
|
.title-actions { margin-left:auto; }
|
|
.title-select { width:min(180px, 42vw); }
|
|
#previewArea { min-height:420px; }
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
let DEFAULT_DEVICES = ` + string(devicesJSON) + `;
|
|
let LANGUAGES = [
|
|
{ name: 'English', code: 'en' },
|
|
{ name: 'Chinese (Simplified)', code: 'zh-hans' },
|
|
{ name: 'Chinese (Traditional)', code: 'zh-hant' },
|
|
{ name: 'French', code: 'fr' },
|
|
{ name: 'German', code: 'de' },
|
|
{ name: 'Japanese', code: 'ja' },
|
|
{ name: 'Korean', code: 'ko' },
|
|
{ name: 'Portuguese', code: 'pt' },
|
|
{ name: 'Russian', code: 'ru' },
|
|
{ name: 'Spanish', code: 'es' }
|
|
];
|
|
let devices = DEFAULT_DEVICES.slice();
|
|
let device = devices[0] || DEFAULT_DEVICES[0];
|
|
let language = 'en';
|
|
let landscape = false;
|
|
let currentPath = '';
|
|
let currentContent = '';
|
|
let currentBlobUrl = '';
|
|
let loadVersion = 0;
|
|
let muted = false;
|
|
const mediaVolumes = new WeakMap();
|
|
let muteObserver = null;
|
|
let muteDocument = null;
|
|
|
|
const statusEl = document.getElementById('status');
|
|
const fileNameEl = document.getElementById('fileName');
|
|
const fileLabel = document.getElementById('fileLabel');
|
|
const dimLabel = document.getElementById('dimLabel');
|
|
const simPanel = document.getElementById('simPanel');
|
|
const sel = document.getElementById('deviceSelect');
|
|
const languageSelect = document.getElementById('languageSelect');
|
|
|
|
function basename(p) {
|
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
|
return i >= 0 ? p.slice(i + 1) : p;
|
|
}
|
|
function setBusy(text) {
|
|
statusEl.textContent = text || '';
|
|
statusEl.classList.toggle('is-busy', !!text);
|
|
}
|
|
populateDeviceSelect(device.id);
|
|
populateLanguageSelect();
|
|
sel.addEventListener('change', () => {
|
|
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
|
renderScreen();
|
|
if (currentContent) loadContent(currentContent);
|
|
});
|
|
languageSelect.addEventListener('change', () => {
|
|
language = languageSelect.value || LANGUAGES[0].code;
|
|
if (currentContent) loadContent(currentContent);
|
|
});
|
|
|
|
function populateDeviceSelect(selectedId) {
|
|
sel.innerHTML = '';
|
|
devices.forEach((d, i) => {
|
|
const o = document.createElement('option');
|
|
o.value = String(i);
|
|
o.textContent = d.name;
|
|
sel.appendChild(o);
|
|
});
|
|
const foundIndex = devices.findIndex(d => d.id === selectedId);
|
|
const index = foundIndex >= 0 ? foundIndex : 0;
|
|
sel.value = String(index);
|
|
device = devices[index] || DEFAULT_DEVICES[0];
|
|
}
|
|
|
|
function populateLanguageSelect() {
|
|
languageSelect.innerHTML = '';
|
|
LANGUAGES.forEach((lang) => {
|
|
const o = document.createElement('option');
|
|
o.value = lang.code;
|
|
o.textContent = lang.name;
|
|
languageSelect.appendChild(o);
|
|
});
|
|
languageSelect.value = language;
|
|
}
|
|
|
|
|
|
function normalizeDeviceList(value) {
|
|
const list = Array.isArray(value) ? value : (value && Array.isArray(value.devices) ? value.devices : []);
|
|
return list.map(normalizeDevice).filter(Boolean);
|
|
}
|
|
|
|
function normalizeDevice(raw, index) {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const name = String(raw.name || '').trim();
|
|
const notch = ['none', 'notch', 'island', 'punch'].includes(raw.notch) ? raw.notch : 'none';
|
|
const d = {
|
|
id: String(raw.id || slugify(name) || ('device-' + Date.now() + '-' + index)).trim(),
|
|
name: name || 'Custom Device',
|
|
sw: intInRange(raw.sw, 1, 5000),
|
|
sh: intInRange(raw.sh, 1, 5000),
|
|
scr: intInRange(raw.scr, 0, 500),
|
|
notch: notch,
|
|
sat: intInRange(raw.sat, 0, 500),
|
|
sab: intInRange(raw.sab, 0, 500)
|
|
};
|
|
if (!d.sw || !d.sh) return null;
|
|
if (raw.nw !== undefined) d.nw = intInRange(raw.nw, 0, 1000);
|
|
if (raw.nh !== undefined) d.nh = intInRange(raw.nh, 0, 1000);
|
|
if (raw.nr !== undefined) d.nr = intInRange(raw.nr, 0, 500);
|
|
if (raw.ps !== undefined) d.ps = intInRange(raw.ps, 0, 500);
|
|
return d;
|
|
}
|
|
|
|
function intInRange(value, min, max) {
|
|
const n = Math.round(Number(value));
|
|
if (!Number.isFinite(n)) return min;
|
|
return Math.max(min, Math.min(max, n));
|
|
}
|
|
|
|
function slugify(value) {
|
|
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
}
|
|
|
|
function sortDevicesByName(arr) {
|
|
return arr.slice().sort(function(a, b) {
|
|
const na = a.name.toLowerCase(), nb = b.name.toLowerCase();
|
|
return na < nb ? -1 : na > nb ? 1 : 0;
|
|
});
|
|
}
|
|
|
|
document.getElementById('orientBtn').addEventListener('click', (event) => {
|
|
landscape = !landscape;
|
|
renderOrientationButton();
|
|
renderScreen();
|
|
if (currentContent) loadContent(currentContent);
|
|
});
|
|
function renderOrientationButton() {
|
|
const btn = document.getElementById('orientBtn');
|
|
btn.classList.toggle('landscape', landscape);
|
|
btn.title = landscape ? 'Switch to Portrait' : 'Switch to Landscape';
|
|
btn.setAttribute('aria-label', landscape ? 'Switch to Portrait' : 'Switch to Landscape');
|
|
}
|
|
document.getElementById('pickBtn').addEventListener('click', async () => {
|
|
setBusy('Opening file picker...');
|
|
try {
|
|
const r = await fetch('/api/device-simulator/pick', { method:'POST' });
|
|
const j = await r.json();
|
|
if (j.path) await loadPath(j.path, true);
|
|
else setBusy('');
|
|
} catch (e) {
|
|
statusEl.classList.remove('is-busy');
|
|
statusEl.textContent = 'Error: ' + (e.message || e);
|
|
}
|
|
});
|
|
document.getElementById('reloadBtn').addEventListener('click', () => {
|
|
if (currentPath) loadPath(currentPath, false);
|
|
});
|
|
document.getElementById('muteBtn').addEventListener('click', () => {
|
|
muted = !muted;
|
|
renderMuteButton();
|
|
applyMute();
|
|
});
|
|
setupDropZone('dropZone', statusEl, (j) => {
|
|
if (j.paths && j.paths.length) loadPath(j.paths[0], true);
|
|
else if (j.skipped) statusEl.textContent = 'Drop an HTML file.';
|
|
});
|
|
|
|
async function loadPath(path, resetMute) {
|
|
currentPath = path;
|
|
setBusy('Reading ' + basename(path) + '...');
|
|
const r = await fetch('/api/device-simulator/load', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({ path })
|
|
});
|
|
const j = await r.json();
|
|
statusEl.classList.remove('is-busy');
|
|
if (j.error) {
|
|
statusEl.textContent = 'Error: ' + j.error;
|
|
return;
|
|
}
|
|
currentContent = j.content || '';
|
|
fileNameEl.textContent = j.name;
|
|
fileLabel.textContent = j.name;
|
|
statusEl.textContent = '';
|
|
if (resetMute) {
|
|
muted = false;
|
|
renderMuteButton();
|
|
}
|
|
simPanel.classList.add('active');
|
|
renderScreen();
|
|
loadContent(currentContent);
|
|
}
|
|
function sw() { return landscape ? device.sh : device.sw; }
|
|
function sh() { return landscape ? device.sw : device.sh; }
|
|
function buildCutout() {
|
|
const d = device;
|
|
if (d.notch === 'notch') {
|
|
const r = Math.round((d.nh || 0) * 0.5);
|
|
if (!landscape) return '<div class="cutout" style="top:0;left:50%;transform:translateX(-50%);width:' + d.nw + 'px;height:' + d.nh + 'px;border-radius:0 0 ' + r + 'px ' + r + 'px;"></div>';
|
|
return '<div class="cutout" style="left:0;top:50%;transform:translateY(-50%);width:' + d.nh + 'px;height:' + d.nw + 'px;border-radius:0 ' + r + 'px ' + r + 'px 0;"></div>';
|
|
}
|
|
if (d.notch === 'island') {
|
|
if (!landscape) return '<div class="cutout" style="top:12px;left:50%;transform:translateX(-50%);width:' + d.nw + 'px;height:' + d.nh + 'px;border-radius:' + d.nr + 'px;"></div>';
|
|
return '<div class="cutout" style="left:12px;top:50%;transform:translateY(-50%);width:' + d.nh + 'px;height:' + d.nw + 'px;border-radius:' + d.nr + 'px;"></div>';
|
|
}
|
|
if (d.notch === 'punch') {
|
|
const ps = d.ps || 12;
|
|
if (!landscape) return '<div class="cutout" style="top:10px;left:50%;transform:translateX(-50%);width:' + ps + 'px;height:' + ps + 'px;border-radius:50%;"></div>';
|
|
return '<div class="cutout" style="left:10px;top:50%;transform:translateY(-50%);width:' + ps + 'px;height:' + ps + 'px;border-radius:50%;"></div>';
|
|
}
|
|
return '';
|
|
}
|
|
function buildHomeBar() {
|
|
if (!device.sab) return '';
|
|
const base = 'position:absolute;background:rgba(255,255,255,0.3);border-radius:3px;pointer-events:none;z-index:10;';
|
|
if (!landscape) return '<div style="' + base + 'bottom:8px;left:50%;transform:translateX(-50%);width:130px;height:5px;"></div>';
|
|
return '<div style="' + base + 'right:8px;top:50%;transform:translateY(-50%);width:5px;height:100px;"></div>';
|
|
}
|
|
function renderScreen() {
|
|
const W = sw(), H = sh();
|
|
const scaler = document.getElementById('screenScaler');
|
|
scaler.innerHTML = '<div id="deviceScreen" style="width:' + W + 'px;height:' + H + 'px;border-radius:' + device.scr + 'px;">' +
|
|
buildCutout() +
|
|
'<iframe id="previewFrame" width="' + W + '" height="' + H + '" scrolling="no" frameborder="0" allow="autoplay;pointer-lock"></iframe>' +
|
|
buildHomeBar() +
|
|
'</div>';
|
|
dimLabel.textContent = W + ' x ' + H;
|
|
document.getElementById('previewFrame').addEventListener('load', applyMute);
|
|
updateScale();
|
|
}
|
|
function loadContent(html) {
|
|
const frame = document.getElementById('previewFrame');
|
|
if (!frame) return;
|
|
if (currentBlobUrl) {
|
|
URL.revokeObjectURL(currentBlobUrl);
|
|
currentBlobUrl = '';
|
|
}
|
|
if (currentPath) {
|
|
frame.src = previewUrl();
|
|
return;
|
|
}
|
|
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
|
|
currentBlobUrl = URL.createObjectURL(blob);
|
|
frame.src = currentBlobUrl;
|
|
}
|
|
function previewUrl() {
|
|
loadVersion += 1;
|
|
return '/api/device-simulator/preview/index.html?path=' + encodeURIComponent(currentPath) +
|
|
'&lang=' + encodeURIComponent(language) +
|
|
'&v=' + loadVersion;
|
|
}
|
|
function injectMuteBridge(html) {
|
|
const languageBridge =
|
|
'<scr' + 'ipt>' +
|
|
'(function(){try{' +
|
|
'var url=new URL(window.location.href);' +
|
|
'url.searchParams.set("lang","' + language + '");' +
|
|
'history.replaceState(null,"",url.href);' +
|
|
'}catch(e){}})();' +
|
|
'</scr' + 'ipt>';
|
|
const bridge = languageBridge +
|
|
'<scr' + 'ipt>' +
|
|
'(function(){' +
|
|
'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' +
|
|
'var muted=false,media=[],contexts=[],gains=[],destinations=[];' +
|
|
'function rememberMedia(el){if(!el||media.indexOf(el)>=0)return;media.push(el);applyMedia(el);}' +
|
|
'function applyMedia(el){try{el.muted=muted;if(muted){if(el.__hplVolume===undefined)el.__hplVolume=el.volume;el.volume=0;}else{if(el.__hplVolume!==undefined)el.volume=el.__hplVolume;}}catch(e){}}' +
|
|
'function apply(){for(var i=0;i<media.length;i++)applyMedia(media[i]);for(var j=0;j<gains.length;j++){try{gains[j].gain.value=muted?0:1;}catch(e){}}}' +
|
|
'window.__hplSetMuted=function(v){muted=!!v;window.__hplMuted=muted;apply();};' +
|
|
'window.addEventListener("message",function(e){if(e.data&&e.data.type==="hplDeviceSimulatorMute")window.__hplSetMuted(e.data.muted);});' +
|
|
'function patchAudioContext(name){var Orig=window[name];if(!Orig||Orig.__hplPatched)return;function Wrapped(){var ctx=arguments.length?new Orig(arguments[0]):new Orig();try{var gain=ctx.createGain();gain.gain.value=muted?0:1;gain.connect(ctx.destination);contexts.push(ctx);gains.push(gain);destinations.push(ctx.destination);}catch(e){}return ctx;}Wrapped.prototype=Orig.prototype;try{Object.setPrototypeOf(Wrapped,Orig);}catch(e){}Wrapped.__hplPatched=true;window[name]=Wrapped;}' +
|
|
'try{var origConnect=window.AudioNode&&window.AudioNode.prototype&&window.AudioNode.prototype.connect;if(origConnect&&!origConnect.__hplPatched){var patched=function(target){var args=Array.prototype.slice.call(arguments);var idx=destinations.indexOf(args[0]);if(idx>=0&&gains[idx])args[0]=gains[idx];return origConnect.apply(this,args);};patched.__hplPatched=true;window.AudioNode.prototype.connect=patched;}}catch(e){}' +
|
|
'try{patchAudioContext("AudioContext");patchAudioContext("webkitAudioContext");}catch(e){}' +
|
|
'try{var play=window.HTMLMediaElement&&window.HTMLMediaElement.prototype&&window.HTMLMediaElement.prototype.play;if(play&&!play.__hplPatched){var p=function(){rememberMedia(this);return play.apply(this,arguments);};p.__hplPatched=true;window.HTMLMediaElement.prototype.play=p;}}catch(e){}' +
|
|
'function scan(root){try{(root||document).querySelectorAll("audio,video").forEach(rememberMedia);}catch(e){}}' +
|
|
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){scan(document);});else scan(document);' +
|
|
'try{new MutationObserver(function(ms){ms.forEach(function(m){Array.prototype.forEach.call(m.addedNodes,function(n){if(n.nodeType!==1)return;if(n.matches&&n.matches("audio,video"))rememberMedia(n);scan(n);});});}).observe(document.documentElement,{childList:true,subtree:true});}catch(e){}' +
|
|
'})();' +
|
|
'</scr' + 'ipt>';
|
|
if (/<head\b[^>]*>/i.test(html)) {
|
|
return html.replace(/<head\b[^>]*>/i, function(match) { return match + bridge; });
|
|
}
|
|
return bridge + html;
|
|
}
|
|
function renderMuteButton() {
|
|
const btn = document.getElementById('muteBtn');
|
|
btn.innerHTML = muted ? '🔈' : '🔇';
|
|
btn.title = muted ? 'Unmute' : 'Mute';
|
|
btn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
|
|
btn.setAttribute('aria-pressed', muted ? 'true' : 'false');
|
|
}
|
|
function applyMute() {
|
|
const frame = document.getElementById('previewFrame');
|
|
if (!frame || !frame.contentWindow) return;
|
|
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
|
try {
|
|
frame.contentWindow.__hplMuted = muted;
|
|
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
|
const doc = frame.contentWindow.document;
|
|
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
|
if (muteDocument !== doc) {
|
|
if (muteObserver) muteObserver.disconnect();
|
|
muteDocument = doc;
|
|
muteObserver = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
mutation.addedNodes.forEach((node) => {
|
|
if (node.nodeType !== 1) return;
|
|
if (node.matches && node.matches('audio,video')) syncMediaMute(node);
|
|
if (node.querySelectorAll) node.querySelectorAll('audio,video').forEach(syncMediaMute);
|
|
});
|
|
});
|
|
});
|
|
muteObserver.observe(doc.documentElement, { childList:true, subtree:true });
|
|
}
|
|
} catch {}
|
|
}
|
|
function syncMediaMute(media) {
|
|
if (muted) {
|
|
if (!mediaVolumes.has(media)) mediaVolumes.set(media, media.volume);
|
|
media.muted = true;
|
|
media.volume = 0;
|
|
return;
|
|
}
|
|
media.muted = false;
|
|
if (mediaVolumes.has(media)) media.volume = mediaVolumes.get(media);
|
|
}
|
|
function updateScale() {
|
|
const area = document.getElementById('previewArea');
|
|
const screen = document.getElementById('deviceScreen');
|
|
const scaler = document.getElementById('screenScaler');
|
|
if (!screen || !area || !scaler) return;
|
|
scaler.style.transform = 'none';
|
|
const nw = screen.offsetWidth, nh = screen.offsetHeight;
|
|
const aw = area.clientWidth - 40, ah = area.clientHeight - 40;
|
|
const scale = Math.min(aw / nw, ah / nh, 1);
|
|
scaler.style.transform = 'scale(' + scale + ')';
|
|
scaler.style.marginBottom = Math.round(nh * (scale - 1)) + 'px';
|
|
}
|
|
window.addEventListener('resize', updateScale);
|
|
new ResizeObserver(updateScale).observe(document.getElementById('previewArea'));
|
|
|
|
(function fetchRemoteData() {
|
|
fetch('/api/device-simulator/remoteDevices').then(r => r.json()).then(data => {
|
|
if (data.error) return;
|
|
const newDefaults = normalizeDeviceList(data);
|
|
if (!newDefaults.length) return;
|
|
DEFAULT_DEVICES = sortDevicesByName(newDefaults);
|
|
devices = DEFAULT_DEVICES.slice();
|
|
const currentId = device ? device.id : null;
|
|
const stillExists = currentId && devices.some(d => d.id === currentId);
|
|
populateDeviceSelect(stillExists ? currentId : devices[0].id);
|
|
renderScreen();
|
|
if (currentContent) loadContent(currentContent);
|
|
}).catch(() => {});
|
|
|
|
fetch('/api/device-simulator/remoteLanguages').then(r => r.json()).then(data => {
|
|
if (data.error) return;
|
|
const list = Array.isArray(data) ? data : (data && Array.isArray(data.languages) ? data.languages : []);
|
|
const valid = list.filter(l => l && l.name && l.code);
|
|
if (!valid.length) return;
|
|
const prev = language;
|
|
LANGUAGES = valid;
|
|
populateLanguageSelect();
|
|
const stillValid = LANGUAGES.some(l => l.code === prev);
|
|
language = stillValid ? prev : LANGUAGES[0].code;
|
|
languageSelect.value = language;
|
|
}).catch(() => {});
|
|
})();
|
|
</script>`
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(Page("/device-simulator", "Device Simulator", body)))
|
|
}
|
|
|
|
func DeviceSimulatorPick(w http.ResponseWriter, r *http.Request) {
|
|
paths := PickFiles("Select HTML file", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, false, "")
|
|
path := ""
|
|
if len(paths) > 0 {
|
|
path = paths[0]
|
|
}
|
|
writeJSON(w, map[string]any{"path": path})
|
|
}
|
|
|
|
func DeviceSimulatorLoad(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
if req.Path == "" || !isHTMLPath(req.Path) {
|
|
writeJSON(w, map[string]any{"error": "Pick an HTML file."})
|
|
return
|
|
}
|
|
info, err := os.Stat(req.Path)
|
|
if err != nil {
|
|
writeJSON(w, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
if info.IsDir() {
|
|
writeJSON(w, map[string]any{"error": "Expected an HTML file, got a folder."})
|
|
return
|
|
}
|
|
data, err := os.ReadFile(req.Path)
|
|
if err != nil {
|
|
writeJSON(w, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"name": filepath.Base(req.Path),
|
|
"path": req.Path,
|
|
"content": string(data),
|
|
})
|
|
}
|
|
|
|
func DeviceSimulatorPreview(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Query().Get("path")
|
|
if path == "" || !isHTMLPath(path) {
|
|
http.Error(w, "Pick an HTML file.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if info.IsDir() {
|
|
http.Error(w, "Expected an HTML file, got a folder.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
baseDir := filepath.Dir(path)
|
|
assetName := filepath.ToSlash(r.URL.Path[len("/api/device-simulator/preview/"):])
|
|
targetPath := path
|
|
if assetName != "" && assetName != "index.html" {
|
|
cleanName := filepath.Clean(filepath.FromSlash(assetName))
|
|
targetPath = filepath.Join(baseDir, cleanName)
|
|
resolvedBase, err := filepath.Abs(baseDir)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
resolvedTarget, err := filepath.Abs(targetPath)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if resolvedTarget != resolvedBase && len(resolvedTarget) > len(resolvedBase) && resolvedTarget[:len(resolvedBase)+1] != resolvedBase+string(os.PathSeparator) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
data, err := os.ReadFile(targetPath)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if targetPath == path {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
data = []byte(injectDeviceSimulatorMuteBridge(string(data)))
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
var reGDriveFileIDSim = regexp.MustCompile(`/file/d/([a-zA-Z0-9_-]+)`)
|
|
var reGDriveIDParamSim = regexp.MustCompile(`[?&]id=([a-zA-Z0-9_-]+)`)
|
|
|
|
func resolveSimGDriveURL(rawURL string) string {
|
|
if m := reGDriveFileIDSim.FindStringSubmatch(rawURL); m != nil {
|
|
return "https://drive.google.com/uc?export=download&id=" + m[1]
|
|
}
|
|
if m := reGDriveIDParamSim.FindStringSubmatch(rawURL); m != nil {
|
|
return "https://drive.google.com/uc?export=download&id=" + m[1]
|
|
}
|
|
return rawURL
|
|
}
|
|
|
|
type simRemoteCache struct {
|
|
mu sync.Mutex
|
|
devices string
|
|
languages string
|
|
devURL string
|
|
langURL string
|
|
}
|
|
|
|
var simCache simRemoteCache
|
|
|
|
func fetchRemoteJSON(rawURL string) (string, error) {
|
|
url := resolveSimGDriveURL(rawURL)
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently {
|
|
loc := resp.Header.Get("Location")
|
|
if loc != "" {
|
|
return fetchRemoteJSON(loc)
|
|
}
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var v any
|
|
if err := json.Unmarshal(body, &v); err != nil {
|
|
return "", fmt.Errorf("invalid JSON: %w", err)
|
|
}
|
|
return string(body), nil
|
|
}
|
|
|
|
func DeviceSimulatorRemoteDevicesEndpoint(w http.ResponseWriter, r *http.Request) {
|
|
cfg := LoadConfig()
|
|
url := strings.TrimSpace(cfg.DeviceSimulator.DevicesURL)
|
|
if url == "" {
|
|
writeJSON(w, map[string]any{"error": "devicesUrl not configured"})
|
|
return
|
|
}
|
|
simCache.mu.Lock()
|
|
if simCache.devURL == url && simCache.devices != "" {
|
|
cached := simCache.devices
|
|
simCache.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(cached))
|
|
return
|
|
}
|
|
simCache.mu.Unlock()
|
|
data, err := fetchRemoteJSON(url)
|
|
if err != nil {
|
|
writeJSON(w, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
simCache.mu.Lock()
|
|
simCache.devURL = url
|
|
simCache.devices = data
|
|
simCache.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(data))
|
|
}
|
|
|
|
func DeviceSimulatorRemoteLanguagesEndpoint(w http.ResponseWriter, r *http.Request) {
|
|
cfg := LoadConfig()
|
|
url := strings.TrimSpace(cfg.DeviceSimulator.LanguagesURL)
|
|
if url == "" {
|
|
writeJSON(w, map[string]any{"error": "languagesUrl not configured"})
|
|
return
|
|
}
|
|
simCache.mu.Lock()
|
|
if simCache.langURL == url && simCache.languages != "" {
|
|
cached := simCache.languages
|
|
simCache.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(cached))
|
|
return
|
|
}
|
|
simCache.mu.Unlock()
|
|
data, err := fetchRemoteJSON(url)
|
|
if err != nil {
|
|
writeJSON(w, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
simCache.mu.Lock()
|
|
simCache.langURL = url
|
|
simCache.languages = data
|
|
simCache.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(data))
|
|
}
|
|
|
|
func injectDeviceSimulatorMuteBridge(html string) string {
|
|
bridge := `<script>(function(){if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;var muted=false,media=[],contexts=[],gains=[],destinations=[];function rememberMedia(el){if(!el||media.indexOf(el)>=0)return;media.push(el);applyMedia(el);}function applyMedia(el){try{el.muted=muted;if(muted){if(el.__hplVolume===undefined)el.__hplVolume=el.volume;el.volume=0;}else{if(el.__hplVolume!==undefined)el.volume=el.__hplVolume;}}catch(e){}}function apply(){for(var i=0;i<media.length;i++)applyMedia(media[i]);for(var j=0;j<gains.length;j++){try{gains[j].gain.value=muted?0:1;}catch(e){}}}window.__hplSetMuted=function(v){muted=!!v;window.__hplMuted=muted;apply();};window.addEventListener("message",function(e){if(e.data&&e.data.type==="hplDeviceSimulatorMute")window.__hplSetMuted(e.data.muted);});function patchAudioContext(name){var Orig=window[name];if(!Orig||Orig.__hplPatched)return;function Wrapped(){var ctx=arguments.length?new Orig(arguments[0]):new Orig();try{var gain=ctx.createGain();gain.gain.value=muted?0:1;gain.connect(ctx.destination);contexts.push(ctx);gains.push(gain);destinations.push(ctx.destination);}catch(e){}return ctx;}Wrapped.prototype=Orig.prototype;try{Object.setPrototypeOf(Wrapped,Orig);}catch(e){}Wrapped.__hplPatched=true;window[name]=Wrapped;}try{var origConnect=window.AudioNode&&window.AudioNode.prototype&&window.AudioNode.prototype.connect;if(origConnect&&!origConnect.__hplPatched){var patched=function(target){var args=Array.prototype.slice.call(arguments);var idx=destinations.indexOf(args[0]);if(idx>=0&&gains[idx])args[0]=gains[idx];return origConnect.apply(this,args);};patched.__hplPatched=true;window.AudioNode.prototype.connect=patched;}}catch(e){}try{patchAudioContext("AudioContext");patchAudioContext("webkitAudioContext");}catch(e){}try{var play=window.HTMLMediaElement&&window.HTMLMediaElement.prototype&&window.HTMLMediaElement.prototype.play;if(play&&!play.__hplPatched){var p=function(){rememberMedia(this);return play.apply(this,arguments);};p.__hplPatched=true;window.HTMLMediaElement.prototype.play=p;}}catch(e){}function scan(root){try{(root||document).querySelectorAll("audio,video").forEach(rememberMedia);}catch(e){}}if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){scan(document);});else scan(document);try{new MutationObserver(function(ms){ms.forEach(function(m){Array.prototype.forEach.call(m.addedNodes,function(n){if(n.nodeType!==1)return;if(n.matches&&n.matches("audio,video"))rememberMedia(n);scan(n);});});}).observe(document.documentElement,{childList:true,subtree:true});}catch(e){}})();</script>`
|
|
lower := strings.ToLower(html)
|
|
headIndex := strings.Index(lower, "<head")
|
|
if headIndex >= 0 {
|
|
closeIndex := strings.Index(lower[headIndex:], ">")
|
|
if closeIndex >= 0 {
|
|
insertAt := headIndex + closeIndex + 1
|
|
return html[:insertAt] + bridge + html[insertAt:]
|
|
}
|
|
}
|
|
return bridge + html
|
|
}
|