diff --git a/express/code/blocks/frictionless-quick-action-mobile/frictionless-quick-action-mobile.js b/express/code/blocks/frictionless-quick-action-mobile/frictionless-quick-action-mobile.js index e9e7d4be7..e62e648f6 100644 --- a/express/code/blocks/frictionless-quick-action-mobile/frictionless-quick-action-mobile.js +++ b/express/code/blocks/frictionless-quick-action-mobile/frictionless-quick-action-mobile.js @@ -19,6 +19,7 @@ import { processFilesForQuickAction, loadAndInitializeCCEverywhere, getErrorMsg, + shouldShowVideoQuickActionPickerForMobile, } from '../../scripts/utils/frictionless-utils.js'; let replaceKey; let getConfig; @@ -283,11 +284,22 @@ export default async function decorate(block) { accept: QA_CONFIGS[quickAction].accept, ...(quickAction === 'merge-videos' && { multiple: true }), }); - inputElement.onchange = () => { - if (quickAction === 'merge-videos' && inputElement.files.length > 1) { + inputElement.onchange = async () => { + const file = inputElement.files[0]; + if (shouldShowVideoQuickActionPickerForMobile(quickAction, file)) { + if (!file) return; + inputElement.value = ''; + try { + const { default: showVideoQuickActionPicker } = await import( + '../video-quick-action-picker/video-quick-action-picker.js' + ); + await showVideoQuickActionPicker(file, block, { startSDKWithUnconvertedFiles }); + } catch (e) { + showErrorToast(block, await replaceKey('upload-media-error', getConfig())); + } + } else if (quickAction === 'merge-videos' && inputElement.files.length > 1) { startSDKWithUnconvertedFiles(inputElement.files, quickAction, block); } else { - const file = inputElement.files[0]; startSDKWithUnconvertedFiles([file], quickAction, block); } }; diff --git a/express/code/blocks/video-quick-action-picker/video-quick-action-picker-config.js b/express/code/blocks/video-quick-action-picker/video-quick-action-picker-config.js new file mode 100644 index 000000000..80d4f10e1 --- /dev/null +++ b/express/code/blocks/video-quick-action-picker/video-quick-action-picker-config.js @@ -0,0 +1,110 @@ +import { getLibs } from '../../scripts/utils.js'; +import { QA_CONFIGS } from '../../scripts/utils/frictionless-utils.js'; + +const ICONS_BASE = '/express/code/icons'; + +export const ACTION_TYPES = { + QUICK_ACTION: 'quick-action', // launches SDK inline with the uploaded file + APP_INSTALL: 'app-install', // navigates to app install URL (same tab) +}; + +// Max duration in seconds per quick action (source: Adobe Express help docs) +const MAX_DURATION = { + 'convert-to-gif': 60, // 1 minute + 'caption-video': 300, // 5 minutes +}; + +let replaceKey; +let getConfig; + +export async function loadPlaceholders() { + const [utils, placeholders] = await Promise.all([ + import(`${getLibs()}/utils/utils.js`), + import(`${getLibs()}/features/placeholders.js`), + ]); + ({ getConfig } = utils); + ({ replaceKey } = placeholders); +} + +async function resolveKey(key, fallback) { + const resolved = await replaceKey(key, getConfig()); + if (resolved === key.replaceAll('-', ' ')) return fallback; + return resolved; +} + +export async function getLocalizedStrings() { + return { + editVideo: await resolveKey('edit-video', 'Edit video'), + appOnly: await resolveKey('ios-app-only', 'iOS App only'), + convertVideoToGif: await resolveKey('convert-video-to-gif', 'Convert video to GIF'), + cropVideo: await resolveKey('crop-video', 'Crop video'), + trimVideo: await resolveKey('trim-video', 'Trim video'), + resizeVideo: await resolveKey('resize-video', 'Resize video'), + convertVideoToMp4: await resolveKey('convert-video-to-mp4', 'Convert video to MP4'), + captionVideo: await resolveKey('caption-video', 'Caption video'), + openingPreview: await resolveKey('opening-preview', 'Opening preview'), + uploadedVideo: await resolveKey('uploaded-video', 'Uploaded video'), + startFromYourVideo: await resolveKey('start-from-your-video', 'Start from your video'), + closeDialog: await resolveKey('close-dialog', 'Close dialog'), + }; +} + +export function getVideoActions(strings, videoFile, videoDuration) { + const actions = [ + { + id: 'edit-video', + label: strings.editVideo, + badge: strings.appOnly, + type: ACTION_TYPES.APP_INSTALL, + iconPath: `${ICONS_BASE}/edit-video.svg`, + }, + { + id: 'convert-to-gif', + label: strings.convertVideoToGif, + type: ACTION_TYPES.QUICK_ACTION, + iconPath: `${ICONS_BASE}/ax-convert-to-gif-22.svg`, + }, + { + id: 'crop-video', + label: strings.cropVideo, + type: ACTION_TYPES.QUICK_ACTION, + iconPath: `${ICONS_BASE}/vqa-crop-video.svg`, + }, + { + id: 'trim-video', + label: strings.trimVideo, + type: ACTION_TYPES.QUICK_ACTION, + iconPath: `${ICONS_BASE}/vqa-trim-video.svg`, + }, + { + id: 'resize-video', + label: strings.resizeVideo, + type: ACTION_TYPES.QUICK_ACTION, + iconPath: `${ICONS_BASE}/vqa-resize-video.svg`, + }, + { + id: 'convert-to-mp4', + label: strings.convertVideoToMp4, + type: ACTION_TYPES.QUICK_ACTION, + iconPath: `${ICONS_BASE}/convert-to-mp4.svg`, + }, + { + id: 'caption-video', + label: strings.captionVideo, + type: ACTION_TYPES.QUICK_ACTION, + iconPath: `${ICONS_BASE}/ax-caption-video-22.svg`, + }, + ]; + + return actions.filter((action) => { + // No point offering convert-to-mp4 if the file is already an MP4 + if (action.id === 'convert-to-mp4' && videoFile.type === 'video/mp4') return false; + const config = QA_CONFIGS[action.id]; + if (!config) return false; + if (!config.input_check(videoFile.type)) return false; + if (videoFile.size > config.max_size) return false; + const maxDuration = MAX_DURATION[action.id]; + if (maxDuration && videoDuration > maxDuration) return false; + return true; + }); +} diff --git a/express/code/blocks/video-quick-action-picker/video-quick-action-picker.css b/express/code/blocks/video-quick-action-picker/video-quick-action-picker.css new file mode 100644 index 000000000..284201025 --- /dev/null +++ b/express/code/blocks/video-quick-action-picker/video-quick-action-picker.css @@ -0,0 +1,212 @@ +/* --- Fullscreen Dialog --- */ +.vqap-dialog { + position: fixed; + inset: 0; + --vqap-mobile-header-bar-height: 53px; + --vqap-hero-inline-mobile-height: 282px; + background: var(--body-background-color); + display: flex; + flex-direction: column; + z-index: 9999; + overflow: hidden; +} + +/* --- Close Button --- */ +.vqap-close-btn { + position: absolute; + inset-block-start: calc(var(--spacing-75) + env(safe-area-inset-top, 0px)); + inset-inline-end: var(--spacing-300); + width: 50px; + height: 50px; + border-radius: 100%; + border: 0; + background: transparent; + color: var(--color-white); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1; +} + +/* --- Hero Container --- */ +.vqap-hero { + background: var(--color-gray-100); + height: var(--vqap-hero-inline-mobile-height); + width: 100%; + flex-shrink: 0; + margin-block-end: var(--spacing-400); +} + +/* --- Header Bar --- */ + +.vqap-header-bar { + height: var(--vqap-mobile-header-bar-height); + background-color: var(--color-gray-800-variant); + flex-shrink: 0; +} + +/* --- Preview Area --- */ +.vqap-preview-container { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + background-color: var(--color-gray-100); + box-sizing: border-box; + height: calc(100% - var(--vqap-mobile-header-bar-height)); + width: 100%; + padding: var(--spacing-300); + display: flex; + align-items: center; + justify-content: center; +} + +.vqap-preview-container video { + width: 100%; + height: auto; + max-height: 100%; + max-width: 100%; + display: block; + object-fit: contain; + box-sizing: border-box; +} + +/* --- Loading Spinner --- */ +.vqap-loading { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.vqap-spinner { + width: 48px; + height: 48px; + border: 4px solid var(--color-light-gray); + border-top-color: var(--color-gray-800-variant); + border-radius: 50%; + animation: vqap-spin 0.8s linear infinite; +} + +@keyframes vqap-spin { + to { transform: rotate(360deg); } +} + +/* --- Error State --- */ +.vqap-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-100); + width: 100%; + height: 100%; + color: var(--color-default-font); + font-size: var(--ax-body-xs-size, 14px); +} + +/* --- Scrollable Body & Content Container --- */ +.vqap-body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} + +.vqap-content-container { + padding-inline: var(--spacing-300); + flex: 1 1 auto; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + display: flex; + flex-direction: column; + gap: var(--spacing-400); + padding-block: var(--spacing-50); + padding-bottom: calc(var(--spacing-50) + env(safe-area-inset-bottom, 0px)); +} + +.vqap-content-container > .vqap-action-card:first-child { + margin-block-start: var(--spacing-75); +} + +.vqap-content-container > .vqap-action-card:last-child { + margin-block-end: var(--spacing-300); +} + +/* --- Action Card --- */ +.vqap-action-card { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + display: block; + width: 100%; + border: var(--border-width-2) solid var(--color-light-gray); + border-radius: var(--border-radius-10); + padding: var(--spacing-400) var(--spacing-300); + cursor: pointer; + background: var(--body-background-color); + box-sizing: border-box; + touch-action: manipulation; +} + +.vqap-action-card:hover, +.vqap-action-card:active { + border-color: var(--color-gray-250); +} + +/* --- Card Inner (mirrors .action-title) --- */ +.vqap-action-title { + align-items: center; + display: flex; + gap: var(--spacing-200); + padding-block: var(--spacing-75); + font-family: var(--body-font-family); + font-size: var(--ax-heading-xs-size, 16px); + line-height: var(--ax-heading-xs-lh, 20px); +} + +.vqap-action-title span { + align-items: center; + display: flex; + gap: var(--spacing-100); + flex-wrap: wrap-reverse; +} + +.vqap-action-title strong { + font-weight: var(--ax-heading-weight, 700); + color: var(--heading-color, var(--color-default-font)); +} + +.vqap-card-icon { + flex-shrink: 0; +} + +.vqap-card-icon img { + width: 28px; + height: 28px; +} + +.vqap-close-btn img { + width: 18px; + height: 18px; +} + +/* --- Badge --- */ +.vqap-badge { + display: flex; + align-items: center; + min-height: 26px; + border: 1px solid transparent; + border-radius: 4px; + background: rgb(229, 240, 254); + flex-shrink: 0; +} + +.vqap-badge strong { + font-size: var(--ax-body-xs-size); + line-height: 1.3; + color: var(--color-black); + padding-block: var(--spacing-50); + padding-inline: var(--spacing-80); +} diff --git a/express/code/blocks/video-quick-action-picker/video-quick-action-picker.js b/express/code/blocks/video-quick-action-picker/video-quick-action-picker.js new file mode 100644 index 000000000..88a84ed2b --- /dev/null +++ b/express/code/blocks/video-quick-action-picker/video-quick-action-picker.js @@ -0,0 +1,219 @@ +import { createTag } from '../../scripts/utils.js'; +import { createMetadataMap } from '../../scripts/utils/mobile-fork-button-utils.js'; +import { + ACTION_TYPES, + loadPlaceholders, + getLocalizedStrings, + getVideoActions, +} from './video-quick-action-picker-config.js'; + +const STYLES_URL = '/express/code/blocks/video-quick-action-picker/video-quick-action-picker.css'; + +function loadStyles() { + if (document.querySelector(`link[href="${STYLES_URL}"]`)) return; + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = STYLES_URL; + document.head.append(link); +} + +const CLOSE_ICON_PATH = '/express/code/icons/close-white.svg'; + +function getAppInstallLink() { + const metadataMap = createMetadataMap(); + return metadataMap['fork-cta-1-link'] || metadataMap['fork-cta-1-link-frictionless'] || ''; +} + +function lockBodyScroll() { + document.body.style.overflow = 'hidden'; + document.body.style.position = 'fixed'; + document.body.style.width = '100%'; + document.body.dataset.vqapScrollY = window.scrollY; + document.body.style.top = `-${window.scrollY}px`; +} + +function unlockBodyScroll() { + document.body.style.overflow = ''; + document.body.style.position = ''; + document.body.style.width = ''; + document.body.style.top = ''; + const scrollY = document.body.dataset.vqapScrollY; + if (scrollY) window.scrollTo(0, parseInt(scrollY, 10)); + delete document.body.dataset.vqapScrollY; +} + +function createVideoPreview(blobUrl, strings) { + const previewContainer = createTag('div', { class: 'vqap-preview-container' }); + + const loading = createTag('div', { + class: 'vqap-loading', + role: 'status', + 'aria-label': strings.openingPreview, + }); + loading.append(createTag('div', { class: 'vqap-spinner' })); + previewContainer.append(loading); + + const video = createTag('video', { + autoplay: true, + muted: true, + playsinline: '', + 'aria-label': strings.uploadedVideo, + }); + video.src = blobUrl; + video.style.display = 'none'; + + // Resolve duration from the same video element to avoid a separate load + const durationPromise = new Promise((resolve) => { + video.addEventListener('loadedmetadata', () => resolve(video.duration), { once: true }); + video.addEventListener('error', () => resolve(0), { once: true }); + }); + + video.addEventListener('loadeddata', () => { + loading.remove(); + video.style.display = 'block'; + }, { once: true }); + + video.addEventListener('error', () => { + loading.remove(); + video.remove(); + }, { once: true }); + + previewContainer.append(video); + return { previewContainer, durationPromise }; +} + +function buildActionCard(action) { + const card = createTag('button', { + class: 'vqap-action-card', + 'aria-label': action.label, + }); + + const actionTitle = createTag('div', { class: 'vqap-action-title' }); + + const iconWrapper = createTag('span', { class: 'vqap-card-icon' }); + if (action.iconPath) { + const icon = createTag('img', { + src: action.iconPath, + alt: '', + 'aria-hidden': 'true', + width: '28', + height: '28', + }); + iconWrapper.append(icon); + } + + const textSpan = createTag('span'); + const title = createTag('strong', { 'aria-hidden': 'true' }); + title.textContent = action.label; + textSpan.append(title); + + if (action.badge) { + const badge = createTag('span', { class: 'vqap-badge' }); + const badgeText = createTag('strong'); + badgeText.textContent = action.badge; + badge.append(badgeText); + textSpan.append(badge); + } + + actionTitle.append(iconWrapper, textSpan); + card.append(actionTitle); + return card; +} + +/** + * @param {File} videoFile - the selected video blob + * @param {HTMLElement} block - the frictionless-quick-action-mobile block + * @param {Object} sdkHandlers - { startSDKWithUnconvertedFiles } + */ +export default async function showVideoQuickActionPicker(videoFile, block, sdkHandlers) { + loadStyles(); + await loadPlaceholders(); + const strings = await getLocalizedStrings(); + const { startSDKWithUnconvertedFiles } = sdkHandlers; + const blobUrl = URL.createObjectURL(videoFile); + let dialog; + let handleKeydown; + + try { + const parsedBlobUrl = new URL(blobUrl, window.location.href); + if (parsedBlobUrl.protocol !== 'blob:') { + throw new Error('Invalid video preview URL'); + } + + dialog = createTag('div', { + class: 'vqap-dialog', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': strings.startFromYourVideo, + }); + + const hero = createTag('div', { class: 'vqap-hero' }); + const headerBar = createTag('div', { class: 'vqap-header-bar' }); + + const closeBtn = createTag('button', { + class: 'vqap-close-btn', + 'aria-label': strings.closeDialog, + }); + const closeIcon = createTag('img', { + src: CLOSE_ICON_PATH, + alt: '', + 'aria-hidden': 'true', + width: '18', + height: '18', + }); + closeBtn.append(closeIcon); + + // The preview URL is browser-generated from the selected file and restricted to blob:. + const { previewContainer, durationPromise } = createVideoPreview(parsedBlobUrl.href, strings); + hero.append(headerBar, previewContainer); + + const body = createTag('div', { class: 'vqap-body' }); + const contentContainer = createTag('div', { class: 'vqap-content-container' }); + + const closeDialog = () => { + document.removeEventListener('keydown', handleKeydown); + unlockBodyScroll(); + URL.revokeObjectURL(blobUrl); + dialog.remove(); + }; + + handleKeydown = (e) => { + if (e.key === 'Escape') { + closeDialog(); + } + }; + + closeBtn.addEventListener('click', closeDialog); + document.addEventListener('keydown', handleKeydown); + + dialog.append(closeBtn, hero, body); + document.body.append(dialog); + lockBodyScroll(); + closeBtn.focus(); + + const videoDuration = await durationPromise; + const videoActions = getVideoActions(strings, videoFile, videoDuration); + videoActions.forEach((action) => { + const card = buildActionCard(action); + card.addEventListener('click', () => { + closeDialog(); + if (action.type === ACTION_TYPES.APP_INSTALL) { + const appLink = getAppInstallLink(); + if (appLink) window.location.href = appLink; + } else { + startSDKWithUnconvertedFiles([videoFile], action.id, block); + } + }); + contentContainer.append(card); + }); + + body.append(contentContainer); + } catch (error) { + document.removeEventListener('keydown', handleKeydown); + if (dialog?.isConnected) { + dialog.remove(); + } + URL.revokeObjectURL(blobUrl); + throw error; + } +} diff --git a/express/code/icons/convert-to-mp4.svg b/express/code/icons/convert-to-mp4.svg new file mode 100644 index 000000000..e998247a7 --- /dev/null +++ b/express/code/icons/convert-to-mp4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/express/code/icons/edit-video.svg b/express/code/icons/edit-video.svg new file mode 100644 index 000000000..c58847022 --- /dev/null +++ b/express/code/icons/edit-video.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/express/code/icons/vqa-crop-video.svg b/express/code/icons/vqa-crop-video.svg new file mode 100644 index 000000000..ff4e89b41 --- /dev/null +++ b/express/code/icons/vqa-crop-video.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/express/code/icons/vqa-resize-video.svg b/express/code/icons/vqa-resize-video.svg new file mode 100644 index 000000000..458ab3bdc --- /dev/null +++ b/express/code/icons/vqa-resize-video.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/express/code/icons/vqa-trim-video.svg b/express/code/icons/vqa-trim-video.svg new file mode 100644 index 000000000..036b4eef4 --- /dev/null +++ b/express/code/icons/vqa-trim-video.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/express/code/scripts/utils.js b/express/code/scripts/utils.js index 1782898c8..f27c3762e 100644 --- a/express/code/scripts/utils.js +++ b/express/code/scripts/utils.js @@ -421,12 +421,24 @@ export function hideQuickActionsOnDevices(userAgent) { } // latest setup that supports safari frictionless, enabled by metadata // fqa-non-qualified: always removed. (before: safari) - // fqa-qualified-mobile: mobile only. (before: non-safari mobile) + // fqa-qualified-ios: iOS mobile only. + // fqa-qualified-android: Android mobile only. + // fqa-qualified-mobile: mobile only. (before: non-safari mobile) — kept for backwards compat // fqa-qualified-desktop: desktop only. (before: non-safari desktop) const audienceFqaMeta = document.createElement('meta'); audienceFqaMeta.setAttribute('content', 'on'); if (getMetadata('frictionless-safari')?.toLowerCase() === 'on' || isQualifiedBrowser) { - audienceFqaMeta.setAttribute('name', `fqa-qualified-${isMobile ? 'mobile' : 'desktop'}`); + if (isMobile) { + const os = getMobileOperatingSystem(); + audienceFqaMeta.setAttribute('name', os === 'Android' ? 'fqa-qualified-android' : 'fqa-qualified-ios'); + // also inject fqa-qualified-mobile so sections targeting both iOS + Android still show + const mobileFqaMeta = document.createElement('meta'); + mobileFqaMeta.setAttribute('name', 'fqa-qualified-mobile'); + mobileFqaMeta.setAttribute('content', 'on'); + document.head.append(mobileFqaMeta); + } else { + audienceFqaMeta.setAttribute('name', 'fqa-qualified-desktop'); + } } else { audienceFqaMeta.setAttribute('name', 'fqa-non-qualified'); } @@ -454,7 +466,7 @@ export function preDecorateSections(area) { || urlParams.get(`${sectionMeta.showwith}`); } const showwith = sectionMeta.showwith.toLowerCase(); - if (['fqa-off', 'fqa-on', 'fqa-non-qualified', 'fqa-qualified-mobile', 'fqa-qualified-desktop'].includes(showwith)) hideQuickActionsOnDevices(navigator.userAgent); + if (['fqa-off', 'fqa-on', 'fqa-non-qualified', 'fqa-qualified-mobile', 'fqa-qualified-desktop', 'fqa-qualified-ios', 'fqa-qualified-android'].includes(showwith)) hideQuickActionsOnDevices(navigator.userAgent); sectionRemove = showWithSearchParam !== null ? showWithSearchParam !== 'on' : getMetadata(showwith) !== 'on'; } if (sectionRemove) section.remove(); diff --git a/express/code/scripts/utils/frictionless-utils.js b/express/code/scripts/utils/frictionless-utils.js index c1f329d1b..daf071167 100644 --- a/express/code/scripts/utils/frictionless-utils.js +++ b/express/code/scripts/utils/frictionless-utils.js @@ -1,4 +1,4 @@ -import { getLibs } from '../utils.js'; +import { getLibs, getMobileOperatingSystem } from '../utils.js'; // Shared constants and configurations for frictionless quick actions const JPG = 'jpg'; @@ -161,6 +161,12 @@ export const AUTH_FRICTIONLESS_UPLOAD_QUICK_ACTIONS = { removeBackground: 'remove-background', }; +export function shouldShowVideoQuickActionPickerForMobile(quickAction, file) { + return quickAction === FRICTIONLESS_UPLOAD_QUICK_ACTIONS.videoEditor + && getMobileOperatingSystem() === 'iOS' + && file?.type?.startsWith('video/'); +} + // Route paths map corresponding to the express routes export const EXPRESS_ROUTE_PATHS = { loggedOutEditor: '/new',