feat: reconcile WebAVP desktop parity and releases

This commit is contained in:
2026-08-19 12:14:29 +00:00
parent 407892ed9a
commit 2a275fc536
26 changed files with 2155 additions and 1508 deletions
+146 -28
View File
@@ -7,6 +7,7 @@
<title>Alta Video Player</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect rx='20' width='100' height='100' fill='%23121826'/><polygon points='38,25 38,75 78,50' fill='%23006ED7'/></svg>">
<script src="/static/jszip.min.js"></script>
<script src="/static/webavp-utils.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -469,6 +470,10 @@
}
.btn-add:hover { background: var(--bg-button-hover); }
.btn-add svg { width: 14px; height: 14px; fill: currentColor; }
.release-control { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
.release-control .btn-add { text-decoration: none; white-space: nowrap; }
.release-status { max-width: 280px; color: var(--text-muted); font-size: 9px; text-align: right; }
.release-status.error { color: var(--status-warning); }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg-panel); }
@@ -723,6 +728,10 @@
</div>
</div>
<div class="header-actions">
<div class="release-control">
<a class="btn btn-add" id="releaseAction" href="https://git.pejicorp.com/peji/WebAVP/releases" target="_blank" rel="noopener noreferrer">Download Desktop App</a>
<span class="release-status" id="releaseStatus" aria-live="polite"></span>
</div>
<div class="verify-badge" id="verifyBadge" title="Click for details">
<span class="verify-dot"></span>
<span class="verify-label">Integrity</span>
@@ -878,11 +887,15 @@
<svg viewBox="0 0 24 24"><rect x="2" y="2" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="9.25" y="2" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="16.5" y="2" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="2" y="9.25" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="9.25" y="9.25" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="16.5" y="9.25" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="2" y="16.5" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="9.25" y="16.5" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="16.5" y="16.5" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
3x3
</button>
<button class="layout-preset-btn" data-layout="4x4" title="4x4 / 16 tiles">
<svg viewBox="0 0 24 24"><path d="M2 2h4v4H2V2zm5.3 0h4v4h-4V2zm5.4 0h4v4h-4V2zM18 2h4v4h-4V2zM2 7.3h4v4H2v-4zm5.3 0h4v4h-4v-4zm5.4 0h4v4h-4v-4zm5.3 0h4v4h-4v-4zM2 12.7h4v4H2v-4zm5.3 0h4v4h-4v-4zm5.4 0h4v4h-4v-4zm5.3 0h4v4h-4v-4zM2 18h4v4H2v-4zm5.3 0h4v4h-4v-4zm5.4 0h4v4h-4v-4zm5.3 0h4v4h-4v-4z"/></svg>
4x4
</button>
</div>
<div class="layout-custom-row">
<input type="number" id="layoutCols" min="1" max="6" value="2" placeholder="C">
<input type="number" id="layoutCols" min="1" max="4" value="2" placeholder="C">
<span>&times;</span>
<input type="number" id="layoutRows" min="1" max="6" value="2" placeholder="R">
<input type="number" id="layoutRows" min="1" max="4" value="2" placeholder="R">
<button id="layoutApplyCustom">Apply</button>
</div>
</div>
@@ -1334,6 +1347,88 @@
const verifySummary = document.getElementById('verifySummary');
const verifyCertInfoEl = document.getElementById('verifyCertInfo');
const verifyFileList = document.getElementById('verifyFileList');
const releaseAction = document.getElementById('releaseAction');
const releaseStatus = document.getElementById('releaseStatus');
function setReleaseStatus(message, isError) {
releaseStatus.textContent = message || '';
releaseStatus.classList.toggle('error', !!isError);
}
async function detectBrowserTarget() {
const userAgent = navigator.userAgent || '';
const platformValue = navigator.userAgentData?.platform || navigator.platform || userAgent;
const platformText = platformValue.toLowerCase();
const platform = /win/.test(platformText) ? 'win32' : (/mac/.test(platformText) ? 'darwin' : 'linux');
let architecture = /arm64|aarch64/.test(userAgent.toLowerCase()) ? 'arm64' : 'x64';
if (navigator.userAgentData?.getHighEntropyValues) {
try {
const values = await navigator.userAgentData.getHighEntropyValues(['architecture', 'bitness']);
if (/arm/.test(values.architecture || '')) architecture = 'arm64';
else if (values.architecture) architecture = 'x64';
} catch { /* use the conservative user-agent fallback */ }
}
return { platform, architecture };
}
async function initializeReleaseControl() {
if (window.webavpNative?.checkForUpdates) {
releaseAction.textContent = 'Check for Updates';
releaseAction.removeAttribute('target');
let pendingAsset = null;
let fallbackPage = WebAVPUtils.RELEASES_URL;
releaseAction.addEventListener('click', async (event) => {
event.preventDefault();
if (pendingAsset) {
const result = await window.webavpNative.downloadUpdate(pendingAsset.url);
setReleaseStatus(result.opened
? 'Download opened in your browser. Close the app before installing.'
: (result.error || 'Could not open the update download.'), !result.opened);
return;
}
if (releaseAction.dataset.mode === 'releases') {
window.open(fallbackPage, '_blank', 'noopener,noreferrer');
return;
}
releaseAction.textContent = 'Checking...';
setReleaseStatus('Querying public GitPeji releases...', false);
const result = await window.webavpNative.checkForUpdates();
fallbackPage = result.pageUrl || fallbackPage;
if (result.status === 'available') {
pendingAsset = result.asset;
releaseAction.textContent = `Download v${result.releaseVersion}`;
setReleaseStatus(`Update available (installed: v${result.currentVersion}).`, false);
} else if (result.status === 'current') {
releaseAction.textContent = 'Check for Updates';
setReleaseStatus(`You are current (v${result.currentVersion}).`, false);
} else if (result.status === 'unavailable-platform') {
releaseAction.textContent = 'View Releases';
releaseAction.dataset.mode = 'releases';
setReleaseStatus(result.message, true);
} else {
releaseAction.textContent = 'Check for Updates';
setReleaseStatus(result.message || 'Update check failed.', true);
}
});
return;
}
try {
const response = await fetch('/api/latest-release', { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const release = WebAVPUtils.normalizeRelease(await response.json());
const target = await detectBrowserTarget();
const asset = WebAVPUtils.selectReleaseAsset(release, target.platform, target.architecture);
releaseAction.href = asset ? asset.url : release.pageUrl;
setReleaseStatus(asset
? `Latest: v${release.version} for ${target.platform}/${target.architecture}`
: 'No matching installer; opens all GitPeji releases.', !asset);
} catch {
setReleaseStatus('Latest installer unavailable; opens GitPeji releases.', true);
}
}
initializeReleaseControl();
// ─── Activity Log ───
const logPanel = document.getElementById('logPanel');
@@ -1435,8 +1530,8 @@
// Custom layout
document.getElementById('layoutApplyCustom').addEventListener('click', () => {
const cols = Math.max(1, Math.min(6, parseInt(document.getElementById('layoutCols').value) || 2));
const rows = Math.max(1, Math.min(6, parseInt(document.getElementById('layoutRows').value) || 2));
const cols = Math.max(1, Math.min(4, parseInt(document.getElementById('layoutCols').value) || 2));
const rows = Math.max(1, Math.min(4, parseInt(document.getElementById('layoutRows').value) || 2));
document.getElementById('layoutCols').value = cols;
document.getElementById('layoutRows').value = rows;
gridLayoutOverride = { cols, rows };
@@ -1447,11 +1542,12 @@
function applyGridLayout() {
applyCameraVisibility();
if (!gridLayoutOverride) {
// Auto mode — restore cams-N class
cameraGrid.style.gridTemplateColumns = '';
cameraGrid.style.gridTemplateRows = '';
// Auto mode — choose a bounded grid through 4x4 / 16 tiles.
const visibleCount = countVisibleCameras();
cameraGrid.className = `camera-grid cams-${Math.min(visibleCount, 9)}`;
const layout = WebAVPUtils.getAutoGrid(visibleCount);
cameraGrid.className = 'camera-grid';
cameraGrid.style.gridTemplateColumns = `repeat(${layout.cols}, 1fr)`;
cameraGrid.style.gridTemplateRows = `repeat(${layout.rows}, 1fr)`;
} else {
// Override mode
cameraGrid.className = 'camera-grid';
@@ -1475,7 +1571,7 @@
return;
}
const maxSlots = gridLayoutOverride ? gridLayoutOverride.cols * gridLayoutOverride.rows : Infinity;
const maxSlots = gridLayoutOverride ? gridLayoutOverride.cols * gridLayoutOverride.rows : 16;
let visibleIdx = 0;
for (const ch of channels.values()) {
@@ -1546,7 +1642,7 @@
}
function applyCameraVisibility() {
const maxSlots = gridLayoutOverride ? gridLayoutOverride.cols * gridLayoutOverride.rows : Infinity;
const maxSlots = gridLayoutOverride ? gridLayoutOverride.cols * gridLayoutOverride.rows : 16;
let visibleIdx = 0;
for (const ch of channels.values()) {
if (!ch.cellEl) continue;
@@ -1634,6 +1730,8 @@
let batchingSegments = false;
// Track which camera is expanded (null = none)
let expandedChannel = null;
// Timeline segment highlighting only needs a DOM scan after a transition or rebuild.
let activeSegmentsDirty = true;
// ─── Timeline Zoom State ───
// viewStart/viewEnd define the visible window in seconds (offset from globalStart)
@@ -1768,12 +1866,11 @@
cameraGrid.style.gridTemplateRows = `repeat(${gridLayoutOverride.rows}, 1fr)`;
} else {
const visibleCount = countVisibleCameras();
cameraGrid.className = `camera-grid cams-${Math.min(visibleCount, 9)}`;
cameraGrid.style.gridTemplateColumns = '';
cameraGrid.style.gridTemplateRows = '';
const layout = WebAVPUtils.getAutoGrid(visibleCount);
cameraGrid.className = 'camera-grid';
cameraGrid.style.gridTemplateColumns = `repeat(${layout.cols}, 1fr)`;
cameraGrid.style.gridTemplateRows = `repeat(${layout.rows}, 1fr)`;
}
applyCameraVisibility();
let idx = 0;
for (const ch of channels.values()) {
if (!ch.cellEl) {
@@ -1803,6 +1900,10 @@
}
idx++;
}
// New channels receive their cell nodes in the loop above. Apply the slot
// cap afterwards so first imports and later additions honour the selected
// layout (and auto mode never exposes more than 16 tiles).
applyCameraVisibility();
}
function createCamCell(ch) {
@@ -2038,6 +2139,8 @@
row++;
}
activeSegmentsDirty = true;
// Update minimap segments
renderMinimap(visibleChannels);
updateTimelineLabels();
@@ -2104,13 +2207,14 @@
if (ch.cellEl) {
applyZoom(ch.cellEl);
}
}
if (ch._segInfoEl) {
const total = ch.segments.length;
ch._segInfoEl.textContent = newIdx >= 0
? `Clip ${newIdx + 1}/${total}`
: `${total} clips`;
if (ch._segInfoEl) {
const total = ch.segments.length;
ch._segInfoEl.textContent = newIdx >= 0
? `Clip ${newIdx + 1}/${total}`
: `${total} clips`;
}
activeSegmentsDirty = true;
}
}
}
@@ -2249,12 +2353,13 @@
}
}
timelineTrack.querySelectorAll('.timeline-segment').forEach(el => {
const chName = el.dataset.channel;
const segIdx = parseInt(el.dataset.segIdx);
const ch = channels.get(chName);
el.classList.toggle('active-segment', ch && ch.activeSegIdx === segIdx);
});
if (activeSegmentsDirty) {
timelineTrack.querySelectorAll('.timeline-segment').forEach(el => {
const ch = channels.get(el.dataset.channel);
el.classList.toggle('active-segment', ch && ch.activeSegIdx === parseInt(el.dataset.segIdx));
});
activeSegmentsDirty = false;
}
if (slideshowActive) updateSlideshow();
}
@@ -3131,11 +3236,21 @@
let off = 0;
for (const c of chunks) { out.set(c, off); off += c.length; }
return out;
} catch {
} catch (err) {
avpLog('warn', 'Decompression failed; using raw entry data: ' + (err && err.message ? err.message : err));
return data; // fallback: return as-is
}
}
async function verifyEntryAuth(hmacKey, encData, authCode) {
if (!authCode || authCode.length !== 10) return false;
const key = await crypto.subtle.importKey('raw', hmacKey, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, encData));
let diff = 0;
for (let index = 0; index < 10; index++) diff |= mac[index] ^ authCode[index];
return diff === 0;
}
async function decryptZipEntries(buf, password) {
const entries = parseEncryptedZip(buf);
if (entries.length === 0) throw new Error('No encrypted entries found');
@@ -3155,6 +3270,9 @@
loadingDetail.textContent = `Decrypting ${i + 1}/${entries.length}: ${entry.name.split('/').pop()}`;
const ek = (i === 0) ? keys : await deriveAesKey(password, entry.salt, entry.aesStrength);
if (!(await verifyEntryAuth(ek.hmacKey, entry.encData, entry.authCode))) {
avpLog('warn', `Integrity check failed for "${entry.name.split('/').pop()}" — the file may be corrupt or tampered with.`);
}
let data = aesCtrDecrypt(ek.encKey, entry.encData);
// Decompress if needed