Files
vsix-hpl-toolbox/standalone/device_simulator.go
2026-06-08 19:43:17 +08:00

885 lines
35 KiB
Go

package main
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
)
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: "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-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-air", Name: "iPhone Air", SW: 420, SH: 912, SCR: 42, 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-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-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-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", Name: "iPhone 16", SW: 390, SH: 844, 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-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-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", Name: "iPhone 15", SW: 390, SH: 844, SCR: 38, 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-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-plus", Name: "iPhone 14 Plus", SW: 428, SH: 926, SCR: 40, Notch: "notch", NW: 130, NH: 34, SAT: 47, SAB: 34},
{ID: "iphone-14", Name: "iPhone 14", SW: 390, SH: 844, SCR: 38, Notch: "notch", NW: 130, NH: 34, SAT: 47, SAB: 34},
{ID: "iphone-se3", Name: "iPhone SE (3rd gen)", SW: 375, SH: 667, SCR: 4, Notch: "none", SAT: 20, SAB: 0},
{ID: "pixel-8", Name: "Google Pixel 8", SW: 411, SH: 914, SCR: 22, Notch: "punch", PS: 14, SAT: 28, SAB: 24},
{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 settings-close" title="Device Settings" aria-label="Device Settings">&#9881;</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>
<div id="deviceSettingsModal" class="device-settings-modal" role="dialog" aria-modal="true" aria-labelledby="deviceSettingsTitle">
<div class="device-settings-dialog">
<div class="panel-header device-settings-header">
<h3 id="deviceSettingsTitle" class="panel-title">Device Settings</h3>
<button id="closeSettingsBtn" class="icon-btn settings-close" title="Close" aria-label="Close">&times;</button>
</div>
<div class="panel-body">
<div class="device-list-manager">
<div id="deviceList" class="device-list" role="listbox" aria-label="Devices"></div>
<div class="device-list-controls">
<button id="addDeviceBtn" class="icon-btn" title="Add Device" aria-label="Add Device">+</button>
<button id="removeDeviceBtn" class="icon-btn" title="Delete Selected Devices" aria-label="Delete Selected Devices">-</button>
<button id="moveDeviceUpBtn" class="icon-btn" title="Move Selected Up" aria-label="Move Selected Up">&#8593;</button>
<button id="moveDeviceDownBtn" class="icon-btn" title="Move Selected Down" aria-label="Move Selected Down">&#8595;</button>
</div>
</div>
<div id="deviceSettingsStatus" class="device-settings-status"></div>
<div id="deviceForm" class="device-form">
<div class="device-form-grid">
<label class="full">Name<input id="deviceNameInput" type="text" /></label>
<label>Width<input id="deviceSwInput" type="text" inputmode="numeric" /></label>
<label>Height<input id="deviceShInput" type="text" inputmode="numeric" /></label>
<label>Corner Radius<input id="deviceScrInput" type="text" inputmode="numeric" /></label>
<label>Notch
<select id="deviceNotchInput">
<option value="none">none</option>
<option value="notch">notch</option>
<option value="island">island</option>
<option value="punch">punch</option>
</select>
</label>
<label>Notch Width<input id="deviceNwInput" type="text" inputmode="numeric" /></label>
<label>Notch Height<input id="deviceNhInput" type="text" inputmode="numeric" /></label>
<label>Notch Radius<input id="deviceNrInput" type="text" inputmode="numeric" /></label>
<label>Punch Size<input id="devicePsInput" type="text" inputmode="numeric" /></label>
<label>Safe Top<input id="deviceSatInput" type="text" inputmode="numeric" /></label>
<label>Safe Bottom<input id="deviceSabInput" type="text" inputmode="numeric" /></label>
</div>
<div class="action-row">
<button id="saveDeviceBtn">Save Device</button>
<button id="cancelDeviceBtn" class="secondary">Cancel</button>
</div>
</div>
<div class="device-settings-footer">
<button id="restoreDevicesBtn" class="secondary">Restore Defaults</button>
<button id="importDevicesBtn" class="secondary">Import Devices</button>
<button id="exportDevicesBtn" class="secondary">Export Devices</button>
</div>
<input id="importDevicesInput" type="file" accept="application/json,.json" style="display:none" />
</div>
</div>
</div>
<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>
<button id="orientBtn" class="secondary title-orient">Landscape</button>
<button id="muteBtn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">&#128263;</button>
<button id="reloadBtn" class="icon-btn" title="Reload" aria-label="Reload">&#8635;</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; padding:2px 10px; white-space:nowrap; }
.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;
}
.device-settings-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: none;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(0,0,0,0.48);
}
.device-settings-modal.active { display: flex; }
.device-settings-dialog {
width: min(560px, 100%);
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
background: #1e1e1e;
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
overflow: hidden;
}
.device-list-manager {
display: grid;
grid-template-columns: minmax(0, 1fr) 32px;
gap: 8px;
}
.device-list {
min-height: 220px;
max-height: 320px;
overflow: auto;
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
background: rgba(128,128,128,0.08);
}
.device-row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
border-bottom: 1px solid var(--tool-border);
}
.device-row:last-child { border-bottom: 0; }
.device-row.active { background: rgba(0,127,212,0.16); }
.device-row-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row-size {
color: #a7a7a7;
font-size: 11px;
white-space: nowrap;
}
.device-list-controls {
display: flex;
flex-direction: column;
gap: 6px;
}
.device-settings-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.device-settings-footer button { min-height: 30px; }
.device-settings-status {
margin-top: 10px;
color: #a7a7a7;
font-size: 12px;
min-height: 18px;
}
.device-form {
display: none;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--tool-border);
}
.device-form.active { display: block; }
.device-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.device-form-grid label {
display: grid;
gap: 4px;
color: #a7a7a7;
font-size: 11px;
}
.device-form-grid .full { grid-column: 1 / -1; }
.settings-close { margin-left: auto; }
#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>
const DEFAULT_DEVICES = ` + string(devicesJSON) + `;
const DEVICES_STORAGE_KEY = 'hplDeviceSimulator.devices.v1';
let devices = loadDevices();
let device = devices[0] || DEFAULT_DEVICES[0];
let landscape = false;
let currentPath = '';
let currentContent = '';
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');
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);
sel.addEventListener('change', () => {
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
renderScreen();
if (currentContent) loadContent(currentContent);
});
document.getElementById('addDeviceBtn').addEventListener('click', addDevice);
document.getElementById('removeDeviceBtn').addEventListener('click', removeCurrentDevice);
document.getElementById('importDevicesBtn').addEventListener('click', () => {
document.getElementById('importDevicesInput').click();
});
document.getElementById('exportDevicesBtn').addEventListener('click', exportDevices);
document.getElementById('importDevicesInput').addEventListener('change', importDevices);
document.getElementById('restoreDevicesBtn').addEventListener('click', restoreDefaultDevices);
document.getElementById('settingsBtn').addEventListener('click', openDeviceSettings);
document.getElementById('closeSettingsBtn').addEventListener('click', closeDeviceSettings);
document.getElementById('saveDeviceBtn').addEventListener('click', saveDeviceForm);
document.getElementById('cancelDeviceBtn').addEventListener('click', hideDeviceForm);
document.getElementById('moveDeviceUpBtn').addEventListener('click', moveSelectedDevicesUp);
document.getElementById('moveDeviceDownBtn').addEventListener('click', moveSelectedDevicesDown);
document.getElementById('deviceSettingsModal').addEventListener('click', (event) => {
if (event.target === event.currentTarget) closeDeviceSettings();
});
function openDeviceSettings() {
document.getElementById('deviceSettingsModal').classList.add('active');
renderDeviceList();
setDeviceSettingsStatus('');
}
function closeDeviceSettings() {
document.getElementById('deviceSettingsModal').classList.remove('active');
hideDeviceForm();
}
function setDeviceSettingsStatus(message) {
document.getElementById('deviceSettingsStatus').textContent = message || '';
}
function showDeviceForm() {
const source = device || DEFAULT_DEVICES[0];
document.getElementById('deviceNameInput').value = source.name ? source.name + ' Copy' : 'Custom Device';
document.getElementById('deviceSwInput').value = String(source.sw || 390);
document.getElementById('deviceShInput').value = String(source.sh || 844);
document.getElementById('deviceScrInput').value = String(source.scr || 40);
document.getElementById('deviceNotchInput').value = source.notch || 'none';
document.getElementById('deviceNwInput').value = source.nw === undefined ? '' : String(source.nw);
document.getElementById('deviceNhInput').value = source.nh === undefined ? '' : String(source.nh);
document.getElementById('deviceNrInput').value = source.nr === undefined ? '' : String(source.nr);
document.getElementById('devicePsInput').value = source.ps === undefined ? '' : String(source.ps);
document.getElementById('deviceSatInput').value = String(source.sat || 0);
document.getElementById('deviceSabInput').value = String(source.sab || 0);
document.getElementById('deviceForm').classList.add('active');
setDeviceSettingsStatus('');
}
function hideDeviceForm() {
document.getElementById('deviceForm').classList.remove('active');
}
function formOptionalNumber(id) {
const value = document.getElementById(id).value.trim();
return value === '' ? undefined : Number(value);
}
function saveDeviceForm() {
const name = document.getElementById('deviceNameInput').value.trim();
const created = normalizeDevice({
id: (slugify(name) || 'custom-device') + '-' + Date.now(),
name: name,
sw: Number(document.getElementById('deviceSwInput').value),
sh: Number(document.getElementById('deviceShInput').value),
scr: Number(document.getElementById('deviceScrInput').value),
notch: document.getElementById('deviceNotchInput').value,
nw: formOptionalNumber('deviceNwInput'),
nh: formOptionalNumber('deviceNhInput'),
nr: formOptionalNumber('deviceNrInput'),
ps: formOptionalNumber('devicePsInput'),
sat: Number(document.getElementById('deviceSatInput').value),
sab: Number(document.getElementById('deviceSabInput').value)
}, devices.length);
if (!created) {
setDeviceSettingsStatus('Enter at least a valid width and height.');
return;
}
devices.push(created);
saveDevices();
populateDeviceSelect(created.id);
renderDeviceList([created.id]);
hideDeviceForm();
setDeviceSettingsStatus('Added ' + created.name + '.');
renderScreen();
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];
renderDeviceList(getCheckedDeviceIds());
}
function renderDeviceList(checkedIds) {
const list = document.getElementById('deviceList');
if (!list) return;
const checked = new Set(checkedIds || []);
list.innerHTML = '';
devices.forEach((d, i) => {
const row = document.createElement('label');
row.className = 'device-row' + (d.id === device.id ? ' active' : '');
row.setAttribute('role', 'option');
row.title = d.name;
const input = document.createElement('input');
input.type = 'checkbox';
input.value = d.id;
input.checked = checked.has(d.id);
const name = document.createElement('span');
name.className = 'device-row-name';
name.textContent = d.name;
const size = document.createElement('span');
size.className = 'device-row-size';
size.textContent = d.sw + ' x ' + d.sh;
row.append(input, name, size);
row.addEventListener('dblclick', () => {
populateDeviceSelect(d.id);
renderScreen();
if (currentContent) loadContent(currentContent);
});
list.appendChild(row);
});
}
function getCheckedDeviceIds() {
return Array.from(document.querySelectorAll('#deviceList input[type="checkbox"]:checked')).map(input => input.value);
}
function loadDevices() {
try {
const raw = localStorage.getItem(DEVICES_STORAGE_KEY);
if (raw) {
const parsed = normalizeDeviceList(JSON.parse(raw));
if (parsed.length) return parsed;
}
} catch {}
return DEFAULT_DEVICES.map(d => Object.assign({}, d));
}
function saveDevices() {
localStorage.setItem(DEVICES_STORAGE_KEY, JSON.stringify(devices));
}
function cloneDefaultDevices() {
return DEFAULT_DEVICES.map(d => Object.assign({}, d));
}
function restoreDefaultDevices() {
const existingIds = new Set(devices.map(d => d.id));
const restored = cloneDefaultDevices().filter(d => !existingIds.has(d.id));
if (!restored.length) {
setDeviceSettingsStatus('All default devices are already present.');
return;
}
devices = devices.concat(restored);
saveDevices();
populateDeviceSelect(restored[0].id);
hideDeviceForm();
renderDeviceList(restored.map(d => d.id));
setDeviceSettingsStatus('Restored ' + restored.length + ' default device(s).');
renderScreen();
if (currentContent) loadContent(currentContent);
}
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 addDevice() {
showDeviceForm();
}
function removeCurrentDevice() {
const selectedIds = getCheckedDeviceIds();
if (!selectedIds.length) {
setDeviceSettingsStatus('Select one or more devices to delete.');
return;
}
if (devices.length - selectedIds.length < 1) {
setDeviceSettingsStatus('At least one device is required.');
return;
}
const selected = new Set(selectedIds);
const oldDeviceId = device.id;
const removedCount = selectedIds.length;
devices = devices.filter(d => !selected.has(d.id));
saveDevices();
const nextDevice = devices.find(d => d.id === oldDeviceId) || devices[0];
populateDeviceSelect(nextDevice.id);
renderDeviceList([]);
setDeviceSettingsStatus('Deleted ' + removedCount + ' device(s).');
renderScreen();
if (currentContent) loadContent(currentContent);
}
function moveSelectedDevicesUp() {
moveSelectedDevices(-1);
}
function moveSelectedDevicesDown() {
moveSelectedDevices(1);
}
function moveSelectedDevices(direction) {
const selectedIds = getCheckedDeviceIds();
if (!selectedIds.length) {
setDeviceSettingsStatus('Select one or more devices to move.');
return;
}
const selected = new Set(selectedIds);
if (direction < 0) {
for (let i = 1; i < devices.length; i++) {
if (selected.has(devices[i].id) && !selected.has(devices[i - 1].id)) {
const tmp = devices[i - 1];
devices[i - 1] = devices[i];
devices[i] = tmp;
}
}
} else {
for (let i = devices.length - 2; i >= 0; i--) {
if (selected.has(devices[i].id) && !selected.has(devices[i + 1].id)) {
const tmp = devices[i + 1];
devices[i + 1] = devices[i];
devices[i] = tmp;
}
}
}
saveDevices();
populateDeviceSelect(device.id);
renderDeviceList(selectedIds);
setDeviceSettingsStatus('Moved ' + selectedIds.length + ' device(s).');
}
function exportDevices() {
const payload = JSON.stringify({ version: 1, devices: devices }, null, 2);
const blob = new Blob([payload], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'hpl-device-simulator-devices.json';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function importDevices(event) {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const imported = normalizeDeviceList(JSON.parse(String(reader.result || '')));
if (!imported.length) {
setDeviceSettingsStatus('No valid devices found.');
return;
}
devices = imported;
saveDevices();
populateDeviceSelect(devices[0].id);
renderDeviceList([]);
setDeviceSettingsStatus('Imported ' + imported.length + ' device(s).');
renderScreen();
if (currentContent) loadContent(currentContent);
} catch (e) {
setDeviceSettingsStatus('Could not import devices: ' + (e.message || e));
}
};
reader.readAsText(file);
}
document.getElementById('orientBtn').addEventListener('click', (event) => {
landscape = !landscape;
event.currentTarget.textContent = landscape ? 'Portrait' : 'Landscape';
renderScreen();
if (currentContent) loadContent(currentContent);
});
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);
else setBusy('');
} catch (e) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + (e.message || e);
}
});
document.getElementById('reloadBtn').addEventListener('click', () => {
if (currentPath) loadPath(currentPath);
});
document.getElementById('muteBtn').addEventListener('click', () => {
muted = !muted;
renderMuteButton();
applyMute();
});
setupDropZone('dropZone', statusEl, (j) => {
if (j.paths && j.paths.length) loadPath(j.paths[0]);
else if (j.skipped) statusEl.textContent = 'Drop an HTML file.';
});
async function loadPath(path) {
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 = '';
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;
frame.srcdoc = injectMuteBridge(html);
}
function injectMuteBridge(html) {
const bridge =
'<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 ? '&#128264;' : '&#128263;';
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;
try {
frame.contentWindow.__hplMuted = muted;
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: 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'));
</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),
})
}