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:
@ -153,6 +153,7 @@
|
||||
}
|
||||
|
||||
.media-overlay .icon-btn {
|
||||
padding: 4px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@ -160,6 +161,32 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@ -464,7 +491,8 @@
|
||||
<div class="modal-content">
|
||||
<button class="modal-close" onclick="ui.closeModal()">×</button>
|
||||
<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;"
|
||||
allow="autoplay; encrypted-media" allowfullscreen></iframe>
|
||||
</div>
|
||||
@ -794,6 +822,11 @@
|
||||
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
|
||||
div.dataset.id = item.id;
|
||||
|
||||
// Processing Class
|
||||
if (item.isProcessing) {
|
||||
div.className += ' processing-card';
|
||||
}
|
||||
|
||||
div.onmouseenter = function () {
|
||||
var v = div.querySelector('video');
|
||||
if (v) v.play();
|
||||
@ -816,6 +849,12 @@
|
||||
if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename);
|
||||
|
||||
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)
|
||||
var contentUrl = item.contentUrl || "";
|
||||
@ -827,6 +866,7 @@
|
||||
div.innerHTML =
|
||||
badge +
|
||||
videoBadgeIcon +
|
||||
centerIcon +
|
||||
'<div class="media-overlay">' +
|
||||
'<button class="icon-btn btn-view" onclick="ui.openPreview(\'' + item.id + '\')" title="View">👁️</button>' +
|
||||
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() {
|
||||
document.getElementById('loading-ui').style.display = 'none';
|
||||
document.getElementById('main-ui').style.display = 'block';
|
||||
ui.logStatus('done', 'Finished loading.', 'success');
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -111,6 +111,8 @@ export class MediaService {
|
||||
// Match Logic (Strict ID Match Only)
|
||||
driveFileStats.forEach(d => {
|
||||
let match = null
|
||||
let isProcessing = false
|
||||
let thumbnail = "";
|
||||
|
||||
// 1. ID Match
|
||||
if (d.shopifyId) {
|
||||
@ -120,22 +122,37 @@ export class MediaService {
|
||||
|
||||
// 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({
|
||||
id: d.file.getId(), // Use Drive ID as primary key
|
||||
driveId: d.file.getId(),
|
||||
shopifyId: match ? match.id : null,
|
||||
filename: d.file.getName(),
|
||||
source: match ? 'synced' : 'drive_only',
|
||||
thumbnail: (match && match.preview && match.preview.image && match.preview.image.originalSrc)
|
||||
? match.preview.image.originalSrc
|
||||
: `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`,
|
||||
thumbnail: thumbnail,
|
||||
status: 'active',
|
||||
galleryOrder: d.galleryOrder,
|
||||
mimeType: d.file.getMimeType(),
|
||||
// Prefer Shopify Video URL for playback/hover if available, otherwise Drive Download URL
|
||||
contentUrl: (match && match.sources)
|
||||
? (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()}`)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user