diff --git a/src/components/LibraryPickerModal.tsx b/src/components/LibraryPickerModal.tsx new file mode 100644 index 0000000..7812f83 --- /dev/null +++ b/src/components/LibraryPickerModal.tsx @@ -0,0 +1,764 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + View, + Text, + Modal, + TouchableOpacity, + FlatList, + Image, + Animated, + PanResponder, + ActivityIndicator, + useWindowDimensions, +} from 'react-native'; +import { X, Check } from 'lucide-react-native'; +import * as MediaLibrary from 'expo-media-library/legacy'; +import ZoomableImage from './ZoomableImage'; +import { PickedPhotoAsset } from '../utils/photoGps'; + +// In-app library picker. Tapping a thumbnail opens it full screen: pinch and +// double-tap zoom, swipe left/right through the library, then USE or CANCEL. +// It replaces the system picker on Android 13, whose grid hands the photo back +// on the first tap — no preview, so nothing to look at before choosing. +// +// ponytail: newest PAGE photos, no album row / search / multi-select; add the +// album buckets here when a large library makes the first page too shallow. +const PAGE = 60; +const COLS = 3; +// Date scrubber on the right edge of the grid: drag it down to walk back in +// time. Pages are pulled in on demand, so a jump can load a few pages first. +const SCRUB_W = 34; +const THUMB_H = 44; +const TRACK_PAD = 8; + +interface LibraryPickerModalProps { + visible: boolean; + onCancel: () => void; + onSelect: (asset: PickedPhotoAsset) => void; +} + +export default function LibraryPickerModal({ + visible, + onCancel, + onSelect, +}: LibraryPickerModalProps) { + const { width } = useWindowDimensions(); + const [assets, setAssets] = useState([]); + const [cursor, setCursor] = useState(undefined); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // null = grid, otherwise the index of the photo shown full screen. + const [preview, setPreview] = useState(null); + const [busy, setBusy] = useState(false); + // Creation time of the oldest photo on the device: the far end of the + // scrubber. Fetched once, so the track does not shift while pages load. + const [oldest, setOldest] = useState(null); + // The newest photo on the device, and the width of one preview page. A jump + // replaces the list with a window around the target date, so the bar has to + // map its travel onto the whole library rather than onto whatever the grid + // happens to hold, and the preview pages are sized by the strip's own layout, + // never by the window: a mismatch between the two parked the preview between + // two photos. + const [newest, setNewest] = useState(null); + const [pageW, setPageW] = useState(width); + // ponytail: the thumb is owned by the drag (time based), never by the grid + // scroll - syncing it to the list fought with the pages a jump pulls in and + // sent the thumb back to the top. Wire onScroll back in if the grid itself + // ever needs to move the thumb. + const [frac, setFrac] = useState(0); + const [trackH, setTrackH] = useState(0); + const [scrubbing, setScrubbing] = useState(false); + // The preview pager lays its three slots out around `anchor`, which only moves + // once a settle animation has finished: rebasing them on the new photo mid + // flight is what would flash the photo the strip just left. + const [anchor, setAnchor] = useState(0); + const gridRef = useRef>(null); + const alive = useRef(true); + // Mirrors of the list state so the pan handlers (created once) always read + // the current page without being rebuilt. + const assetsRef = useRef([]); + const cursorRef = useRef(undefined); + const hasMoreRef = useRef(false); + // Whether newer photos exist above the loaded window (true only after a jump, + // which starts the list at the target date instead of at the newest photo). + const hasNewerRef = useRef(false); + const newestRef = useRef(null); + // Latest date the bar asked for, and whether its query is running. The loop in + // jumpTo reads these, so a drag coalesces into one last query. + const jumpTarget = useRef(null); + const jumpBusy = useRef(false); + const loadingRef = useRef(false); + // Live scroll offset: the prepend reads it to tell a real pull past the top + // from the echo of its own scrollToOffset. + const offsetRef = useRef(0); + const trackHRef = useRef(0); + const stripRef = useRef(null); + // Top of the bar in window coordinates, so a drag can be read from pageY. + const stripTop = useRef(0); + const pageWRef = useRef(width); + const txAn = useRef(new Animated.Value(0)).current; + const tyAn = useRef(new Animated.Value(0)).current; + // The preview shrinks as it is dragged down, so the drag reads as "put it back + // in the grid" before it lands there. + const scaleAn = useRef(new Animated.Value(1)).current; + const anchorRef = useRef(0); + const axis = useRef<'x' | 'y' | null>(null); + const settling = useRef(false); + + const load = async (after?: string) => { + if (loadingRef.current) return null; + loadingRef.current = true; + setLoading(true); + try { + const page = await MediaLibrary.getAssetsAsync({ + first: PAGE, + after, + mediaType: 'photo', + // @ts-ignore legacy SortByValue tuple shape + sortBy: [['creationTime', false]], + }); + if (!alive.current) return null; + assetsRef.current = after ? [...assetsRef.current, ...page.assets] : page.assets; + cursorRef.current = page.endCursor; + hasMoreRef.current = page.hasNextPage; + setAssets(assetsRef.current); + setCursor(page.endCursor); + setHasMore(page.hasNextPage); + setError(page.assets.length === 0 && !after ? 'NO PHOTOS ON THIS DEVICE' : null); + return page; + } catch (e) { + console.warn('Library page load failed:', e); + if (alive.current) setError('CANNOT READ PHOTOS'); + return null; + } finally { + loadingRef.current = false; + if (alive.current) setLoading(false); + } + }; + + // One query lands on any date, however deep the library is. Walking page by + // page to a date a couple of years back meant a dozen round trips and the + // thumbnails only caught up seconds after the drag; the grid is a window that + // starts at the target instead, and paging down from there stays on the same + // cursor. + // The window carries the page above the target too: a list whose first row is + // the target has nothing above it, so a downward drag dies on the spot (the + // scroll view is already at offset 0, no scroll event, nothing to page in). + // Returns the row the target date sits on, to park on. + const loadWindow = async (before: number) => { + // Wait for whatever is already loading instead of skipping: the last drag + // event usually lands while the previous jump (or a scroll-triggered page) + // is still resolving, and bailing out left the grid a drag behind the thumb. + for (let i = 0; i < 50 && loadingRef.current; i++) { + await new Promise((r) => setTimeout(r, 20)); + } + loadingRef.current = true; + setLoading(true); + try { + const [page, above0] = await Promise.all([ + MediaLibrary.getAssetsAsync({ + first: PAGE * 2, + mediaType: 'photo', + createdBefore: before, + // @ts-ignore legacy SortByValue tuple shape + sortBy: [['creationTime', false]], + }), + MediaLibrary.getAssetsAsync({ + first: PAGE, + mediaType: 'photo', + createdAfter: before, + // @ts-ignore legacy SortByValue tuple shape + sortBy: [['creationTime', true]], + }), + ]); + if (!alive.current) return null; + // createdAfter sorts oldest first, so the page is the block sitting right + // above the target; reversed it is newest first like the rest of the list. + const above = [...above0.assets].reverse(); + assetsRef.current = [...above, ...page.assets]; + cursorRef.current = page.endCursor; + hasMoreRef.current = page.hasNextPage; + hasNewerRef.current = above.length === PAGE && + above[0].creationTime < (newestRef.current ?? 0); + setAssets(assetsRef.current); + setCursor(page.endCursor); + setHasMore(page.hasNextPage); + return Math.ceil(above.length / COLS); + } catch (e) { + console.warn('Library window load failed:', e); + return null; + } finally { + loadingRef.current = false; + if (alive.current) setLoading(false); + } + }; + + // Scrolling up to the top of the window pulls the page above it and stacks it + // on. Native maintainVisibleContentPosition keeps the offset glued to index 0 + // instead of to the row the user is looking at, which walked the grid forward + // in time as it paged in - so the offset is corrected here. + const prependNewer = async () => { + const top = assetsRef.current[0]; + if (!top || !hasNewerRef.current) return; + for (let i = 0; i < 50 && loadingRef.current; i++) { + await new Promise((r) => setTimeout(r, 20)); + } + // A drag emits one scroll event per frame, so this is called repeatedly + // while it sits at the top; the ones that queued up behind the first page + // find the list already moved down and drop out. + if (loadingRef.current || offsetRef.current >= cell * 2) return; + loadingRef.current = true; + try { + const page = await MediaLibrary.getAssetsAsync({ + first: PAGE, + mediaType: 'photo', + createdAfter: top.creationTime, + // @ts-ignore legacy SortByValue tuple shape + sortBy: [['creationTime', true]], + }); + if (!alive.current) return; + const fetched = page.assets; + hasNewerRef.current = fetched.length === PAGE && + fetched[fetched.length - 1].creationTime < (newestRef.current ?? 0); + // Whole rows only: a block that is not a multiple of COLS would shift + // every cell a column to the side. The few dropped at the newest end are + // asked for again by the next call. + let above = [...fetched].reverse(); + above = above.slice(above.length % COLS); + if (!above.length) return; + assetsRef.current = [...above, ...assetsRef.current]; + // The preview pager holds its photo by index, so the index has to move + // with the block that just landed in front of it. + anchorRef.current += above.length; + setAnchor(anchorRef.current); + setAssets(assetsRef.current); + offsetRef.current += (above.length / COLS) * cell; + gridRef.current?.scrollToOffset({ offset: offsetRef.current, animated: false }); + } catch (e) { + console.warn('Library prepend failed:', e); + } finally { + loadingRef.current = false; + } + }; + + // Photo read permission first: without it the query returns an empty page and + // the picker would look broken rather than locked. + useEffect(() => { + if (!visible) { + setPreview(null); + return; + } + alive.current = true; + setPreview(null); + setFrac(0); + (async () => { + try { + const perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']); + if (!alive.current) return; + if (!perm.granted) { + setError('PHOTO ACCESS DENIED - ENABLE IT IN SETTINGS'); + return; + } + // Both ends of the timeline, in one round trip each. The oldest one + // needs createdAfter (it maps to DATE_TAKEN > 1): a photo the scanner + // could not date (DATE_TAKEN 0 - screenshots, downloads, anything it + // only saw with a 1970 mtime) sorts as the oldest, and an oldest of 0 + // reads as "no range", so the bar moved but printed no date and never + // scrolled the grid. + const [newestPage, oldestPage] = await Promise.all([ + MediaLibrary.getAssetsAsync({ + first: 1, + mediaType: 'photo', + // @ts-ignore legacy SortByValue tuple shape + sortBy: [['creationTime', false]], + }), + MediaLibrary.getAssetsAsync({ + first: 1, + mediaType: 'photo', + createdAfter: 1, + // @ts-ignore legacy SortByValue tuple shape + sortBy: [['creationTime', true]], + }), + ]); + if (!alive.current) return; + setNewest(newestPage.assets[0]?.creationTime ?? null); + newestRef.current = newestPage.assets[0]?.creationTime ?? null; + setOldest(oldestPage.assets[0]?.creationTime ?? null); + await load(); + } catch (e) { + console.warn('Photo permission request failed:', e); + if (alive.current) setError('CANNOT READ PHOTOS'); + } + })(); + return () => { + alive.current = false; + }; + }, [visible]); + + // USE: localUri is the real file (grid uri is the same on Android, the lookup + // is what also brings the EXIF/MediaStore id the GPS resolver wants). + const handleUse = async () => { + if (preview === null || busy) return; + const asset = assets[preview]; + if (!asset) return; + setBusy(true); + try { + const info = await MediaLibrary.getAssetInfoAsync(asset.id); + onSelect({ + uri: info.localUri || asset.uri, + assetId: asset.id, + exif: (info.exif as never) ?? null, + }); + } catch (e) { + console.warn('Asset info lookup failed, using grid uri:', e); + onSelect({ uri: asset.uri, assetId: asset.id, exif: null }); + } finally { + setBusy(false); + } + }; + + const cell = (width - SCRUB_W - 8) / COLS; + + const timeAtFrac = (f: number) => { + if (!newest || !oldest || oldest >= newest) return null; + return newest - f * (newest - oldest); + }; + + // Jump: one query re-windows the grid on the target date, so the thumbnails + // are there as soon as the drag stops instead of after a dozen page loads. + // Dragging fires these faster than the query resolves, so only the newest + // drag position parks the grid (an older jump that finishes late is dropped). + const parkRow = (row: number) => { + gridRef.current?.scrollToOffset({ offset: row * cell, animated: false }); + // The window can land after the native list has measured its content, and + // an offset past the measured content is clamped - park again once layout + // has caught up. + setTimeout(() => { + gridRef.current?.scrollToOffset({ offset: row * cell, animated: false }); + }, 300); + }; + + const jumpTo = (f: number) => { + const target = timeAtFrac(f); + if (target == null) return; + // One query at a time, only ever for the newest position asked for. A drag + // fires these every 120ms and a window query on the 12S Ultra takes over a + // second: queueing them all left the grid frozen for the length of the drag + // and then let an early (stale) jump land last, so the grid settled on a + // date the finger had long left. + jumpTarget.current = target + 1; + if (jumpBusy.current) return; + jumpBusy.current = true; + (async () => { + while (alive.current && jumpTarget.current != null) { + const before = jumpTarget.current; + jumpTarget.current = null; + const row = await loadWindow(before); + // Park on the row the target date sits on, not the top row: the window + // opens a page above the target so the grid can still be dragged down. + if (row != null && jumpTarget.current == null && alive.current) parkRow(row); + } + jumpBusy.current = false; + })(); + }; + + const scrub = (y: number) => { + const travel = Math.max(1, trackHRef.current - 2 * TRACK_PAD - THUMB_H); + const f = Math.min(1, Math.max(0, (y - TRACK_PAD - THUMB_H / 2) / travel)); + setScrubbing(true); + setFrac(f); + // No throttle: jumpTo only records the position, and the loop inside it runs + // one query at a time, so the window that lands is always the one the bar + // ended on. Skipping a move meant the grid could settle a date behind the + // bar when the finger stopped inside the throttle window. + jumpTo(f); + }; + const scrubRef = useRef(scrub); + scrubRef.current = scrub; + + // Read the drag from pageY, never from locationY: locationY is relative to + // whichever node the OS picked as the touch target, and on a Xiaomi 14 Ultra + // that switched to the thumb as it slid under the finger - every other move + // event came back as a small number, which clamped the thumb back to the top + // and made it flash between the finger and the top, with no date to read. + // pageY is absolute, so the bar's own origin is measured instead. + const pan = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderGrant: (e) => { + const { pageY, locationY } = e.nativeEvent; + const top = stripTop.current || pageY - locationY; + stripTop.current = top; + scrubRef.current(pageY - top); + }, + onPanResponderMove: (e) => { + if (!stripTop.current) return; + scrubRef.current(e.nativeEvent.pageY - stripTop.current); + }, + onPanResponderRelease: () => setScrubbing(false), + onPanResponderTerminate: () => setScrubbing(false), + }) + ).current; + + // Preview pager. This used to be a paging ScrollView, which only handed the + // gesture over where its own native interceptor beat the photo's PanResponder: + // on a Xiaomi 14 Ultra that left most of the picture unable to swipe, and only + // the middle band paged. Here the pager takes the drag itself as soon as the + // finger travels, so the whole photo swipes to the previous/next one; a + // downward drag closes the preview and drops back to the thumbnails. The photo + // keeps taps and pinches - it holds the responder from the start and refuses + // to hand it over while zoomed (ZoomableImage.onPanResponderTerminationRequest). + const closePreview = () => { + settling.current = true; + Animated.parallel([ + Animated.timing(tyAn, { toValue: 1200, duration: 180, useNativeDriver: false }), + Animated.timing(scaleAn, { toValue: 0.7, duration: 180, useNativeDriver: false }), + ]).start(() => { + tyAn.setValue(0); + scaleAn.setValue(1); + settling.current = false; + setPreview(null); + }); + }; + + const pager = useRef( + PanResponder.create({ + // capture: the photo is the one holding the responder + onMoveShouldSetPanResponderCapture: (e, g) => { + // Two fingers is a pinch, whatever the photo has decided yet. + if (e.nativeEvent.touches.length >= 2) return false; + if (settling.current) return false; + if (Math.abs(g.dx) < 10 && g.dy < 10) return false; + axis.current = Math.abs(g.dy) > Math.abs(g.dx) ? 'y' : 'x'; + return true; + }, + onPanResponderMove: (_e, g) => { + if (axis.current === 'y') { + const dy = Math.max(0, g.dy); + tyAn.setValue(dy); + scaleAn.setValue(Math.max(0.7, 1 - dy / 2000)); + return; + } + const i = anchorRef.current; + const edge = (i === 0 && g.dx > 0) || (i >= assetsRef.current.length - 1 && g.dx < 0); + txAn.setValue(edge ? g.dx * 0.3 : g.dx); + }, + onPanResponderRelease: (_e, g) => { + const a = axis.current; + axis.current = null; + if (a === 'y') { + if (g.dy > 120 || g.vy > 0.7) { + closePreview(); + return; + } + Animated.parallel([ + Animated.spring(tyAn, { toValue: 0, friction: 8, tension: 80, useNativeDriver: false }), + Animated.spring(scaleAn, { toValue: 1, friction: 8, tension: 80, useNativeDriver: false }), + ]).start(); + return; + } + const i = anchorRef.current; + const last = assetsRef.current.length - 1; + const target = g.dx < 0 ? Math.min(i + 1, last) : Math.max(i - 1, 0); + const far = Math.abs(g.dx) > pageWRef.current * 0.2 || Math.abs(g.vx) > 0.4; + if (target === i || !far) { + Animated.spring(txAn, { toValue: 0, friction: 8, tension: 80, useNativeDriver: false }).start(); + return; + } + settling.current = true; + const slide = g.dx < 0 ? -pageWRef.current : pageWRef.current; + setPreview(target); + Animated.timing(txAn, { toValue: slide, duration: 160, useNativeDriver: false }).start(() => { + // Same pixels before and after the swap, so the slots can be rebased + // on the new photo without a flicker. + txAn.setValue(0); + anchorRef.current = target; + setAnchor(target); + settling.current = false; + }); + if (hasMoreRef.current && target >= assetsRef.current.length - 2) { + load(cursorRef.current); + } + }, + onPanResponderTerminate: () => { + axis.current = null; + txAn.setValue(0); + tyAn.setValue(0); + scaleAn.setValue(1); + }, + }) + ).current; + + const openPreview = (index: number) => { + axis.current = null; + settling.current = false; + txAn.setValue(0); + tyAn.setValue(0); + scaleAn.setValue(1); + anchorRef.current = index; + setAnchor(index); + setPreview(index); + }; + + // No usable range (nothing datable, or every photo on one date): leave the + // bar out instead of showing a control that cannot move the grid. + const rangeOk = !!newest && !!oldest && oldest < newest; + const time = timeAtFrac(frac); + // creationTime is already in milliseconds, so it goes straight into Date. + const stamp = time + ? (() => { + const d = new Date(time); + const p = (n: number) => String(n).padStart(2, '0'); + return `${p(d.getDate())}/${p(d.getMonth() + 1)}/${d.getFullYear()}`; + })() + : null; + + return ( + + + {preview === null ? ( + <> + {/* Header */} + + + + SELECT PHOTO + + + TAP TO PREVIEW · DRAG THE BAR TO CHANGE DATE + + + + + + + + {error ? ( + + {error} + + ) : ( + + a.id} + style={{ flex: 1, paddingRight: SCRUB_W }} + columnWrapperStyle={{ paddingHorizontal: 1 }} + // FlatList hands getItemLayout the ROW index (its item count + // is rows, not photos) - so one cell per index, no /COLS. + getItemLayout={(_, i) => ({ + length: cell, + offset: cell * i, + index: i, + })} + onEndReachedThreshold={0.7} + onEndReached={() => { + if (hasMore && !loading) load(cursor); + }} + // Paging newer photos in cannot go through onStartReached: it + // stays quiet while the first rendered cell is index 0, which + // is exactly where a downward drag starts. The offset is read + // instead, and a drag that has nothing left above it pulls the + // page in. + scrollEventThrottle={16} + onScroll={(e) => { + offsetRef.current = e.nativeEvent.contentOffset.y; + if (offsetRef.current < cell * 2) prependNewer(); + }} + renderItem={({ item, index }) => ( + openPreview(index)} + activeOpacity={0.8} + style={{ width: cell, height: cell, padding: 1 }} + > + + + )} + ListFooterComponent={ + loading ? ( + + + + ) : null + } + /> + + {assets.length > 0 && rangeOk && ( + { + trackHRef.current = e.nativeEvent.layout.height; + setTrackH(e.nativeEvent.layout.height); + stripRef.current?.measureInWindow((_x, y) => { + stripTop.current = y; + }); + }} + style={{ + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: SCRUB_W, + }} + {...pan.panHandlers} + > + + + + )} + + {scrubbing && stamp && ( + + + {stamp} + + + )} + + )} + + ) : ( + <> + + + + {assets[preview]?.filename?.toUpperCase() || ''} + + + {preview + 1} / {assets.length} + {hasMore ? '+' : ''} · PINCH OR DOUBLE TAP TO ZOOM + + + setPreview(null)} + className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center" + activeOpacity={0.7} + > + + + + + { + const w = e.nativeEvent.layout.width; + if (w > 0 && w !== pageWRef.current) { + pageWRef.current = w; + setPageW(w); + } + }} + {...pager.panHandlers} + > + + {[-1, 0, 1].map((d) => { + const i = anchor + d; + const a = assets[i]; + if (!a) return null; + return ( + + + + ); + })} + + + + + + + CANCEL + + + + + {busy ? 'LOADING...' : 'USE PHOTO'} + + + + + )} + + + ); +}