Compare commits
4 Commits
986993a027
...
7094e6c3b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7094e6c3b0 | |||
| 6c09beea43 | |||
| ad6403f3e4 | |||
| 700bb135ad |
20
README.md
20
README.md
@@ -85,3 +85,23 @@ Changed
|
|||||||
- Standalone navigation now uses a collapsible sidebar with compact tool initials.
|
- Standalone navigation now uses a collapsible sidebar with compact tool initials.
|
||||||
- Mintegral Checker UI now embeds the playable preview, checklist, runtime events, mute, and reload controls in one view.
|
- Mintegral Checker UI now embeds the playable preview, checklist, runtime events, mute, and reload controls in one view.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**v0.2.1**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added Device Simulator to the VS Code extension and standalone app.
|
||||||
|
- Added editable Device Simulator device lists with add, delete, restore defaults, import, and export.
|
||||||
|
- Added drag-and-drop reordering for Device Simulator devices.
|
||||||
|
- Added iPhone 17, iPhone Air, iPhone 17 Pro, and iPhone 17 Pro Max defaults.
|
||||||
|
- Added drag-and-drop tool reordering with persisted order in the VS Code launcher and standalone Home/sidebar.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
- Device Simulator settings now use a modal list manager with multi-select, add/delete, and move up/down controls.
|
||||||
|
- Standalone Device Simulator sidebar abbreviation is now DS.
|
||||||
|
- Send To Mobile browser preview now opens playables in a delayed full-screen preview wrapper while Download still serves the original HTML.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed Device Simulator mute for HTML media and WebAudio playables.
|
||||||
|
- Fixed Device Simulator Add Device and Remove Device interactions inside embedded webviews.
|
||||||
|
- Fixed Send To Mobile browser preview spending the View button's initial tap as a premature playable click-through while preserving real user-initiated redirects.
|
||||||
|
```
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
BIN
dist/hpl-toolbox-0.2.0.vsix
vendored
BIN
dist/hpl-toolbox-0.2.0.vsix
vendored
Binary file not shown.
Binary file not shown.
BIN
dist/hpl-toolbox-0.2.1.vsix
vendored
Normal file
BIN
dist/hpl-toolbox-0.2.1.vsix
vendored
Normal file
Binary file not shown.
@@ -2,7 +2,7 @@
|
|||||||
"name": "hpl-toolbox",
|
"name": "hpl-toolbox",
|
||||||
"displayName": "HPL Toolbox",
|
"displayName": "HPL Toolbox",
|
||||||
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"publisher": "hesukastro",
|
"publisher": "hesukastro",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -47,6 +47,10 @@
|
|||||||
{
|
{
|
||||||
"command": "hplToolbox.openMintegralChecker",
|
"command": "hplToolbox.openMintegralChecker",
|
||||||
"title": "HPL Toolbox: Open Mintegral Checker"
|
"title": "HPL Toolbox: Open Mintegral Checker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openDeviceSimulator",
|
||||||
|
"title": "HPL Toolbox: Open Device Simulator"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"viewsContainers": {
|
"viewsContainers": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { openSendToMobile } from './tools/sendToMobile';
|
|||||||
import { openPlayworksConverter } from './tools/playworksConverter';
|
import { openPlayworksConverter } from './tools/playworksConverter';
|
||||||
import { openMintegralChecker } from './tools/mintegralChecker';
|
import { openMintegralChecker } from './tools/mintegralChecker';
|
||||||
import { openChangelog } from './changelogView';
|
import { openChangelog } from './changelogView';
|
||||||
|
import { openDeviceSimulator } from './tools/deviceSimulator';
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
@@ -18,6 +19,7 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openMintegralChecker', () => openMintegralChecker(context)),
|
vscode.commands.registerCommand('hplToolbox.openMintegralChecker', () => openMintegralChecker(context)),
|
||||||
|
vscode.commands.registerCommand('hplToolbox.openDeviceSimulator', () => openDeviceSimulator(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
|
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
|
||||||
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
|
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
|
||||||
|
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
|
||||||
|
|
||||||
interface ToolDefinition {
|
interface ToolDefinition {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -54,7 +55,8 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
|||||||
const version = this.context.extension.packageJSON.version as string;
|
const version = this.context.extension.packageJSON.version as string;
|
||||||
const tools = loadToolDefinitions(this.context.extensionPath);
|
const tools = loadToolDefinitions(this.context.extensionPath);
|
||||||
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
|
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
|
||||||
view.webview.html = getHtml(version, betaToolsEnabled, tools);
|
const toolOrder = this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []);
|
||||||
|
view.webview.html = getHtml(version, betaToolsEnabled, tools, toolOrder);
|
||||||
view.webview.onDidReceiveMessage(async (msg) => {
|
view.webview.onDidReceiveMessage(async (msg) => {
|
||||||
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
||||||
vscode.commands.executeCommand(msg.command);
|
vscode.commands.executeCommand(msg.command);
|
||||||
@@ -62,7 +64,9 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
|||||||
vscode.commands.executeCommand('hplToolbox.openChangelog');
|
vscode.commands.executeCommand('hplToolbox.openChangelog');
|
||||||
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
|
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
|
||||||
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
|
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
|
||||||
view.webview.html = getHtml(version, msg.enabled, tools);
|
view.webview.html = getHtml(version, msg.enabled, tools, this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []));
|
||||||
|
} else if (msg?.type === 'setToolOrder' && Array.isArray(msg.order)) {
|
||||||
|
await this.context.globalState.update(TOOL_ORDER_KEY, msg.order.filter((id: unknown) => typeof id === 'string'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -88,8 +92,8 @@ function loadToolDefinitions(extensionPath: string): ToolDefinition[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[]): string {
|
function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[], toolOrder: string[]): string {
|
||||||
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta));
|
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta), toolOrder);
|
||||||
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -127,6 +131,9 @@ function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefiniti
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
.tool-btn.dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
.tool-btn:hover {
|
.tool-btn:hover {
|
||||||
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
|
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
|
||||||
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
|
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
|
||||||
@@ -217,9 +224,44 @@ ${toolButtons}
|
|||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn.dataset.dragging === 'true') {
|
||||||
|
btn.dataset.dragging = 'false';
|
||||||
|
return;
|
||||||
|
}
|
||||||
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
|
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const toolsEl = document.querySelector('.tools');
|
||||||
|
let dragged = null;
|
||||||
|
toolsEl.querySelectorAll('.tool-btn').forEach((btn) => {
|
||||||
|
btn.draggable = true;
|
||||||
|
btn.addEventListener('dragstart', (event) => {
|
||||||
|
dragged = btn;
|
||||||
|
btn.classList.add('dragging');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', btn.dataset.id || '');
|
||||||
|
});
|
||||||
|
btn.addEventListener('dragend', () => {
|
||||||
|
btn.classList.remove('dragging');
|
||||||
|
btn.dataset.dragging = 'true';
|
||||||
|
dragged = null;
|
||||||
|
persistToolOrder();
|
||||||
|
setTimeout(() => { btn.dataset.dragging = 'false'; }, 0);
|
||||||
|
});
|
||||||
|
btn.addEventListener('dragover', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!dragged || dragged === btn) return;
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
const after = event.clientY > rect.top + rect.height / 2;
|
||||||
|
toolsEl.insertBefore(dragged, after ? btn.nextSibling : btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function persistToolOrder() {
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'setToolOrder',
|
||||||
|
order: Array.from(toolsEl.querySelectorAll('.tool-btn')).map(btn => btn.dataset.id).filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
document.getElementById('betaToggle').addEventListener('click', (event) => {
|
document.getElementById('betaToggle').addEventListener('click', (event) => {
|
||||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||||
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
||||||
@@ -232,17 +274,29 @@ ${toolButtons}
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortToolsForDisplay(tools: ToolDefinition[]): ToolDefinition[] {
|
function sortToolsForDisplay(tools: ToolDefinition[], toolOrder: string[]): ToolDefinition[] {
|
||||||
return [...tools].sort((a, b) => Number(Boolean(a.beta)) - Number(Boolean(b.beta)));
|
const order = new Map(toolOrder.map((id, index) => [id, index]));
|
||||||
|
return [...tools].sort((a, b) => {
|
||||||
|
const aId = toolId(a);
|
||||||
|
const bId = toolId(b);
|
||||||
|
const ai = order.has(aId) ? order.get(aId)! : Number.MAX_SAFE_INTEGER;
|
||||||
|
const bi = order.has(bId) ? order.get(bId)! : Number.MAX_SAFE_INTEGER;
|
||||||
|
if (ai !== bi) return ai - bi;
|
||||||
|
return Number(Boolean(a.beta)) - Number(Boolean(b.beta));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToolButton(tool: ToolDefinition): string {
|
function renderToolButton(tool: ToolDefinition): string {
|
||||||
return ` <button class="tool-btn" data-cmd="${escapeHtml(tool.command)}">
|
return ` <button class="tool-btn" data-id="${escapeHtml(toolId(tool))}" data-cmd="${escapeHtml(tool.command)}">
|
||||||
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
|
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
|
||||||
<span class="tool-desc">${escapeHtml(tool.description)}</span>
|
<span class="tool-desc">${escapeHtml(tool.description)}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolId(tool: ToolDefinition): string {
|
||||||
|
return tool.id || tool.command;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
function escapeHtml(value: string): string {
|
||||||
return value.replace(/[&<>"']/g, ch => ({
|
return value.replace(/[&<>"']/g, ch => ({
|
||||||
'&': '&',
|
'&': '&',
|
||||||
|
|||||||
986
src/tools/deviceSimulator.ts
Normal file
986
src/tools/deviceSimulator.ts
Normal file
@@ -0,0 +1,986 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getToolWebviewStyles } from './shared';
|
||||||
|
|
||||||
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sw: number; sh: number;
|
||||||
|
scr: number;
|
||||||
|
notch: 'none' | 'notch' | 'island' | 'punch';
|
||||||
|
nw?: number; nh?: number; nr?: number; ps?: number;
|
||||||
|
sat: number; sab: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICES: Device[] = [
|
||||||
|
// ── iPhone 17 ──────────────────────────────────────────────────────────────
|
||||||
|
{ 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 },
|
||||||
|
// ── iPhone 16 ──────────────────────────────────────────────────────────────
|
||||||
|
{ 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 },
|
||||||
|
// ── iPhone 15 ──────────────────────────────────────────────────────────────
|
||||||
|
{ 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 },
|
||||||
|
// ── iPhone 14 ──────────────────────────────────────────────────────────────
|
||||||
|
{ 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 },
|
||||||
|
// ── Other ──────────────────────────────────────────────────────────────────
|
||||||
|
{ 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function openDeviceSimulator(_context: vscode.ExtensionContext) {
|
||||||
|
if (store.panel) {
|
||||||
|
store.panel.reveal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = vscode.window.createWebviewPanel(
|
||||||
|
'hplToolbox.deviceSimulator',
|
||||||
|
'Device Simulator',
|
||||||
|
vscode.ViewColumn.Active,
|
||||||
|
{ enableScripts: true, retainContextWhenHidden: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
store.panel = panel;
|
||||||
|
panel.onDidDispose(() => { store.panel = null; });
|
||||||
|
panel.webview.html = getHtml(DEVICES);
|
||||||
|
|
||||||
|
let lastFilePath = '';
|
||||||
|
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
if (msg.type === 'pickFile') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: true,
|
||||||
|
canSelectFolders: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select File',
|
||||||
|
filters: { 'HTML': ['html', 'htm'] },
|
||||||
|
});
|
||||||
|
if (picked?.[0]) {
|
||||||
|
lastFilePath = picked[0].fsPath;
|
||||||
|
sendFile(panel, lastFilePath);
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'reload') {
|
||||||
|
if (lastFilePath) sendFile(panel, lastFilePath);
|
||||||
|
} else if (msg.type === 'exportDevices') {
|
||||||
|
const target = await vscode.window.showSaveDialog({
|
||||||
|
defaultUri: vscode.Uri.file('hpl-device-simulator-devices.json'),
|
||||||
|
filters: { 'JSON': ['json'] },
|
||||||
|
saveLabel: 'Export Devices',
|
||||||
|
});
|
||||||
|
if (target) {
|
||||||
|
await fs.promises.writeFile(target.fsPath, String(msg.content || ''), 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFile(panel: vscode.WebviewPanel, filePath: string) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
panel.webview.postMessage({ type: 'fileLoaded', content, name: path.basename(filePath) });
|
||||||
|
} catch {
|
||||||
|
vscode.window.showErrorMessage('Device Simulator: could not read ' + path.basename(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtml(devices: Device[]): string {
|
||||||
|
const devicesJson = JSON.stringify(devices);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
${getToolWebviewStyles()}
|
||||||
|
|
||||||
|
/* ── layout ─────────────────────────────────────────────────────────── */
|
||||||
|
html, body { min-height: 100%; }
|
||||||
|
|
||||||
|
/* Setup view — shown before any file is loaded */
|
||||||
|
#setup-view { min-height: 100%; }
|
||||||
|
|
||||||
|
/* Simulator view — shown after first file load */
|
||||||
|
#sim-view {
|
||||||
|
display: none;
|
||||||
|
height: min(720px, calc(100vh - 280px));
|
||||||
|
min-height: 520px;
|
||||||
|
margin-top: var(--tool-gap-lg);
|
||||||
|
gap: var(--tool-gap-lg);
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
#sim-view.active { display: grid; }
|
||||||
|
|
||||||
|
/* shared panel chrome */
|
||||||
|
.sim-panel {
|
||||||
|
border: 1px solid var(--tool-border);
|
||||||
|
border-radius: var(--tool-radius);
|
||||||
|
background: var(--tool-panel-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── left: preview ───────────────────────────────────────────────────── */
|
||||||
|
#preview-area {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#screen-scaler {
|
||||||
|
transform-origin: top center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
#device-screen {
|
||||||
|
--cutout-bg: #000;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255,255,255,0.08),
|
||||||
|
0 0 0 3px #1a1a1a,
|
||||||
|
0 0 0 4px rgba(255,255,255,0.05),
|
||||||
|
0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
#device-screen iframe {
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
}
|
||||||
|
.cutout {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--cutout-bg);
|
||||||
|
}
|
||||||
|
.title-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.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: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border-color: var(--tool-border);
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||||
|
.icon-btn[aria-pressed="true"] { color: var(--vscode-testing-iconPassed, #73c991); }
|
||||||
|
.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: var(--vscode-editor-background);
|
||||||
|
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: var(--tool-panel-soft);
|
||||||
|
}
|
||||||
|
.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: var(--vscode-descriptionForeground);
|
||||||
|
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: var(--vscode-descriptionForeground);
|
||||||
|
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: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.device-form-grid .full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.settings-close {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
#sim-view {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(420px, 60vh);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.title-select {
|
||||||
|
width: min(180px, 36vw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── setup view (no file loaded yet) ─────────────────────────────── -->
|
||||||
|
<div id="setup-view">
|
||||||
|
<main class="tool-page">
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Device Simulator</h2>
|
||||||
|
<p class="tool-description">Preview HTML playables inside accurate 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="settings-btn" class="icon-btn settings-close" title="Device Settings" aria-label="Device Settings">⚙</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-label">Playable HTML</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<button id="pick-btn" class="secondary">Select File</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-name" id="setup-file-name" style="margin-top:8px;">(no file selected)</div>
|
||||||
|
<div id="setup-status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div id="device-settings-modal" class="device-settings-modal" role="dialog" aria-modal="true" aria-labelledby="device-settings-title">
|
||||||
|
<div class="device-settings-dialog">
|
||||||
|
<div class="panel-header device-settings-header">
|
||||||
|
<h3 id="device-settings-title" class="panel-title">Device Settings</h3>
|
||||||
|
<button id="close-settings-btn" class="icon-btn settings-close" title="Close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="device-list-manager">
|
||||||
|
<div id="device-list" class="device-list" role="listbox" aria-label="Devices"></div>
|
||||||
|
<div class="device-list-controls">
|
||||||
|
<button id="add-device-btn" class="icon-btn" title="Add Device" aria-label="Add Device">+</button>
|
||||||
|
<button id="remove-device-btn" class="icon-btn" title="Delete Selected Devices" aria-label="Delete Selected Devices">-</button>
|
||||||
|
<button id="move-device-up-btn" class="icon-btn" title="Move Selected Up" aria-label="Move Selected Up">↑</button>
|
||||||
|
<button id="move-device-down-btn" class="icon-btn" title="Move Selected Down" aria-label="Move Selected Down">↓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="device-settings-status" class="device-settings-status"></div>
|
||||||
|
<div id="device-form" class="device-form">
|
||||||
|
<div class="device-form-grid">
|
||||||
|
<label class="full">Name<input id="device-name-input" type="text" /></label>
|
||||||
|
<label>Width<input id="device-sw-input" type="text" inputmode="numeric" /></label>
|
||||||
|
<label>Height<input id="device-sh-input" type="text" inputmode="numeric" /></label>
|
||||||
|
<label>Corner Radius<input id="device-scr-input" type="text" inputmode="numeric" /></label>
|
||||||
|
<label>Notch
|
||||||
|
<select id="device-notch-input">
|
||||||
|
<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="device-nw-input" type="text" inputmode="numeric" /></label>
|
||||||
|
<label>Notch Height<input id="device-nh-input" type="text" inputmode="numeric" /></label>
|
||||||
|
<label>Notch Radius<input id="device-nr-input" type="text" inputmode="numeric" /></label>
|
||||||
|
<label>Punch Size<input id="device-ps-input" type="text" inputmode="numeric" /></label>
|
||||||
|
<label>Safe Top<input id="device-sat-input" type="text" inputmode="numeric" /></label>
|
||||||
|
<label>Safe Bottom<input id="device-sab-input" type="text" inputmode="numeric" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="save-device-btn">Save Device</button>
|
||||||
|
<button id="cancel-device-btn" class="secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-settings-footer">
|
||||||
|
<button id="restore-devices-btn" class="secondary">Restore Defaults</button>
|
||||||
|
<button id="import-devices-btn" class="secondary">Import Devices</button>
|
||||||
|
<button id="export-devices-btn" class="secondary">Export Devices</button>
|
||||||
|
</div>
|
||||||
|
<input id="import-devices-input" type="file" accept="application/json,.json" style="display:none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ── simulator view (file loaded) ───────────────────────────── -->
|
||||||
|
<div id="sim-view">
|
||||||
|
|
||||||
|
<!-- left: device preview -->
|
||||||
|
<section class="sim-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Playable</h3>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden;margin-left:auto;">
|
||||||
|
<span id="file-label" style="font-size:11px;color:var(--vscode-descriptionForeground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;"></span>
|
||||||
|
<span id="dim-label" style="font-size:11px;color:var(--vscode-descriptionForeground);white-space:nowrap;flex-shrink:0;"></span>
|
||||||
|
</div>
|
||||||
|
<div class="title-actions">
|
||||||
|
<select id="device-select" class="title-select" title="Device"></select>
|
||||||
|
<button id="orient-btn" class="secondary title-orient">Landscape</button>
|
||||||
|
<button id="mute-btn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">🔇</button>
|
||||||
|
<button id="reload-btn" class="icon-btn" title="Reload" aria-label="Reload">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="preview-area">
|
||||||
|
<div id="screen-scaler"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
const DEFAULT_DEVICES = ${devicesJson};
|
||||||
|
const DEVICES_STORAGE_KEY = 'hplDeviceSimulator.devices.v1';
|
||||||
|
|
||||||
|
let devices = loadDevices();
|
||||||
|
let device = devices[0] || DEFAULT_DEVICES[0];
|
||||||
|
let landscape = false;
|
||||||
|
let currentContent = null;
|
||||||
|
let currentBlobUrl = null;
|
||||||
|
let cutoutColor = '#000000';
|
||||||
|
let muted = false;
|
||||||
|
const mediaVolumes = new WeakMap();
|
||||||
|
let muteObserver = null;
|
||||||
|
let muteDocument = null;
|
||||||
|
|
||||||
|
/* ── device selector ─────────────────────────────────────────────────── */
|
||||||
|
const sel = document.getElementById('device-select');
|
||||||
|
populateDeviceSelect(device.id);
|
||||||
|
sel.addEventListener('change', function() {
|
||||||
|
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
document.getElementById('add-device-btn').addEventListener('click', addDevice);
|
||||||
|
document.getElementById('remove-device-btn').addEventListener('click', removeCurrentDevice);
|
||||||
|
document.getElementById('import-devices-btn').addEventListener('click', function() {
|
||||||
|
document.getElementById('import-devices-input').click();
|
||||||
|
});
|
||||||
|
document.getElementById('export-devices-btn').addEventListener('click', exportDevices);
|
||||||
|
document.getElementById('restore-devices-btn').addEventListener('click', restoreDefaultDevices);
|
||||||
|
document.getElementById('import-devices-input').addEventListener('change', importDevices);
|
||||||
|
document.getElementById('settings-btn').addEventListener('click', openDeviceSettings);
|
||||||
|
document.getElementById('close-settings-btn').addEventListener('click', closeDeviceSettings);
|
||||||
|
document.getElementById('save-device-btn').addEventListener('click', saveDeviceForm);
|
||||||
|
document.getElementById('cancel-device-btn').addEventListener('click', hideDeviceForm);
|
||||||
|
document.getElementById('move-device-up-btn').addEventListener('click', moveSelectedDevicesUp);
|
||||||
|
document.getElementById('move-device-down-btn').addEventListener('click', moveSelectedDevicesDown);
|
||||||
|
document.getElementById('device-settings-modal').addEventListener('click', function(event) {
|
||||||
|
if (event.target === event.currentTarget) closeDeviceSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openDeviceSettings() {
|
||||||
|
document.getElementById('device-settings-modal').classList.add('active');
|
||||||
|
renderDeviceList();
|
||||||
|
setDeviceSettingsStatus('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeviceSettings() {
|
||||||
|
document.getElementById('device-settings-modal').classList.remove('active');
|
||||||
|
hideDeviceForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDeviceSettingsStatus(message) {
|
||||||
|
document.getElementById('device-settings-status').textContent = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDeviceForm() {
|
||||||
|
const source = device || DEFAULT_DEVICES[0];
|
||||||
|
document.getElementById('device-name-input').value = source.name ? source.name + ' Copy' : 'Custom Device';
|
||||||
|
document.getElementById('device-sw-input').value = String(source.sw || 390);
|
||||||
|
document.getElementById('device-sh-input').value = String(source.sh || 844);
|
||||||
|
document.getElementById('device-scr-input').value = String(source.scr || 40);
|
||||||
|
document.getElementById('device-notch-input').value = source.notch || 'none';
|
||||||
|
document.getElementById('device-nw-input').value = source.nw === undefined ? '' : String(source.nw);
|
||||||
|
document.getElementById('device-nh-input').value = source.nh === undefined ? '' : String(source.nh);
|
||||||
|
document.getElementById('device-nr-input').value = source.nr === undefined ? '' : String(source.nr);
|
||||||
|
document.getElementById('device-ps-input').value = source.ps === undefined ? '' : String(source.ps);
|
||||||
|
document.getElementById('device-sat-input').value = String(source.sat || 0);
|
||||||
|
document.getElementById('device-sab-input').value = String(source.sab || 0);
|
||||||
|
document.getElementById('device-form').classList.add('active');
|
||||||
|
setDeviceSettingsStatus('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDeviceForm() {
|
||||||
|
document.getElementById('device-form').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formOptionalNumber(id) {
|
||||||
|
const value = document.getElementById(id).value.trim();
|
||||||
|
return value === '' ? undefined : Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDeviceForm() {
|
||||||
|
const name = document.getElementById('device-name-input').value.trim();
|
||||||
|
const created = normalizeDevice({
|
||||||
|
id: (slugify(name) || 'custom-device') + '-' + Date.now(),
|
||||||
|
name: name,
|
||||||
|
sw: Number(document.getElementById('device-sw-input').value),
|
||||||
|
sh: Number(document.getElementById('device-sh-input').value),
|
||||||
|
scr: Number(document.getElementById('device-scr-input').value),
|
||||||
|
notch: document.getElementById('device-notch-input').value,
|
||||||
|
nw: formOptionalNumber('device-nw-input'),
|
||||||
|
nh: formOptionalNumber('device-nh-input'),
|
||||||
|
nr: formOptionalNumber('device-nr-input'),
|
||||||
|
ps: formOptionalNumber('device-ps-input'),
|
||||||
|
sat: Number(document.getElementById('device-sat-input').value),
|
||||||
|
sab: Number(document.getElementById('device-sab-input').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(function(d, i) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = String(i);
|
||||||
|
o.textContent = d.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
const foundIndex = devices.findIndex(function(d) { return 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('device-list');
|
||||||
|
if (!list) return;
|
||||||
|
const checked = new Set(checkedIds || []);
|
||||||
|
list.innerHTML = '';
|
||||||
|
devices.forEach(function(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', function() {
|
||||||
|
populateDeviceSelect(d.id);
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckedDeviceIds() {
|
||||||
|
return Array.from(document.querySelectorAll('#device-list input[type="checkbox"]:checked')).map(function(input) {
|
||||||
|
return input.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckedDeviceIndexes() {
|
||||||
|
const checked = new Set(getCheckedDeviceIds());
|
||||||
|
return devices.map(function(d, i) { return checked.has(d.id) ? i : -1; }).filter(function(i) { return i >= 0; });
|
||||||
|
}
|
||||||
|
|
||||||
|
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(function(d) { return Object.assign({}, d); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDevices() {
|
||||||
|
localStorage.setItem(DEVICES_STORAGE_KEY, JSON.stringify(devices));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneDefaultDevices() {
|
||||||
|
return DEFAULT_DEVICES.map(function(d) { return Object.assign({}, d); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDefaultDevices() {
|
||||||
|
const existingIds = new Set(devices.map(function(d) { return d.id; }));
|
||||||
|
const restored = cloneDefaultDevices().filter(function(d) { return !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(function(d) { return 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(function(d) { return !selected.has(d.id); });
|
||||||
|
saveDevices();
|
||||||
|
const nextDevice = devices.find(function(d) { return 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);
|
||||||
|
vscode.postMessage({ type: 'exportDevices', content: payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
function importDevices(event) {
|
||||||
|
const file = event.target.files && event.target.files[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── orientation ────────────────────────────────────────────────────── */
|
||||||
|
document.getElementById('orient-btn').addEventListener('click', function() {
|
||||||
|
landscape = !landscape;
|
||||||
|
this.textContent = landscape ? 'Portrait' : 'Landscape';
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
function triggerPick(btn) {
|
||||||
|
const status = document.getElementById('setup-status');
|
||||||
|
if (status) {
|
||||||
|
status.className = 'status-panel is-busy';
|
||||||
|
status.textContent = 'Opening file picker...';
|
||||||
|
}
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Selecting...'; }
|
||||||
|
vscode.postMessage({ type: 'pickFile' });
|
||||||
|
setTimeout(function() {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Select File'; }
|
||||||
|
if (status && status.textContent === 'Opening file picker...') {
|
||||||
|
status.className = 'status-panel';
|
||||||
|
status.textContent = '';
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
document.getElementById('pick-btn').addEventListener('click', function() { triggerPick(this); });
|
||||||
|
document.getElementById('reload-btn').addEventListener('click', function() {
|
||||||
|
vscode.postMessage({ type: 'reload' });
|
||||||
|
});
|
||||||
|
document.getElementById('mute-btn').addEventListener('click', function() {
|
||||||
|
muted = !muted;
|
||||||
|
renderMuteButton();
|
||||||
|
applyMute();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── helpers ─────────────────────────────────────────────────────────── */
|
||||||
|
function sw() { return landscape ? device.sh : device.sw; }
|
||||||
|
function sh() { return landscape ? device.sw : device.sh; }
|
||||||
|
|
||||||
|
/* ── cutout overlay HTML ─────────────────────────────────────────────── */
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── render ──────────────────────────────────────────────────────────── */
|
||||||
|
function renderScreen() {
|
||||||
|
const W = sw(), H = sh();
|
||||||
|
const scaler = document.getElementById('screen-scaler');
|
||||||
|
scaler.innerHTML =
|
||||||
|
'<div id="device-screen"' +
|
||||||
|
' style="width:' + W + 'px;height:' + H + 'px;border-radius:' + device.scr + 'px;">' +
|
||||||
|
buildCutout() +
|
||||||
|
'<iframe id="preview-frame" width="' + W + '" height="' + H +
|
||||||
|
'" scrolling="no" frameborder="0" allow="autoplay;pointer-lock"></iframe>' +
|
||||||
|
buildHomeBar() +
|
||||||
|
'</div>';
|
||||||
|
document.getElementById('dim-label').textContent = W + ' \xd7 ' + H;
|
||||||
|
document.getElementById('device-screen').style.setProperty('--cutout-bg', cutoutColor);
|
||||||
|
document.getElementById('preview-frame').addEventListener('load', applyMute);
|
||||||
|
updateScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadContent(html) {
|
||||||
|
const frame = document.getElementById('preview-frame');
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
|
// Revoke previous blob URL to free memory
|
||||||
|
if (currentBlobUrl) {
|
||||||
|
URL.revokeObjectURL(currentBlobUrl);
|
||||||
|
currentBlobUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blob URL iframe: the blob document has its own security context — VS Code
|
||||||
|
// does not inject its nonce-based CSP into it, so inline scripts run freely.
|
||||||
|
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
|
||||||
|
currentBlobUrl = URL.createObjectURL(blob);
|
||||||
|
frame.src = currentBlobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('mute-btn');
|
||||||
|
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('preview-frame');
|
||||||
|
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(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
mutation.addedNodes.forEach(function(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('preview-area');
|
||||||
|
const screen = document.getElementById('device-screen');
|
||||||
|
const scaler = document.getElementById('screen-scaler');
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── messages ────────────────────────────────────────────────────────── */
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
const msg = event.data;
|
||||||
|
if (!msg || !msg.type) return;
|
||||||
|
if (msg.type === 'fileLoaded') {
|
||||||
|
currentContent = msg.content;
|
||||||
|
muted = false;
|
||||||
|
renderMuteButton();
|
||||||
|
document.getElementById('file-label').textContent = msg.name;
|
||||||
|
document.getElementById('setup-file-name').textContent = msg.name;
|
||||||
|
var setupStatus = document.getElementById('setup-status');
|
||||||
|
setupStatus.className = 'status-panel';
|
||||||
|
setupStatus.textContent = '';
|
||||||
|
var pb = document.getElementById('pick-btn');
|
||||||
|
pb.disabled = false; pb.textContent = 'Select File';
|
||||||
|
var first = !document.getElementById('sim-view').classList.contains('active');
|
||||||
|
document.getElementById('sim-view').classList.add('active');
|
||||||
|
if (first) renderScreen();
|
||||||
|
loadContent(currentContent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateScale);
|
||||||
|
new ResizeObserver(updateScale).observe(document.getElementById('preview-area'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
|||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
});
|
});
|
||||||
res.end(file.buf);
|
res.end(mobilePreviewPage(file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +296,35 @@ function chooserPage(files: SharedFile[]): string {
|
|||||||
</body></html>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mobilePreviewPage(file: SharedFile): string {
|
||||||
|
const playableJson = JSON.stringify(file.buf.toString('utf8')).replace(/<\/script/gi, '<\\/script');
|
||||||
|
const title = file.filename.replace(/[&<>"]/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string)
|
||||||
|
);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>${title}</title>
|
||||||
|
<style>
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
|
||||||
|
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
|
||||||
|
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head><body>
|
||||||
|
<div id="boot" class="boot">Loading preview...</div>
|
||||||
|
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
|
||||||
|
<script>
|
||||||
|
const playableHtml = ${playableJson};
|
||||||
|
setTimeout(() => {
|
||||||
|
const frame = document.getElementById('playable');
|
||||||
|
document.getElementById('boot').style.display = 'none';
|
||||||
|
frame.srcdoc = playableHtml;
|
||||||
|
}, 750);
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
interface LanIp { address: string; iface: string; }
|
interface LanIp { address: string; iface: string; }
|
||||||
|
|
||||||
function getLanIps(): LanIp[] {
|
function getLanIps(): LanIp[] {
|
||||||
|
|||||||
884
standalone/device_simulator.go
Normal file
884
standalone/device_simulator.go
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
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">⚙</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">×</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">
|
||||||
|
<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">🔇</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; 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 ? '🔈' : '🔇';
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if n.Beta {
|
if n.Beta {
|
||||||
badge = ` <span class="beta-pill">Beta</span>`
|
badge = ` <span class="beta-pill">Beta</span>`
|
||||||
}
|
}
|
||||||
items.WriteString(`<button type="button" class="home-tool" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
|
items.WriteString(`<button type="button" class="home-tool" draggable="true" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := `
|
body := `
|
||||||
@@ -43,6 +43,7 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
font:inherit;
|
font:inherit;
|
||||||
}
|
}
|
||||||
.home-tool:hover { background:#33373a; border-color:#007fd4; }
|
.home-tool:hover { background:#33373a; border-color:#007fd4; }
|
||||||
|
.home-tool.dragging { opacity:0.55; }
|
||||||
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
|
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
|
||||||
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
|
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 19 KiB |
@@ -104,6 +104,7 @@ const SharedCSS = `
|
|||||||
}
|
}
|
||||||
.sidebar .nav-link:hover { background: #2a2d2e; }
|
.sidebar .nav-link:hover { background: #2a2d2e; }
|
||||||
.sidebar .nav-link.active { background: #094771; color: #fff; }
|
.sidebar .nav-link.active { background: #094771; color: #fff; }
|
||||||
|
.sidebar .nav-link.dragging { opacity: 0.55; }
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
@@ -433,6 +434,7 @@ var navItems = []navItem{
|
|||||||
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
|
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
|
||||||
{Path: "/mintegral", Label: "Mintegral Checker", Description: "Validate Mintegral playable ZIPs against PlayTurbo checks"},
|
{Path: "/mintegral", Label: "Mintegral Checker", Description: "Validate Mintegral playable ZIPs against PlayTurbo checks"},
|
||||||
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
|
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
|
||||||
|
{Path: "/device-simulator", Label: "Device Simulator", Description: "Preview HTML playables inside mobile device frames"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func visibleNavItems(betaToolsEnabled bool) []navItem {
|
func visibleNavItems(betaToolsEnabled bool) []navItem {
|
||||||
@@ -476,6 +478,8 @@ func navInitials(label string) string {
|
|||||||
return "APP"
|
return "APP"
|
||||||
case "Mintegral Checker":
|
case "Mintegral Checker":
|
||||||
return "MiC"
|
return "MiC"
|
||||||
|
case "Device Simulator":
|
||||||
|
return "DS"
|
||||||
}
|
}
|
||||||
words := strings.Fields(label)
|
words := strings.Fields(label)
|
||||||
if len(words) == 0 {
|
if len(words) == 0 {
|
||||||
@@ -509,7 +513,7 @@ func Page(activePath, title, body string) string {
|
|||||||
if n.Beta {
|
if n.Beta {
|
||||||
label += `<span class="beta-pill">Beta</span>`
|
label += `<span class="beta-pill">Beta</span>`
|
||||||
}
|
}
|
||||||
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `" title="` + n.Label + `"><span class="nav-icon">` + navInitials(n.Label) + `</span><span class="nav-text">` + label + `</span></button>`)
|
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `" draggable="` + boolAttr(n.Path != "/") + `" title="` + n.Label + `"><span class="nav-icon">` + navInitials(n.Label) + `</span><span class="nav-text">` + label + `</span></button>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
betaButtonLabel := "Show beta"
|
betaButtonLabel := "Show beta"
|
||||||
@@ -549,10 +553,72 @@ document.getElementById('sidebarToggle').addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
document.querySelectorAll('[data-path]').forEach((element) => {
|
document.querySelectorAll('[data-path]').forEach((element) => {
|
||||||
element.addEventListener('click', () => {
|
element.addEventListener('click', () => {
|
||||||
|
if (element.dataset.dragging === 'true') {
|
||||||
|
element.dataset.dragging = 'false';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const path = element.dataset.path;
|
const path = element.dataset.path;
|
||||||
if (path) window.location.href = path;
|
if (path) window.location.href = path;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
|
||||||
|
function getToolOrder() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(TOOL_ORDER_KEY) || '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed.filter(path => typeof path === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setToolOrder(paths) {
|
||||||
|
localStorage.setItem(TOOL_ORDER_KEY, JSON.stringify(paths));
|
||||||
|
}
|
||||||
|
function applyStoredToolOrder(container, selector) {
|
||||||
|
if (!container) return;
|
||||||
|
const order = getToolOrder();
|
||||||
|
if (!order.length) return;
|
||||||
|
const elements = Array.from(container.querySelectorAll(selector));
|
||||||
|
const byPath = new Map(elements.map(el => [el.dataset.path, el]));
|
||||||
|
const home = byPath.get('/');
|
||||||
|
const ordered = order.map(path => byPath.get(path)).filter(Boolean);
|
||||||
|
const orderedSet = new Set(ordered);
|
||||||
|
const remaining = elements.filter(el => el !== home && !orderedSet.has(el));
|
||||||
|
if (home) container.appendChild(home);
|
||||||
|
ordered.concat(remaining).forEach(el => container.appendChild(el));
|
||||||
|
}
|
||||||
|
function setupToolReorder(container, selector) {
|
||||||
|
if (!container) return;
|
||||||
|
applyStoredToolOrder(container, selector);
|
||||||
|
let dragged = null;
|
||||||
|
container.querySelectorAll(selector).forEach(element => {
|
||||||
|
if (element.dataset.path === '/') return;
|
||||||
|
element.addEventListener('dragstart', event => {
|
||||||
|
dragged = element;
|
||||||
|
element.classList.add('dragging');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', element.dataset.path || '');
|
||||||
|
});
|
||||||
|
element.addEventListener('dragend', () => {
|
||||||
|
element.classList.remove('dragging');
|
||||||
|
element.dataset.dragging = 'true';
|
||||||
|
dragged = null;
|
||||||
|
const paths = Array.from(container.querySelectorAll(selector))
|
||||||
|
.map(el => el.dataset.path)
|
||||||
|
.filter(path => path && path !== '/');
|
||||||
|
setToolOrder(paths);
|
||||||
|
setTimeout(() => { element.dataset.dragging = 'false'; }, 0);
|
||||||
|
});
|
||||||
|
element.addEventListener('dragover', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!dragged || dragged === element || element.dataset.path === '/') return;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const after = event.clientY > rect.top + rect.height / 2;
|
||||||
|
container.insertBefore(dragged, after ? element.nextSibling : element);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupToolReorder(document.querySelector('.sidebar-nav'), '.nav-link');
|
||||||
|
setupToolReorder(document.querySelector('.home-tools'), '.home-tool');
|
||||||
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
|
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
|
||||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||||
await fetch('/api/betaTools', {
|
await fetch('/api/betaTools', {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ func buildMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("GET /mintegral", MintegralPage)
|
mux.HandleFunc("GET /mintegral", MintegralPage)
|
||||||
mux.HandleFunc("GET /mobile", MobilePage)
|
mux.HandleFunc("GET /mobile", MobilePage)
|
||||||
mux.HandleFunc("GET /playworks", PlayworksPage)
|
mux.HandleFunc("GET /playworks", PlayworksPage)
|
||||||
|
mux.HandleFunc("GET /device-simulator", DeviceSimulatorPage)
|
||||||
mux.HandleFunc("GET /changelog", ChangelogPage)
|
mux.HandleFunc("GET /changelog", ChangelogPage)
|
||||||
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
||||||
|
|
||||||
@@ -165,6 +166,10 @@ func buildMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
|
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
|
||||||
mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert)
|
mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert)
|
||||||
|
|
||||||
|
// Device Simulator
|
||||||
|
mux.HandleFunc("POST /api/device-simulator/pick", DeviceSimulatorPick)
|
||||||
|
mux.HandleFunc("POST /api/device-simulator/load", DeviceSimulatorLoad)
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ func shareHandler(s *activeShare) http.Handler {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(s.files[0].buf)
|
_, _ = w.Write([]byte(mobilePreviewPage(s.files[0])))
|
||||||
case "/file":
|
case "/file":
|
||||||
fallthrough
|
fallthrough
|
||||||
case "/file/0":
|
case "/file/0":
|
||||||
@@ -481,7 +481,7 @@ func shareHandler(s *activeShare) http.Handler {
|
|||||||
if parts[0] == "view" {
|
if parts[0] == "view" {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(file.buf)
|
_, _ = w.Write([]byte(mobilePreviewPage(file)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
safeName := safeFilename(file.filename)
|
safeName := safeFilename(file.filename)
|
||||||
@@ -499,6 +499,35 @@ func shareHandler(s *activeShare) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mobilePreviewPage(file sharedFile) string {
|
||||||
|
playableJSON, _ := json.Marshal(string(file.buf))
|
||||||
|
playableScriptValue := strings.ReplaceAll(string(playableJSON), "</script", "<\\/script")
|
||||||
|
playableScriptValue = strings.ReplaceAll(playableScriptValue, "</SCRIPT", "<\\/SCRIPT")
|
||||||
|
title := html.EscapeString(file.filename)
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>` + title + `</title>
|
||||||
|
<style>
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
|
||||||
|
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
|
||||||
|
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head><body>
|
||||||
|
<div id="boot" class="boot">Loading preview...</div>
|
||||||
|
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
|
||||||
|
<script>
|
||||||
|
const playableHtml = ` + playableScriptValue + `;
|
||||||
|
setTimeout(() => {
|
||||||
|
const frame = document.getElementById('playable');
|
||||||
|
document.getElementById('boot').style.display = 'none';
|
||||||
|
frame.srcdoc = playableHtml;
|
||||||
|
}, 750);
|
||||||
|
</script>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
||||||
|
|
||||||
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
||||||
|
|||||||
Binary file not shown.
@@ -54,5 +54,13 @@
|
|||||||
"command": "hplToolbox.openMintegralChecker",
|
"command": "hplToolbox.openMintegralChecker",
|
||||||
"path": "/mintegral",
|
"path": "/mintegral",
|
||||||
"beta": false
|
"beta": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "device-simulator",
|
||||||
|
"title": "Device Simulator",
|
||||||
|
"description": "Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays",
|
||||||
|
"command": "hplToolbox.openDeviceSimulator",
|
||||||
|
"path": "/device-simulator",
|
||||||
|
"beta": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user