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 {
|
.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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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()}`)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user