feat(media): implement video processing polling and fallback

This commit adds robust handling for Google Drive videos that are still processing (lacking thumbnails).  Changes include:  1. Backend (MediaService.ts): Implement try/catch around thumbnail generation. If it fails, return a placeholder and flag the item as 'isProcessing'. 2. Frontend (MediaManager.html):     - Add polling logic to check for updates on processing items every 15s.     - Add UI support for processing state: slate background, centered animated hourglass emoji.     - Implement sand animation (toggling hourglass state) and rotation animation (180deg flip on poll event).     - Fix badges and positioning issues.
This commit is contained in:
Ben Miller
2025-12-29 09:12:37 -07:00
parent 7ef5ef2913
commit f6831cdc8f
2 changed files with 670 additions and 514 deletions

View File

@ -153,6 +153,7 @@
} }
.media-overlay .icon-btn { .media-overlay .icon-btn {
padding: 4px;
pointer-events: auto; pointer-events: auto;
} }
@ -160,6 +161,32 @@
opacity: 1; opacity: 1;
} }
/* Processing State */
.media-item.processing-card {
background-color: #334155 !important;
/* Slate-700 */
display: flex;
align-items: center;
justify-content: center;
}
.media-item.processing-card .media-content {
display: none !important;
}
.processing-icon {
font-size: 40px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
transition: transform 0.6s ease-in-out;
}
/* .flipping removed, handled by JS inline style */
.type-badge { .type-badge {
position: absolute; position: absolute;
top: 6px; top: 6px;
@ -464,7 +491,8 @@
<div class="modal-content"> <div class="modal-content">
<button class="modal-close" onclick="ui.closeModal()">×</button> <button class="modal-close" onclick="ui.closeModal()">×</button>
<img id="preview-image" style="max-width:100%; max-height:80vh; border-radius:8px; display:none;"> <img id="preview-image" style="max-width:100%; max-height:80vh; border-radius:8px; display:none;">
<video id="preview-video" controls style="max-width:100%; max-height:80vh; border-radius:8px; display:none;"></video> <video id="preview-video" controls
style="max-width:100%; max-height:80vh; border-radius:8px; display:none;"></video>
<iframe id="preview-iframe" style="width:100%; height:60vh; border:none; border-radius:8px; display:none;" <iframe id="preview-iframe" style="width:100%; height:60vh; border:none; border-radius:8px; display:none;"
allow="autoplay; encrypted-media" allowfullscreen></iframe> allow="autoplay; encrypted-media" allowfullscreen></iframe>
</div> </div>
@ -794,6 +822,11 @@
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : ''); div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
div.dataset.id = item.id; div.dataset.id = item.id;
// Processing Class
if (item.isProcessing) {
div.className += ' processing-card';
}
div.onmouseenter = function () { div.onmouseenter = function () {
var v = div.querySelector('video'); var v = div.querySelector('video');
if (v) v.play(); if (v) v.play();
@ -816,6 +849,12 @@
if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename); if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename);
var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">🎞️</div>' : ''; var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">🎞️</div>' : '';
// Processing Badge REMOVED (Handled by center icon now)
var centerIcon = '';
if (item.isProcessing) {
centerIcon = '<div class="processing-icon">⏳</div>';
}
// content URL logic (Only relevant for Shopify where we have a direct public link) // content URL logic (Only relevant for Shopify where we have a direct public link)
var contentUrl = item.contentUrl || ""; var contentUrl = item.contentUrl || "";
@ -827,6 +866,7 @@
div.innerHTML = div.innerHTML =
badge + badge +
videoBadgeIcon + videoBadgeIcon +
centerIcon +
'<div class="media-overlay">' + '<div class="media-overlay">' +
'<button class="icon-btn btn-view" onclick="ui.openPreview(\'' + item.id + '\')" title="View">👁️</button>' + '<button class="icon-btn btn-view" onclick="ui.openPreview(\'' + item.id + '\')" title="View">👁️</button>' +
actionBtn + actionBtn +
@ -1344,11 +1384,110 @@
} }
}, },
// Sand Animation (Global Loop)
sandInterval: null,
startSandAnimation() {
if (this.sandInterval) return;
this.sandInterval = setInterval(() => {
const icons = document.querySelectorAll('.processing-icon');
icons.forEach(icon => {
// Only swap if NOT currently flipping to avoid visual glitch
if (!icon.classList.contains('flipping')) {
icon.innerText = icon.innerText === '⏳' ? '⌛' : '⏳';
}
});
}, 1000);
},
showGallery() { showGallery() {
document.getElementById('loading-ui').style.display = 'none'; document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block'; document.getElementById('main-ui').style.display = 'block';
ui.logStatus('done', 'Finished loading.', 'success'); ui.logStatus('done', 'Finished loading.', 'success');
setTimeout(function () { ui.toggleLog(false); }, 1000); setTimeout(function () { ui.toggleLog(false); }, 1000);
// Start Polling for Processing Items
this.pollProcessingItems();
},
pollInterval: null,
pollProcessingItems() {
if (this.pollInterval) clearInterval(this.pollInterval);
const hasProcessing = state.items.some(function (i) { return i.isProcessing; });
if (!hasProcessing) return;
console.log("[MediaManager] Items are processing. Starting poll...");
// Ensure sand animation is running
this.startSandAnimation();
var _this = this;
this.pollInterval = setInterval(function () {
var processingItems = state.items.filter(function (i) { return i.isProcessing; });
if (processingItems.length === 0) {
clearInterval(_this.pollInterval);
return;
}
// Visual Trigger: Rotate 180deg CW (Cumulative)
const icons = document.querySelectorAll('.processing-icon');
icons.forEach(el => {
let currentRot = parseInt(el.dataset.rotation || '0');
currentRot += 180;
el.style.transform = 'rotate(' + currentRot + 'deg)';
el.dataset.rotation = currentRot;
});
// No timeout needed, we stay at new rotation
// Poll backend silently
google.script.run
.withSuccessHandler(function (items) {
// Update items relative to current state
// We only want to update the 'isProcessing' status and thumbnail of existing items
// to avoid jarring re-renders or losing unsaved reordering.
let changed = false;
items.forEach(function (newItem) {
// Find existing
var idx = state.items.findIndex(function (cur) { return cur.id === newItem.id || (newItem.source === 'drive_only' && cur.driveId === newItem.id); });
// Note: backend 'id' is driveId for drive items.
if (idx !== -1) {
var item = state.items[idx];
if (item.isProcessing) {
// Check if it's done now
// The backend logic for 'isProcessing' in getUnifiedMediaState checks if getThumbnail fails.
// If it succeeds now, isProcessing will be false (undefined/false).
// Update our local item
// CAUTION: The normalized structure in loadMedia sets defaults.
// We need to match that.
const stillProcessing = newItem.isProcessing === true;
if (!stillProcessing) {
console.log("[MediaManager] Processing complete for " + item.filename);
item.isProcessing = false;
item.thumbnail = newItem.thumbnail;
changed = true;
}
}
}
});
if (changed) {
ui.render(state.items);
// If none left, stop
if (!state.items.some(function (i) { return i.isProcessing; })) {
clearInterval(_this.pollInterval);
console.log("[MediaManager] All processing complete. Stopping poll.");
}
}
})
.getMediaForSku(state.sku);
}, 15000); // 15 seconds
} }
}; };

View File

@ -111,6 +111,8 @@ export class MediaService {
// Match Logic (Strict ID Match Only) // Match Logic (Strict ID Match Only)
driveFileStats.forEach(d => { driveFileStats.forEach(d => {
let match = null let match = null
let isProcessing = false
let thumbnail = "";
// 1. ID Match // 1. ID Match
if (d.shopifyId) { if (d.shopifyId) {
@ -120,22 +122,37 @@ export class MediaService {
// NO Filename Fallback matching per new design "Strict Linkage" // NO Filename Fallback matching per new design "Strict Linkage"
if (match && match.preview && match.preview.image && match.preview.image.originalSrc) {
thumbnail = match.preview.image.originalSrc;
} else {
try {
// Try to get Drive thumbnail
thumbnail = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
} catch (e) {
console.warn(`Failed to get thumbnail for ${d.file.getName()} (likely processing): ${e}`);
// Return a generic placeholder (Gray 1x1 pixel) + Flag as processing
// thumbnail = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
// Better placeholder: https://ssl.gstatic.com/docs/doclist/images/icon_10_movie_list.png (Video Icon) or just gray
thumbnail = "https://ssl.gstatic.com/docs/doclist/images/icon_128_video_blue.png"; // Official Video Icon
isProcessing = true;
}
}
unifiedState.push({ unifiedState.push({
id: d.file.getId(), // Use Drive ID as primary key id: d.file.getId(), // Use Drive ID as primary key
driveId: d.file.getId(), driveId: d.file.getId(),
shopifyId: match ? match.id : null, shopifyId: match ? match.id : null,
filename: d.file.getName(), filename: d.file.getName(),
source: match ? 'synced' : 'drive_only', source: match ? 'synced' : 'drive_only',
thumbnail: (match && match.preview && match.preview.image && match.preview.image.originalSrc) thumbnail: thumbnail,
? match.preview.image.originalSrc
: `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`,
status: 'active', status: 'active',
galleryOrder: d.galleryOrder, galleryOrder: d.galleryOrder,
mimeType: d.file.getMimeType(), mimeType: d.file.getMimeType(),
// Prefer Shopify Video URL for playback/hover if available, otherwise Drive Download URL // Prefer Shopify Video URL for playback/hover if available, otherwise Drive Download URL
contentUrl: (match && match.sources) contentUrl: (match && match.sources)
? (match.sources.find((s: any) => s.mimeType === 'video/mp4')?.url || match.sources[0]?.url) ? (match.sources.find((s: any) => s.mimeType === 'video/mp4')?.url || match.sources[0]?.url)
: `https://drive.google.com/uc?export=download&id=${d.file.getId()}` : `https://drive.google.com/uc?export=download&id=${d.file.getId()}`,
isProcessing: isProcessing
}) })
// console.log(`[MediaService] File ${d.file.getName()} (${d.file.getId()}): Mime=${d.file.getMimeType()}, ContentUrl=https://drive.google.com/uc?export=download&id=${d.file.getId()}`) // console.log(`[MediaService] File ${d.file.getName()} (${d.file.getId()}): Mime=${d.file.getMimeType()}, ContentUrl=https://drive.google.com/uc?export=download&id=${d.file.getId()}`)
}) })