feat: reconcile WebAVP desktop parity and releases
This commit is contained in:
+146
-28
@@ -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>×</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
|
||||
|
||||
Reference in New Issue
Block a user