diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts index 203ceb3..9cdc541 100644 --- a/client/src/api/authUrl.ts +++ b/client/src/api/authUrl.ts @@ -14,3 +14,45 @@ export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): P return url } } + +// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ──── + +const MAX_CONCURRENT = 6 +let active = 0 +const queue: Array<() => void> = [] + +function dequeue() { + while (active < MAX_CONCURRENT && queue.length > 0) { + active++ + queue.shift()!() + } +} + +export function clearImageQueue() { + queue.length = 0 +} + +export async function fetchImageAsBlob(url: string): Promise { + if (!url) return '' + return new Promise((resolve) => { + const run = async () => { + try { + const resp = await fetch(url, { credentials: 'include' }) + if (!resp.ok) { resolve(''); return } + const blob = await resp.blob() + resolve(URL.createObjectURL(blob)) + } catch { + resolve('') + } finally { + active-- + dequeue() + } + } + if (active < MAX_CONCURRENT) { + active++ + run() + } else { + queue.push(run) + } + }) +} diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 9dd1ed4..13a8bab 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -3,13 +3,18 @@ import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPi import apiClient from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' -import { getAuthUrl } from '../../api/authUrl' +import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl' import { useToast } from '../shared/Toast' function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { const [src, setSrc] = useState('') useEffect(() => { - getAuthUrl(baseUrl, 'immich').then(setSrc) + let revoke = '' + fetchImageAsBlob(baseUrl).then(blobUrl => { + revoke = blobUrl + setSrc(blobUrl) + }) + return () => { if (revoke) URL.revokeObjectURL(revoke) } }, [baseUrl]) return src ? : null } @@ -208,6 +213,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa shared: true, }) setShowPicker(false) + clearImageQueue() loadInitial() } catch { toast.error(t('memories.error.addPhotos')) } } @@ -365,7 +371,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {t('memories.selectPhotos')}
- @@ -634,8 +640,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }} onClick={() => { setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) setLightboxOriginalSrc('') - getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc) + fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc) setLightboxInfoLoading(true) apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) @@ -743,12 +750,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {/* Lightbox */} {lightboxId && lightboxUserId && ( -
{ setLightboxId(null); setLightboxUserId(null) }} +
{ if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }} style={{ position: 'absolute', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center', }}> -