v1.0.0
This commit is contained in:
@@ -2,10 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type deviceSimulatorDevice struct {
|
||||
@@ -24,25 +29,25 @@ type deviceSimulatorDevice struct {
|
||||
}
|
||||
|
||||
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},
|
||||
{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) {
|
||||
@@ -56,7 +61,7 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
||||
<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">⚙</button>
|
||||
<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>
|
||||
@@ -71,58 +76,6 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
||||
</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">×</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">↑</button>
|
||||
<button id="moveDeviceDownBtn" class="icon-btn" title="Move Selected Down" aria-label="Move Selected Down">↓</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">
|
||||
@@ -192,99 +145,6 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
||||
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;
|
||||
@@ -316,9 +176,8 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const DEFAULT_DEVICES = ` + string(devicesJSON) + `;
|
||||
const DEVICES_STORAGE_KEY = 'hplDeviceSimulator.devices.v1';
|
||||
const LANGUAGES = [
|
||||
let DEFAULT_DEVICES = ` + string(devicesJSON) + `;
|
||||
let LANGUAGES = [
|
||||
{ name: 'English', code: 'en' },
|
||||
{ name: 'Chinese (Simplified)', code: 'zh-hans' },
|
||||
{ name: 'Chinese (Traditional)', code: 'zh-hant' },
|
||||
@@ -330,7 +189,7 @@ const LANGUAGES = [
|
||||
{ name: 'Russian', code: 'ru' },
|
||||
{ name: 'Spanish', code: 'es' }
|
||||
];
|
||||
let devices = loadDevices();
|
||||
let devices = DEFAULT_DEVICES.slice();
|
||||
let device = devices[0] || DEFAULT_DEVICES[0];
|
||||
let language = 'en';
|
||||
let landscape = false;
|
||||
@@ -370,94 +229,6 @@ languageSelect.addEventListener('change', () => {
|
||||
language = languageSelect.value || LANGUAGES[0].code;
|
||||
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 = '';
|
||||
@@ -471,7 +242,6 @@ function populateDeviceSelect(selectedId) {
|
||||
const index = foundIndex >= 0 ? foundIndex : 0;
|
||||
sel.value = String(index);
|
||||
device = devices[index] || DEFAULT_DEVICES[0];
|
||||
renderDeviceList(getCheckedDeviceIds());
|
||||
}
|
||||
|
||||
function populateLanguageSelect() {
|
||||
@@ -485,75 +255,6 @@ function populateLanguageSelect() {
|
||||
languageSelect.value = language;
|
||||
}
|
||||
|
||||
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 : []);
|
||||
@@ -592,107 +293,13 @@ function slugify(value) {
|
||||
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
showDeviceForm();
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
renderOrientationButton();
|
||||
@@ -904,6 +511,34 @@ function updateScale() {
|
||||
}
|
||||
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)))
|
||||
@@ -1002,6 +637,120 @@ func DeviceSimulatorPreview(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = 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)
|
||||
|
||||
Reference in New Issue
Block a user