diff --git a/app/_layout.tsx b/app/_layout.tsx index d717c04f7..0f8c4ab12 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -78,6 +78,11 @@ export default function RootLayout() { }); useEffect(() => { + // async function init() { + // await tagService.preloadTagsIntoCache(); + // } + // void init(); + if (Platform.OS === 'web') return; console.log('[_layout] Setting up deep link handler'); diff --git a/components/AddVerseLabelButton.tsx b/components/AddVerseLabelButton.tsx new file mode 100644 index 000000000..d9c5e466a --- /dev/null +++ b/components/AddVerseLabelButton.tsx @@ -0,0 +1,37 @@ +import { PlusCircleIcon } from 'lucide-react-native'; +import React from 'react'; +import { Pressable, View } from 'react-native'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +interface AddVerseLabelButtonProps { + onPress: () => void; + disabled?: boolean; + className?: string; +} + +export function AddVerseLabelButton({ + onPress, + disabled = false, + className = '' +}: AddVerseLabelButtonProps) { + return ( + + {/* */} + + + + + Add verse + + + {/* */} + + ); +} diff --git a/components/ArrayInsertionWheel.tsx b/components/ArrayInsertionWheel.tsx index 47c4e7a8e..01d867636 100644 --- a/components/ArrayInsertionWheel.tsx +++ b/components/ArrayInsertionWheel.tsx @@ -12,47 +12,66 @@ export interface ArrayInsertionWheelHandle { scrollItemToTop: (index: number, animated?: boolean) => void; } -interface ArrayInsertionWheelProps { - children: React.ReactNode[]; +interface ArrayInsertionWheelPropsBase { value: number; // 0..N insertion boundary onChange?: (index: number) => void; rowHeight: number; className?: string; topInset?: number; // unused in native wheel, kept for API parity bottomInset?: number; // unused in native wheel, kept for API parity + boundaryComponent?: React.ReactNode; +} + +// API 1: Eager rendering with children (backward compatible) +interface ArrayInsertionWheelPropsEager extends ArrayInsertionWheelPropsBase { + children: React.ReactNode[]; + data?: never; + renderItem?: never; +} + +// API 2: Lazy rendering with data + renderItem (optimized) +interface ArrayInsertionWheelPropsLazy extends ArrayInsertionWheelPropsBase { + children?: never; + data: T[]; + renderItem: (item: T, index: number) => React.ReactElement; } -function ArrayInsertionWheelInternal( - { - children, +type ArrayInsertionWheelProps = + | ArrayInsertionWheelPropsEager + | ArrayInsertionWheelPropsLazy; + +function ArrayInsertionWheelInternal( + props: ArrayInsertionWheelProps, + ref: React.Ref +) { + const { value, onChange, rowHeight, className, topInset = 0, - bottomInset = 0 - }: ArrayInsertionWheelProps, - ref: React.Ref -) { - const itemCount = children.length + 1; // extra end boundary + bottomInset = 0, + boundaryComponent + } = props; + + // Determine which API is being used + const isLazyMode = 'data' in props && props.data !== undefined; + + // Calculate item count based on mode + let itemCount: number; + if (isLazyMode) { + itemCount = props.data.length + 1; + } else { + // Eager mode - children is guaranteed by type + itemCount = props.children.length + 1; + } + const clampedValue = Math.max(0, Math.min(itemCount - 1, value)); // Stabilize clampedValue to prevent unnecessary WheelPicker updates - const prevClampedRef = React.useRef(clampedValue); const stableClampedValue = React.useMemo(() => { - if (prevClampedRef.current !== clampedValue) { - console.log( - 'πŸ“Š Wheel value changed:', - prevClampedRef.current, - 'β†’', - clampedValue, - '| itemCount:', - itemCount - ); - prevClampedRef.current = clampedValue; - } return clampedValue; - }, [clampedValue, itemCount]); + }, [clampedValue]); // Debug logging to trace clamping React.useEffect(() => { @@ -67,7 +86,7 @@ function ArrayInsertionWheelInternal( } }, [value, clampedValue, itemCount]); - const data = React.useMemo[]>( + const pickerData = React.useMemo[]>( () => Array.from({ length: itemCount }, (_, i) => ({ value: i })), [itemCount] ); @@ -116,22 +135,50 @@ function ArrayInsertionWheelInternal( [stableClampedValue, itemCount, onChange] ); - const renderItem = React.useCallback( - ({ item }: { item: PickerItem }) => { + const renderItemInternal = React.useCallback( + ({ item }: { item: PickerItem }): React.ReactElement => { const i = item.value; + + // Calculate data length based on mode + let dataLength: number; + if (isLazyMode) { + dataLength = props.data.length; + } else { + dataLength = props.children.length; + } // Render actual items (not the final boundary) - if (i < children.length) { - return ( - - {children[i]} - - ); + if (i < dataLength) { + if (isLazyMode) { + // Lazy mode: call renderItem with data item + const dataItem = props.data[i]; + if (!dataItem) { + // Defensive: should never happen, but TypeScript needs this + return ; + } + return ( + + {props.renderItem(dataItem, i)} + + ); + } else { + // Eager mode: use pre-created children + const child = props.children[i]; + return ( + + {child} + + ); + } } - // Final boundary (i === children.length) + // Final boundary (i === dataLength) // When empty (0 items), this is position 0 - the only insertion point // When non-empty, this is position N - insert after all items + if (boundaryComponent) { + return <>{boundaryComponent}; + } + return ( ); }, - [children, rowHeight] + [isLazyMode, props, rowHeight, boundaryComponent] ); return ( @@ -174,7 +221,7 @@ function ArrayInsertionWheelInternal( onLayout={onContainerLayout} > onChange?.(item.value)} // Constrain height to an exact multiple of rowHeight so overlay aligns style={{ height: wheelHeight }} - renderItem={renderItem} + renderItem={renderItemInternal} renderItemContainer={({ key, ...props }) => ( )} @@ -209,13 +256,10 @@ function ArrayInsertionWheelInternal( ); } -export default React.forwardRef< - ArrayInsertionWheelHandle, - ArrayInsertionWheelProps ->( - ArrayInsertionWheelInternal as unknown as ( - props: ArrayInsertionWheelProps & { - ref?: React.Ref; - } - ) => React.ReactElement -); +const ArrayInsertionWheel = React.forwardRef(ArrayInsertionWheelInternal) as ( + props: ArrayInsertionWheelProps & { + ref?: React.Ref; + } +) => React.ReactElement; + +export default ArrayInsertionWheel; diff --git a/components/AssetsDeletionDrawer.tsx b/components/AssetsDeletionDrawer.tsx new file mode 100644 index 000000000..7e1b29a08 --- /dev/null +++ b/components/AssetsDeletionDrawer.tsx @@ -0,0 +1,125 @@ +import { Button } from '@/components/ui/button'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle +} from '@/components/ui/drawer'; +import { Input } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import { useLocalization } from '@/hooks/useLocalization'; +import { AlertTriangleIcon } from 'lucide-react-native'; +import React from 'react'; +import { View } from 'react-native'; +import { Icon } from './ui/icon'; + +interface AssetsDeletionDrawerProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void | Promise; + title: string; + description: string; + confirmationString: string; // String that user must type to confirm deletion +} + +export const AssetsDeletionDrawer: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + description, + confirmationString +}) => { + const { t } = useLocalization(); + const [inputValue, setInputValue] = React.useState(''); + const [isExecuting, setIsExecuting] = React.useState(false); + + // Reset input when drawer opens + React.useEffect(() => { + if (isOpen) { + setInputValue(''); + setIsExecuting(false); + } + }, [isOpen]); + + const handleConfirm = async () => { + if (inputValue !== confirmationString || isExecuting) return; + + setIsExecuting(true); + try { + await onConfirm(); + onClose(); + } catch (error) { + console.error('Error executing deletion:', error); + } finally { + setIsExecuting(false); + } + }; + + const isButtonDisabled = inputValue !== confirmationString || isExecuting; + + return ( + !open && onClose()}> + + + + + + {title} + + {description} + + + + + + {t('typeToConfirm').replace( + '{text}', + `"${confirmationString}"` + )} + + + + + + + + + + + + + + ); +}; diff --git a/components/QuestionModal.tsx b/components/QuestionModal.tsx new file mode 100644 index 000000000..1ce0e1f60 --- /dev/null +++ b/components/QuestionModal.tsx @@ -0,0 +1,78 @@ +import { Button } from '@/components/ui/button'; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle +} from '@/components/ui/drawer'; +import { Text } from '@/components/ui/text'; +import React from 'react'; +import { View } from 'react-native'; + +interface QuestionModalProps { + visible: boolean; + title: string; + description: string; + onYes: () => void; + onNo: () => void; + onClose?: () => void; +} + +export function QuestionModal({ + visible, + title, + description, + onYes, + onNo, + onClose +}: QuestionModalProps) { + const [isOpen, setIsOpen] = React.useState(visible); + + // Sync internal state with prop + React.useEffect(() => { + setIsOpen(visible); + }, [visible]); + + const handleYes = () => { + onYes(); + setIsOpen(false); + onClose?.(); + }; + + const handleNo = () => { + onNo(); + setIsOpen(false); + onClose?.(); + }; + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (!open) { + onClose?.(); + } + }; + + return ( + + + + {title} + {description} + + + + + + + + + + + ); +} diff --git a/components/SectionSeparator.tsx b/components/SectionSeparator.tsx new file mode 100644 index 000000000..6599302c1 --- /dev/null +++ b/components/SectionSeparator.tsx @@ -0,0 +1,42 @@ +import { Text, View } from 'react-native'; + +type SectionSeparatorVariant = 'default' | 'small' | 'xs'; + +interface SectionSeparatorProps { + text: string; + variant?: SectionSeparatorVariant; + className?: string; + textClassName?: string; +} + +const variantStyles = { + default: { + line: 'bg-primary', + text: 'text-base text-primary' + }, + small: { + line: 'bg-primary/60', + text: 'text-sm text-primary/60' + }, + xs: { + line: 'bg-primary/40', + text: 'text-xs text-primary/40' + } +}; + +export function SectionSeparator({ + text, + variant = 'default', + className = '', + textClassName = '' +}: SectionSeparatorProps) { + const styles = variantStyles[variant]; + + return ( + + + {text} + + + ); +} diff --git a/components/SortListCombo.tsx b/components/SortListCombo.tsx new file mode 100644 index 000000000..bb85dbd68 --- /dev/null +++ b/components/SortListCombo.tsx @@ -0,0 +1,158 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + getOptionFromValue +} from '@/components/ui/select'; +import { Text } from '@/components/ui/text'; +import React from 'react'; +import { View } from 'react-native'; + +export interface SortListComboProps { + options: string[]; + onSelectionChange?: (selection: { + first: string | undefined; + second: string | undefined; + third: string | undefined; + }) => void; + className?: string; + placeholder1?: string; + placeholder2?: string; + placeholder3?: string; +} + +export function SortListCombo({ + options, + onSelectionChange, + className = '', + placeholder1 = 'Select first option', + placeholder2 = 'Select second option', + placeholder3 = 'Select third option' +}: SortListComboProps) { + const [selected1, setSelected1] = React.useState(); + const [selected2, setSelected2] = React.useState(); + const [selected3, setSelected3] = React.useState(); + + // Notify parent when selection changes + React.useEffect(() => { + onSelectionChange?.({ + first: selected1, + second: selected2, + third: selected3 + }); + }, [selected1, selected2, selected3, onSelectionChange]); + + // Filter options for second combobox (exclude selected1) + const options2 = React.useMemo(() => { + return options.filter((option) => option !== selected1); + }, [options, selected1]); + + // Filter options for third combobox (exclude selected1 and selected2) + const options3 = React.useMemo(() => { + return options.filter( + (option) => option !== selected1 && option !== selected2 + ); + }, [options, selected1, selected2]); + + const handleFirstChange = (value: string) => { + setSelected1(value); + setSelected2(undefined); + setSelected3(undefined); + }; + + const handleSecondChange = (value: string) => { + setSelected2(value); + setSelected3(undefined); + }; + + return ( + + Group by + + + + + + + + + + + + + ); +} diff --git a/components/TagModal.tsx b/components/TagModal.tsx new file mode 100644 index 000000000..8edd0646b --- /dev/null +++ b/components/TagModal.tsx @@ -0,0 +1,296 @@ +/** + * TagModal - Modal for assigning tags + */ + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import type { Tag } from '@/database_services/tagCache'; +import { useSearchTags } from '@/hooks/db/useSearchTags'; +import React from 'react'; +import type { TextInput } from 'react-native'; +import { Modal, Pressable, ScrollView, View } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming +} from 'react-native-reanimated'; + +interface TagModalProps { + isVisible: boolean; + selectedTag?: Tag; + initialSelectedTags?: Tag[]; + searchTerm?: string; + limit?: number; + onClose: () => void; + onAssignTags: (tags: Tag[]) => void; +} + +export function TagModal({ + isVisible, + selectedTag, + initialSelectedTags = [], + searchTerm = '', + limit = 20, + onClose, + onAssignTags +}: TagModalProps) { + const [localSearchTerm, setLocalSearchTerm] = React.useState(searchTerm); + const [selectedTags, setSelectedTags] = React.useState(() => { + if (selectedTag) return [selectedTag]; + return initialSelectedTags; + }); + const [modalVisible, setModalVisible] = React.useState(false); + const inputRef = React.useRef(null); + + const { tags = [], isTagsLoading } = useSearchTags({ + searchTerm: searchTerm || localSearchTerm, + maxResults: limit, + enabled: isVisible + }); + const opacity = useSharedValue(0); + const scale = useSharedValue(0.9); + + // Reset state when modal opens or searchTerm changes + React.useEffect(() => { + if (isVisible) { + setLocalSearchTerm(searchTerm); + if (selectedTag) { + setSelectedTags([selectedTag]); + } else { + setSelectedTags(initialSelectedTags); + } + } + }, [isVisible, searchTerm, selectedTag, initialSelectedTags]); + + // Handle modal visibility with exit animation + React.useEffect(() => { + if (isVisible) { + // Show modal immediately + setModalVisible(true); + // Quick, snappy animation (Emil Kowalski style) + opacity.value = withTiming(1, { + duration: 150, + easing: Easing.out(Easing.ease) + }); + scale.value = withTiming(1, { + duration: 150, + easing: Easing.out(Easing.ease) + }); + + // Focus after animation completes + const focusTimer = setTimeout(() => { + inputRef.current?.focus(); + }, 150); + + // Backup focus attempt + const backupTimer = setTimeout(() => { + inputRef.current?.focus(); + }, 200); + + return () => { + clearTimeout(focusTimer); + clearTimeout(backupTimer); + }; + } else { + // Exit animation - quick fade out (ease-out for responsiveness) + opacity.value = withTiming(0, { + duration: 100, + easing: Easing.out(Easing.ease) + }); + scale.value = withTiming(0.9, { + duration: 100, + easing: Easing.out(Easing.ease) + }); + + // Hide modal after exit animation completes + const hideTimer = setTimeout(() => { + setModalVisible(false); + }, 100); + + return () => { + clearTimeout(hideTimer); + }; + } + }, [isVisible, opacity, scale]); + + const handleAssign = () => { + onAssignTags(selectedTags); + onClose(); + }; + + const handleTagToggle = (tag: Tag) => { + setSelectedTags((prev) => { + const isSelected = prev.some((t) => t.id === tag.id); + if (isSelected) { + return prev.filter((t) => t.id !== tag.id); + } else { + return [...prev, tag]; + } + }); + }; + + const handleCancel = () => { + setLocalSearchTerm(searchTerm); + if (selectedTag) { + setSelectedTags([selectedTag]); + } else { + setSelectedTags(initialSelectedTags); + } + onClose(); + }; + + const formatTagText = (tag: Tag) => { + return tag.value ? `${tag.key}:${tag.value}` : tag.key; + }; + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ scale: scale.value }] + })); + + if (!modalVisible) return null; + + return ( + + + + e.stopPropagation()}> + + + Assign Tags + + + {/* Show search input only if no initial search term */} + {!searchTerm && ( + + )} + + {/* Selected tags display */} + {selectedTags.length > 0 && ( + + + Selected Tags ({selectedTags.length}): + + + {selectedTags.map((tag, index) => { + const isFirstTag = index === 0; + return ( + handleTagToggle(tag)} + className={`mr-2 rounded-full px-3 py-1 ${ + isFirstTag ? 'bg-primary' : 'bg-primary/90' + }`} + > + + {formatTagText(tag)} βœ• + + + ); + })} + + + )} + + {/* Tags list */} + + + Available Tags: + + {isTagsLoading ? ( + Loading... + ) : tags.length === 0 ? ( + + {localSearchTerm ? 'No tags found' : 'No tags available'} + + ) : ( + + + {tags.map((tag) => { + const isSelected = selectedTags.some( + (t) => t.id === tag.id + ); + const isFirstSelected = + selectedTags.length > 0 && + selectedTags[0]?.id === tag.id; + return ( + handleTagToggle(tag)} + className={`mb-2 mr-2 rounded-full px-3 py-1 ${ + isFirstSelected + ? 'bg-primary' + : isSelected + ? 'bg-primary/90' + : 'border border-border bg-background' + }`} + > + + {formatTagText(tag)} + {isSelected && ' βœ“'} + + + ); + })} + + + )} + + + + + + + + + + + + ); +} diff --git a/components/VerseAssigner.tsx b/components/VerseAssigner.tsx new file mode 100644 index 000000000..0418d47fa --- /dev/null +++ b/components/VerseAssigner.tsx @@ -0,0 +1,378 @@ +import { XIcon } from 'lucide-react-native'; +import React from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; +import { Button } from './ui/button'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +export interface ExistingLabel { + from: number; + to: number; +} + +// Unified list item type +type ListItem = + | { type: 'available'; verse: number } + | { type: 'existing'; from: number; to: number }; + +interface VerseAssignerProps { + // Either provide availableVerses array OR from/to range (for backward compatibility) + availableVerses?: number[]; + from?: number; + to?: number; + selectedFrom?: number; + selectedTo?: number; + onApply: (from: number, to: number) => void; + onCancel: () => void; + onRemove?: () => void; // Optional callback to remove labels from selected assets + hasSelectedAssetsWithLabels?: boolean; // Whether selected assets have labels to remove + className?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ScrollViewComponent?: React.ComponentType; + // Optional function to limit the maximum "to" value based on selected "from" + getMaxToForFrom?: (selectedFrom: number) => number; + // Existing verse labels to display for quick selection + existingLabels?: ExistingLabel[]; + // Total verse count for the chapter (to show all verses in unified list) + verseCount?: number; +} + +export function VerseAssigner({ + availableVerses, + from, + to, + selectedFrom: initialFrom, + selectedTo: initialTo, + onApply, + onCancel, + onRemove, + hasSelectedAssetsWithLabels = false, + className = '', + ScrollViewComponent = ScrollView, + getMaxToForFrom, + existingLabels = [], + verseCount +}: VerseAssignerProps) { + const [selectedFrom, setSelectedFrom] = React.useState( + initialFrom + ); + const [selectedTo, setSelectedTo] = React.useState( + initialTo + ); + + // Track if selection came from an existing label (to prevent range editing) + const [isExistingSelected, setIsExistingSelected] = React.useState(false); + + // Generate array of available numbers + const availableSet = React.useMemo(() => { + const set = new Set(); + if (availableVerses && availableVerses.length > 0) { + availableVerses.forEach((v) => set.add(v)); + } else if (from !== undefined && to !== undefined) { + for (let i = from; i <= to; i++) { + set.add(i); + } + } + return set; + }, [availableVerses, from, to]); + + // Determine total verse range + const totalVerseCount = React.useMemo(() => { + if (verseCount) return verseCount; + // Calculate from available verses and existing labels + let max = 0; + availableSet.forEach((v) => { + if (v > max) max = v; + }); + existingLabels.forEach((label) => { + if (label.to > max) max = label.to; + }); + return max || to || 1; + }, [verseCount, availableSet, existingLabels, to]); + + // Build unified list: interleave available verses with existing labels + const unifiedList = React.useMemo(() => { + const items: ListItem[] = []; + const existingMap = new Map(); // Maps 'from' to label + + // Index existing labels by their starting verse + for (const label of existingLabels) { + existingMap.set(label.from, label); + } + + // Build a set of verses covered by existing labels + const coveredByExisting = new Set(); + for (const label of existingLabels) { + for (let v = label.from; v <= label.to; v++) { + coveredByExisting.add(v); + } + } + + let verse = 1; + while (verse <= totalVerseCount) { + // Check if this verse starts an existing label + const existingLabel = existingMap.get(verse); + if (existingLabel) { + items.push({ + type: 'existing', + from: existingLabel.from, + to: existingLabel.to + }); + // Skip to after the label's range + verse = existingLabel.to + 1; + } else if (availableSet.has(verse)) { + // Available verse (not part of any existing label) + items.push({ type: 'available', verse }); + verse++; + } else { + // Verse is not available and not in existing label - skip it + verse++; + } + } + + return items; + }, [totalVerseCount, existingLabels, availableSet]); + + // Calculate max "to" value when "from" is selected + const maxTo: number = React.useMemo(() => { + if (selectedFrom === undefined) { + return totalVerseCount; + } + if (getMaxToForFrom) { + return getMaxToForFrom(selectedFrom); + } + return totalVerseCount; + }, [selectedFrom, getMaxToForFrom, totalVerseCount]); + + // Check if a verse is selectable for range selection + const isVerseSelectable = React.useCallback( + (verse: number) => { + if (!availableSet.has(verse)) return false; + + // If nothing selected, all available are selectable + if (selectedFrom === undefined) return true; + + // If existing label is selected, nothing else is selectable + if (isExistingSelected) return false; + + // If only "from" selected, only verses >= selectedFrom and <= maxTo are selectable + if (selectedTo === undefined) { + return verse >= selectedFrom && verse <= maxTo; + } + + // If both selected, nothing is selectable + return false; + }, + [selectedFrom, selectedTo, maxTo, availableSet, isExistingSelected] + ); + + const handleAvailablePress = (verse: number) => { + if (!isVerseSelectable(verse)) return; + + if (selectedFrom === undefined) { + // First selection + setSelectedFrom(verse); + setSelectedTo(undefined); + setIsExistingSelected(false); + } else if (selectedTo === undefined && !isExistingSelected) { + // Second selection for range + setSelectedTo(Math.min(verse, maxTo)); + } + }; + + const handleExistingPress = (label: ExistingLabel) => { + // If already selected, deselect + if (selectedFrom === label.from && selectedTo === label.to) { + setSelectedFrom(undefined); + setSelectedTo(undefined); + setIsExistingSelected(false); + } else { + // Select this existing label + setSelectedFrom(label.from); + setSelectedTo(label.to); + setIsExistingSelected(true); + } + }; + + const handleClear = () => { + setSelectedFrom(undefined); + setSelectedTo(undefined); + setIsExistingSelected(false); + }; + + const handleApply = () => { + if (selectedFrom !== undefined && selectedTo !== undefined) { + onApply(selectedFrom, selectedTo); + } else if (selectedFrom !== undefined) { + onApply(selectedFrom, selectedFrom); + } + }; + + const canApply = selectedFrom !== undefined; + + return ( + + {/* Unified verse list */} + + {unifiedList.map((item) => { + if (item.type === 'existing') { + // Existing label - render as a pill + const isSelected = + selectedFrom === item.from && selectedTo === item.to; + const labelText = + item.from === item.to + ? `${item.from}` + : `${item.from} - ${item.to}`; + // Disable existing labels when user is selecting a new range + const isDisabled = + selectedFrom !== undefined && !isExistingSelected; + + return ( + handleExistingPress(item)} + disabled={isDisabled} + className={`h-10 items-center justify-center rounded-full px-3 ${ + isSelected + ? 'bg-primary' + : isDisabled + ? 'bg-muted/30' + : 'border border-primary/30 bg-primary/5' + } ${!isDisabled ? 'active:scale-95' : ''}`} + > + + {labelText} + + + ); + } else { + // Available verse - render as circle + const verse = item.verse; + const isSelectedFrom = verse === selectedFrom; + const isSelectedTo = verse === selectedTo; + const isSelected = isSelectedFrom || isSelectedTo; + const selectable = isVerseSelectable(verse); + + return ( + handleAvailablePress(verse)} + disabled={!selectable} + className={`h-10 w-10 items-center justify-center rounded-full ${ + isSelected + ? 'bg-primary' + : selectable + ? 'border border-primary/30 bg-primary/5' + : 'bg-muted/30' + } ${selectable ? 'active:scale-95' : ''}`} + > + + {verse} + + + ); + } + })} + + + {/* Selected inputs */} + + {/* From input */} + + {selectedFrom !== undefined ? ( + <> + + {selectedFrom} + + + + ) : ( + From + )} + + + β€” + + {/* To input */} + + {selectedTo !== undefined ? ( + <> + + {selectedTo} + + + + ) : ( + To + )} + + + + {/* Action buttons */} + + + {selectedFrom !== undefined ? ( + // Show Apply when verse is selected + + ) : ( + // Show Remove when no selection but assets have labels + onRemove && + hasSelectedAssetsWithLabels && ( + + ) + )} + + + ); +} diff --git a/components/VersePill.tsx b/components/VersePill.tsx new file mode 100644 index 000000000..def7081ee --- /dev/null +++ b/components/VersePill.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { View } from 'react-native'; +import { Text } from './ui/text'; + +interface VersePillProps { + text: string; + className?: string; + largeText?: boolean; +} + +const VersePillComponent = ({ + text, + className = '', + largeText = false +}: VersePillProps) => { + return ( + + + + {text} + + + + ); +}; + +/** + * Memoized VersePill component + * Only re-renders when text, className, or largeText changes + * + * Performance: Prevents unnecessary re-renders when assets list changes + * but verse pills remain the same (common scenario in BibleRecordingView) + */ +export const VersePill = React.memo( + VersePillComponent, + (prevProps, nextProps) => { + // Return TRUE if props are EQUAL (skip re-render) + // Return FALSE if props are DIFFERENT (re-render needed) + return ( + prevProps.text === nextProps.text && + prevProps.className === nextProps.className && + prevProps.largeText === nextProps.largeText + ); + } +); + +VersePill.displayName = 'VersePill'; diff --git a/components/VerseRangeSelector.tsx b/components/VerseRangeSelector.tsx new file mode 100644 index 000000000..cbb6a6922 --- /dev/null +++ b/components/VerseRangeSelector.tsx @@ -0,0 +1,241 @@ +import { XIcon } from 'lucide-react-native'; +import React from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; +import { Button } from './ui/button'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +interface VerseRangeSelectorProps { + // Either provide availableVerses array OR from/to range (for backward compatibility) + availableVerses?: number[]; + from?: number; + to?: number; + selectedFrom?: number; + selectedTo?: number; + onApply: (from: number, to: number) => void; + onCancel: () => void; + className?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ScrollViewComponent?: React.ComponentType; + // Optional function to limit the maximum "to" value based on selected "from" + getMaxToForFrom?: (selectedFrom: number) => number; +} + +export function VerseRangeSelector({ + availableVerses, + from, + to, + selectedFrom: initialFrom, + selectedTo: initialTo, + onApply, + onCancel, + className = '', + ScrollViewComponent = ScrollView, + getMaxToForFrom +}: VerseRangeSelectorProps) { + const [selectedFrom, setSelectedFrom] = React.useState( + initialFrom + ); + const [selectedTo, setSelectedTo] = React.useState( + initialTo + ); + + // Generate array of numbers - use availableVerses if provided, otherwise generate from/to range + const allNumbers = React.useMemo(() => { + if (availableVerses && availableVerses.length > 0) { + return availableVerses; + } + // Fallback to from/to range for backward compatibility + if (from !== undefined && to !== undefined) { + const numbers: number[] = []; + for (let i = from; i <= to; i++) { + numbers.push(i); + } + return numbers; + } + return []; + }, [availableVerses, from, to]); + + // Calculate max "to" value when "from" is selected + const maxTo: number = React.useMemo(() => { + if (selectedFrom === undefined) { + return allNumbers.length > 0 + ? allNumbers[allNumbers.length - 1]! + : to || 1; + } + if (getMaxToForFrom) { + return getMaxToForFrom(selectedFrom); + } + // If no getMaxToForFrom function, allow up to the last available verse + return allNumbers.length > 0 ? allNumbers[allNumbers.length - 1]! : to || 1; + }, [selectedFrom, getMaxToForFrom, allNumbers, to]); + + // Check if a number is selectable based on current selection state + const isNumberSelectable = React.useCallback( + (num: number) => { + // Number must be in the available numbers list + if (!allNumbers.includes(num)) { + return false; + } + + // If nothing selected, all available numbers are selectable + if (selectedFrom === undefined) { + return true; + } + // If only "from" selected, only numbers >= selectedFrom and <= maxTo are selectable + if (selectedTo === undefined) { + return num >= selectedFrom && num <= maxTo && allNumbers.includes(num); + } + // If both selected, nothing is selectable (user must clear first) + return false; + }, + [selectedFrom, selectedTo, maxTo, allNumbers] + ); + + const handleNumberPress = (num: number) => { + if (!isNumberSelectable(num)) return; + + if (selectedFrom === undefined) { + // First selection - set "from" + setSelectedFrom(num); + // Clear "to" if it was set, since maxTo might have changed + setSelectedTo(undefined); + } else if (selectedTo === undefined) { + // Second selection - set "to" (but limit to maxTo) + setSelectedTo(Math.min(num, maxTo)); + } + }; + + const handleClearFrom = () => { + setSelectedFrom(undefined); + setSelectedTo(undefined); // Clear both since "to" depends on "from" + }; + + const handleClearTo = () => { + setSelectedTo(undefined); + }; + + const handleApply = () => { + if (selectedFrom !== undefined && selectedTo !== undefined) { + onApply(selectedFrom, selectedTo); + } else if (selectedFrom !== undefined) { + // If only "from" is selected, use same value for both + onApply(selectedFrom, selectedFrom); + } + }; + + const canApply = selectedFrom !== undefined; + + return ( + + {/* Number scroll */} + + {allNumbers.map((num) => { + const isSelectedFrom = num === selectedFrom; + const isSelectedTo = num === selectedTo; + const isSelected = isSelectedFrom || isSelectedTo; + const selectable = isNumberSelectable(num); + + return ( + handleNumberPress(num)} + disabled={!selectable} + className={`h-10 w-10 items-center justify-center rounded-full ${ + isSelected + ? 'bg-primary' + : selectable + ? 'border border-primary/30 bg-primary/5' + : 'bg-muted/30' + } ${selectable ? 'active:scale-95' : ''}`} + > + + {num} + + + ); + })} + + + {/* Selected inputs */} + + {/* From input */} + + {selectedFrom !== undefined ? ( + <> + + {selectedFrom} + + + + ) : ( + From + )} + + + β€” + + {/* To input */} + + {selectedTo !== undefined ? ( + <> + + {selectedTo} + + + + ) : ( + To + )} + + + + {/* Action buttons */} + + + + + + ); +} diff --git a/components/VerseSeparator.tsx b/components/VerseSeparator.tsx new file mode 100644 index 000000000..342ff09ec --- /dev/null +++ b/components/VerseSeparator.tsx @@ -0,0 +1,179 @@ +import { + AlertCircleIcon, + MoveVerticalIcon, + PencilIcon +} from 'lucide-react-native'; +import React from 'react'; +import { Pressable, View } from 'react-native'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +interface VerseSeparatorProps { + from?: number; + to?: number; + label: string; + className?: string; + editable?: boolean; + largeText?: boolean; + onPress?: () => void; + // Selection for recording: clicking the separator text selects it for recording + isSelectedForRecording?: boolean; + onSelectForRecording?: () => void; + dragHandleComponent?: React.ComponentType<{ + mode?: 'fixed-order' | 'draggable'; + children?: React.ReactNode; + }>; + dragHandleProps?: { + mode?: 'fixed-order' | 'draggable'; + }; +} + +export function VerseSeparator({ + from, + to, + label, + className = '', + editable = false, + largeText = false, + onPress, + isSelectedForRecording = false, + onSelectForRecording, + dragHandleComponent: DragHandleComponent, + dragHandleProps +}: VerseSeparatorProps) { + const hasNumbers = from !== undefined || to !== undefined; + + const getText = () => { + // No numbers provided + if (!hasNumbers) { + return `No label assigned`; + } + + // Only one number or both are the same + if (from === to || from === undefined || to === undefined) { + const value = from ?? to; + return `${label}:${value}`; + } + + // Range of numbers + return `${label}:${from}-${to}`; + }; + + if (!hasNumbers) { + // No assigned - warning style with amber/orange tones + // Background changes when selected for recording + const unassignedBgClass = isSelectedForRecording + ? 'border-primary bg-primary/20' + : 'border-amber-500/30 bg-amber-500/10'; + + return ( + + + + + {/* Text is clickable for recording selection when onSelectForRecording is provided */} + {onSelectForRecording ? ( + + + {getText()} + + + ) : ( + + {getText()} + + )} + + + + ); + } + + // Has numbers - pill style + // Background changes when selected for recording + const pillBgClass = isSelectedForRecording + ? 'bg-primary/30 border border-primary' + : 'bg-primary/10'; + + const pillContent = ( + + {DragHandleComponent && editable && ( + + + + )} + {/* Text is clickable for recording selection when onSelectForRecording is provided */} + {onSelectForRecording ? ( + + + {getText()} + + + ) : ( + + {getText()} + + )} + {/* Edit icon - only shown when editable and onPress is provided */} + {editable && onPress && ( + + + + )} + + ); + + return ( + + + {DragHandleComponent && editable ? ( + + {pillContent} + + ) : ( + pillContent + )} + + + ); +} diff --git a/components/ui/select.tsx b/components/ui/select.tsx index e61418e0f..17bdb13f8 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -172,27 +172,33 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< SelectPrimitive.ItemRef, - SelectPrimitive.ItemProps & { textClassName?: string } ->(({ className, textClassName, ...props }, ref) => ( + SelectPrimitive.ItemProps & { + textClassName?: string; + showCheckIcon?: boolean; + } +>(({ className, textClassName, showCheckIcon = true, ...props }, ref) => ( - - - - - + {showCheckIcon && ( + + + + + + )} { + try { + const assetLocalTable = resolveTable('asset', { localOverride: true }); + + // Verify this asset exists in the LOCAL table + const localAsset = await system.db + .select() + .from(assetLocalTable) + .where(eq(assetLocalTable.id, assetId)) + .limit(1); + + if (!localAsset || localAsset.length === 0) { + throw new Error( + 'Asset not found in local table - cannot update synced assets' + ); + } + + // Verify it doesn't exist in synced table (double-check it's not published) + const syncedTable = resolveTable('asset', { localOverride: false }); + const syncedAsset = await system.db + .select() + .from(syncedTable) + .where(eq(syncedTable.id, assetId)) + .limit(1); + + if (syncedAsset && syncedAsset.length > 0) { + throw new Error( + 'Cannot update synced assets - they are immutable once published' + ); + } + + // Safe to update - it's local only + const metadataStr = metadata ? JSON.stringify(metadata) : null; + await system.db + .update(assetLocalTable) + .set({ metadata: metadataStr }) + .where(eq(assetLocalTable.id, assetId)); + + console.log(`βœ… Asset ${assetId.slice(0, 8)} metadata updated`); + } catch (error) { + console.error('Failed to update asset metadata:', error); + throw error; + } +} + +/** + * Asset update payload for batch operations + */ +export interface AssetUpdatePayload { + assetId: string; + metadata?: AssetMetadata | null; + order_index?: number; +} + +/** + * Batch update asset metadata and/or order_index for multiple assets + * @param updates - Array of { assetId, metadata?, order_index? } objects + */ +export async function batchUpdateAssetMetadata( + updates: AssetUpdatePayload[] +): Promise { + if (updates.length === 0) return; + + try { + const assetLocalTable = resolveTable('asset', { localOverride: true }); + const syncedTable = resolveTable('asset', { localOverride: false }); + + const assetIds = updates.map((u) => u.assetId); + + // Check which assets exist in synced table (immutable) + const syncedAssets = await system.db + .select({ id: syncedTable.id }) + .from(syncedTable) + .where(inArray(syncedTable.id, assetIds)); + + const syncedIds = new Set(syncedAssets.map((a) => a.id)); + + // Filter out synced assets + const localUpdates = updates.filter((u) => !syncedIds.has(u.assetId)); + + if (localUpdates.length === 0) { + console.log('No local assets to update'); + return; + } + + // Update each local asset + for (const update of localUpdates) { + const setPayload: { metadata?: string | null; order_index?: number } = {}; + + // Only include metadata if explicitly provided + if (update.metadata !== undefined) { + setPayload.metadata = update.metadata + ? JSON.stringify(update.metadata) + : null; + } + + // Only include order_index if explicitly provided + if (update.order_index !== undefined) { + setPayload.order_index = update.order_index; + } + + // Skip if nothing to update + if (Object.keys(setPayload).length === 0) continue; + + await system.db + .update(assetLocalTable) + .set(setPayload) + .where(eq(assetLocalTable.id, update.assetId)); + } + + console.log(`βœ… Updated ${localUpdates.length} assets`); + } catch (error) { + console.error('Failed to batch update assets:', error); + throw error; + } +} diff --git a/database_services/tagCache.ts b/database_services/tagCache.ts new file mode 100644 index 000000000..acc1d018b --- /dev/null +++ b/database_services/tagCache.ts @@ -0,0 +1,8 @@ +// export type Tag = typeof tag.$inferSelect; +export interface Tag { + id: string; + key: string; + value?: string; +} + +export const tagCache = new Map(); diff --git a/database_services/tagService.ts b/database_services/tagService.ts index 8d4f54bc9..3c5136420 100644 --- a/database_services/tagService.ts +++ b/database_services/tagService.ts @@ -1,6 +1,8 @@ -import { eq } from 'drizzle-orm'; +import { resolveTable } from '@/utils/dbUtils'; +import { and, asc, eq, like } from 'drizzle-orm'; import { asset_tag_link, quest_tag_link, tag } from '../db/drizzleSchema'; import { system } from '../db/powersync/system'; +import { tagCache } from './tagCache'; export type Tag = typeof tag.$inferSelect; @@ -48,6 +50,109 @@ export class TagService { return Promise.all(tagPromises); } + + // async getTagsByTagKey(tagKey: string, limit = 200) { + // // First get tag IDs from junction table + // const tags = await db + // .select() + // .from(tag) + // .where(eq(tag.key, tagKey)) + // .orderBy(asc(tag.value)) + // .limit(limit); + + // return tags; + // } + + async searchTags(searchTerm?: string, limit = 200) { + const whereCondition = searchTerm + ? and(eq(tag.active, true), like(tag.key, `${searchTerm}%`)) + : eq(tag.active, true); + const tags = await db + .select() + .from(tag) + .where(whereCondition) + .orderBy(asc(tag.key), asc(tag.value)) + .limit(limit); + + return tags; + } + + async getAllActiveTags(limit = 200) { + const tags = await db + .select() + .from(tag) + .where(eq(tag.active, true)) + .orderBy(asc(tag.key)) + .limit(limit); + + return tags; + } + + async preloadTagsIntoCache() { + const tags = await db + .select({ id: tag.id, key: tag.key, value: tag.value }) + .from(tag) + .where(eq(tag.active, true)) + // .orderBy(asc(tag.key), asc(tag.value)) + .limit(20000); + + for (const tagRecord of tags) { + tagCache.set(tagRecord.id, tagRecord); + } + } + + /** + * Assigns a list of tags to an asset. + * Deletes all existing tag assignments for the asset and creates new ones. + * @param asset_id The ID of the asset + * @param tag_ids Array of tag IDs to assign to the asset + */ + async assignTagsToAssetLocal(asset_id: string, tag_ids: string[]) { + try { + // Start a transaction to ensure atomicity + const result = await db.transaction(async (tx) => { + const contentLocal = resolveTable('asset_tag_link', { + localOverride: true + }); + // 1. Delete all existing tag assignments for this asset + await tx + .delete(contentLocal) + .where(eq(contentLocal.asset_id, asset_id)); + + // 2. Insert new tag assignments if any tag IDs are provided + if (tag_ids.length > 0) { + const newAssignments = tag_ids.map((tag_id) => ({ + asset_id, + tag_id + })); + + const newAssignmentsResult = await tx + .insert(contentLocal) + .values(newAssignments) + .returning(); + console.log( + `[TagService] New assignments result:`, + newAssignmentsResult + ); + } + + return { success: true, assigned_count: tag_ids.length }; + }); + + console.log( + `[TagService] Successfully assigned ${tag_ids.length} tags to asset ${asset_id}` + ); + return result; + } catch (error) { + console.error( + `[TagService] Failed to assign tags to asset ${asset_id}:`, + error + ); + throw new Error( + `Failed to assign tags to asset: ${error instanceof Error ? error.message : String(error)}` + ); + } + } } export const tagService = new TagService(); diff --git a/db/drizzleSchemaColumns.ts b/db/drizzleSchemaColumns.ts index 3123d623e..809a05edf 100644 --- a/db/drizzleSchemaColumns.ts +++ b/db/drizzleSchemaColumns.ts @@ -359,6 +359,7 @@ export function createAssetTable< content_type: text({ enum: contentTypeOptions }).default('source'), creator_id: text().references(() => profile.id), order_index: int().notNull().default(0), + metadata: text(), // JSON metadata for asset-specific data (e.g., verse range) ...extraColumns }, (table) => { diff --git a/hooks/db/useAssets.ts b/hooks/db/useAssets.ts index 663301fc1..212e0b8e0 100644 --- a/hooks/db/useAssets.ts +++ b/hooks/db/useAssets.ts @@ -9,7 +9,7 @@ import { tag } from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; -import type { WithSource } from '@/utils/dbUtils'; +import { useNetworkStatus } from '@/hooks/useNetworkStatus'; import { blockedContentQuery, blockedUsersQuery } from '@/utils/dbUtils'; import { getOptionShowHiddenContent } from '@/utils/settingsUtils'; import { @@ -17,6 +17,7 @@ import { useSimpleHybridInfiniteData } from '@/views/new/useHybridData'; import { toCompilableQuery } from '@powersync/drizzle-driver'; +import { useQuery } from '@tanstack/react-query'; import type { InferSelectModel, SQL } from 'drizzle-orm'; import { and, @@ -29,9 +30,10 @@ import { isNull, like, notInArray, - or + or, + sql } from 'drizzle-orm'; -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { createHybridQueryConfig, useHybridInfiniteQuery, @@ -1032,6 +1034,7 @@ export function useAssetsQuestLinkById( type AssetQuestLink = Asset & { quest_active: boolean; quest_visible: boolean; + tag_ids?: string[]; }; export function useAssetsByQuest( @@ -1081,7 +1084,12 @@ export function useAssetsByQuest( .select({ ...getTableColumns(asset), quest_visible: quest_asset_link.visible, - quest_active: quest_asset_link.active + quest_active: quest_asset_link.active, + tag_ids: sql`( + SELECT json_group_array(${asset_tag_link.tag_id}) + FROM ${asset_tag_link} + WHERE ${asset_tag_link.asset_id} = ${asset.id} + )` }) .from(asset) .innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id)) @@ -1094,7 +1102,34 @@ export function useAssetsByQuest( .limit(pageSize) .offset(offset); - return assets; + // Convert tag_ids from JSON string to array for consistency with cloud query + const processedAssets = assets.map((asset) => { + let tagIds: string[] = []; + try { + if (asset.tag_ids) { + const parsed = JSON.parse(String(asset.tag_ids)); + tagIds = Array.isArray(parsed) ? (parsed as string[]) : []; + } + if (asset.metadata) { + const parsed = JSON.parse(String(asset.metadata)); + asset.metadata = parsed as string | null; + } + } catch (error) { + console.warn( + '[useAssetsByQuest] Failed to parse tag_ids:', + asset.tag_ids, + error + ); + tagIds = []; + } + + return { + ...asset, + tag_ids: tagIds + } as AssetQuestLink; + }); + + return processedAssets; } catch (error) { console.error('[ASSETS] Offline query error:', error); return []; @@ -1118,7 +1153,8 @@ export function useAssetsByQuest( visible, active, asset:asset_id ( - * + *, + asset_tag_link(tag_id) ) ` ) @@ -1146,7 +1182,8 @@ export function useAssetsByQuest( query = query.filter('asset.name', 'ilike', `%${searchQuery.trim()}%`); } - // Order by order_index, then created_at, then name + // Order by created_at for cloud query + // Note: order_index ordering is handled client-side for cloud data query = query.order('created_at', { ascending: true }); // Add pagination @@ -1161,20 +1198,347 @@ export function useAssetsByQuest( if (error) throw error; // Map to AssetQuestLink format with quest_visible and quest_active - const assets: AssetQuestLink[] = data - .map((item) => { - if (!item.asset) return null; + const assets: AssetQuestLink[] = data.map((item) => { + // Extract tag IDs from asset_tag_link array + const assetWithTags = item.asset as Asset & { + asset_tag_link?: { tag_id: string }[]; + }; + const tag_ids: string[] = + assetWithTags.asset_tag_link?.map((link) => link.tag_id) || []; + + return { + ...item.asset, + quest_visible: item.visible, + quest_active: item.active, + tag_ids + } as AssetQuestLink; + }); + + return assets; + }, + 20 // pageSize + ); + + return { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isOnline, + isFetching, + refetch + }; +} + +export function useLocalAssetsByQuest( + quest_id: string, + searchQuery: string, + showHiddenContent: boolean +) { + const { currentUser } = useAuth(); + const isOnline = useNetworkStatus(); // πŸ”§ Get real network status + + // For local-only quests, use simple query (no pagination needed for ~200 records) + // This is wrapped to maintain API compatibility with infinite scroll structure + const simpleQuery = useQuery({ + queryKey: ['assets', 'by-quest-local-simple', quest_id || '', searchQuery], + queryFn: async () => { + if (!quest_id || !currentUser) return []; + + try { + const conditions = [ + isNull(asset.source_asset_id), + eq(quest_asset_link.quest_id, quest_id), + or( + !showHiddenContent ? eq(asset.visible, true) : undefined, + eq(asset.creator_id, currentUser.id) + ), + or( + !showHiddenContent ? eq(quest_asset_link.visible, true) : undefined, + eq(asset.creator_id, currentUser.id) + ), + notInArray(asset.id, blockedContentQuery(currentUser.id, 'asset')), + notInArray(asset.creator_id, blockedUsersQuery(currentUser.id)), + searchQuery.trim() && and(like(asset.name, `%${searchQuery.trim()}%`)) + ]; + + // Query all assets at once (no pagination for local data) + const assets = await system.db + .select({ + ...getTableColumns(asset), + quest_visible: quest_asset_link.visible, + quest_active: quest_asset_link.active, + tag_ids: sql`( + SELECT json_group_array(${asset_tag_link.tag_id}) + FROM ${asset_tag_link} + WHERE ${asset_tag_link.asset_id} = ${asset.id} + )` + }) + .from(asset) + .innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id)) + .where(and(...conditions.filter(Boolean))) + .orderBy( + asc(asset.order_index), + asc(asset.created_at), + asc(asset.name) + ); + // No .limit() or .offset() - fetch everything + + // Process tag_ids and metadata + const processedAssets = assets.map((asset) => { + let tagIds: string[] = []; + try { + if (asset.tag_ids) { + const parsed = JSON.parse(String(asset.tag_ids)); + tagIds = Array.isArray(parsed) ? (parsed as string[]) : []; + } + if (asset.metadata) { + const parsed = JSON.parse(String(asset.metadata)); + asset.metadata = parsed as string | null; + } + } catch (error) { + console.warn( + '[useLocalAssetsByQuest] Failed to parse tag_ids:', + asset.tag_ids, + error + ); + tagIds = []; + } + return { - ...item.asset, - quest_visible: item.visible, - quest_active: item.active + ...asset, + tag_ids: tagIds } as AssetQuestLink; - }) - .filter((item): item is AssetQuestLink => item !== null); + }); + + return processedAssets; + } catch (error) { + console.error('[useLocalAssetsByQuest] Query error:', error); + return []; + } + }, + enabled: !!quest_id && !!currentUser + }); + + // Wrap simple query result to match infinite query structure + // This maintains API compatibility with code expecting infinite scroll data + const wrappedData = React.useMemo(() => { + if (!simpleQuery.data) { + return { pages: [], pageParams: [] }; + } + // Wrap all data in a single page to match InfiniteData<{ data: T[] }> structure + return { + pages: [{ data: simpleQuery.data }], + pageParams: [0] + }; + }, [simpleQuery.data]); + + // Return structure compatible with useInfiniteQuery + return { + data: wrappedData, + fetchNextPage: () => + Promise.resolve({ + data: wrappedData, + pageParam: undefined + }), // No-op for local data (all loaded) + hasNextPage: false, // All data loaded in one query + isFetchingNextPage: false, + isLoading: simpleQuery.isLoading, + isOnline, // πŸ”§ Use real network status (needed for publish/bookmark buttons) + isFetching: simpleQuery.isFetching, + refetch: simpleQuery.refetch + }; +} + +/** + * Legacy infinite scroll version (not used, kept for reference) + * This was the old implementation using pagination for local data + * Now replaced with simple query above since local data is small (~200 records) + */ +/* +function _useLocalAssetsByQuestInfinite( + quest_id: string, + searchQuery: string, + showHiddenContent: boolean +) { + const { currentUser } = useAuth(); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isOnline, + isFetching, + refetch + } = useSimpleHybridInfiniteData( + 'assets', + ['by-quest', quest_id || '', searchQuery], + // Offline query function - Assets must be downloaded to use + async ({ pageParam, pageSize }) => { + if (!quest_id) return []; + + const limit = pageSize > 1000 ? pageSize : 1000; + + try { + const offset = pageParam * limit; + + const conditions = [ + isNull(asset.source_asset_id), + eq(quest_asset_link.quest_id, quest_id), + or( + !showHiddenContent ? eq(asset.visible, true) : undefined, + eq(asset.creator_id, currentUser!.id) + ), + or( + !showHiddenContent ? eq(quest_asset_link.visible, true) : undefined, + eq(asset.creator_id, currentUser!.id) + ), + notInArray(asset.id, blockedContentQuery(currentUser!.id, 'asset')), + notInArray(asset.creator_id, blockedUsersQuery(currentUser!.id)), + searchQuery.trim() && and(like(asset.name, `%${searchQuery.trim()}%`)) + ]; + + // Normal pagination without search + const assets = await system.db + .select({ + ...getTableColumns(asset), + quest_visible: quest_asset_link.visible, + quest_active: quest_asset_link.active, + tag_ids: sql`( + SELECT json_group_array(${asset_tag_link.tag_id}) + FROM ${asset_tag_link} + WHERE ${asset_tag_link.asset_id} = ${asset.id} + )` + }) + .from(asset) + .innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id)) + .where(and(...conditions.filter(Boolean))) + .orderBy( + asc(asset.order_index), + asc(asset.created_at), + asc(asset.name) + ) + .limit(limit) + .offset(offset); + + // Convert tag_ids from JSON string to array for consistency with cloud query + const processedAssets = assets.map((asset) => { + let tagIds: string[] = []; + try { + if (asset.tag_ids) { + const parsed = JSON.parse(String(asset.tag_ids)); + tagIds = Array.isArray(parsed) ? (parsed as string[]) : []; + } + if (asset.metadata) { + const parsed = JSON.parse(String(asset.metadata)); + asset.metadata = parsed as string | null; + } + } catch (error) { + console.warn( + '[useAssetsByQuest] Failed to parse tag_ids:', + asset.tag_ids, + error + ); + tagIds = []; + } + + return { + ...asset, + tag_ids: tagIds + } as AssetQuestLink; + }); + + return processedAssets; + } catch (error) { + console.error('[ASSETS] Offline query error:', error); + return []; + } + }, + // Cloud query function - For anonymous users, fetch assets directly from cloud + // For authenticated users, assets must be downloaded to use offline, but cloud query + // can still be used for browsing (anonymous-style access) + async ({ pageParam, pageSize }) => { + if (!quest_id) return []; + + const offset = pageParam * pageSize; + const from = offset; + const to = offset + pageSize - 1; + + // Build query from quest_asset_link to get both asset data and link metadata + let query = system.supabaseConnector.client + .from('quest_asset_link') + .select( + ` + visible, + active, + asset:asset_id ( + *, + asset_tag_link(tag_id) + ) + ` + ) + .eq('quest_id', quest_id) + .is('asset.source_asset_id', null); // Only get original assets, not variants + + // Filter by visibility - anonymous users can only see visible assets + // Authenticated users can see their own hidden assets if showHiddenContent is true + if (!showHiddenContent) { + // Show only visible assets + query = query.eq('visible', true).filter('asset.visible', 'eq', true); + } else if (currentUser?.id) { + // Show all assets, but still filter by link visibility for non-creators + // For creators, show all their assets even if hidden + query = query.or( + `visible.eq.true,asset.creator_id.eq.${currentUser.id}` + ); + } else { + // Anonymous users with showHiddenContent=true still only see visible (for safety) + query = query.eq('visible', true).filter('asset.visible', 'eq', true); + } + + // Add search filtering + if (searchQuery.trim()) { + query = query.filter('asset.name', 'ilike', `%${searchQuery.trim()}%`); + } + + // Order by created_at for cloud query + // Note: order_index ordering is handled client-side for cloud data + query = query.order('created_at', { ascending: true }); + + // Add pagination + const { data, error } = await query.range(from, to).overrideTypes< + { + visible: boolean; + active: boolean; + asset: Asset; + }[] + >(); + + if (error) throw error; + + // Map to AssetQuestLink format with quest_visible and quest_active + const assets: AssetQuestLink[] = data.map((item) => { + // Extract tag IDs from asset_tag_link array + const assetWithTags = item.asset as Asset & { + asset_tag_link?: { tag_id: string }[]; + }; + const tag_ids: string[] = + assetWithTags.asset_tag_link?.map((link) => link.tag_id) || []; + + return { + ...item.asset, + quest_visible: item.visible, + quest_active: item.active, + tag_ids + } as AssetQuestLink; + }); return assets; }, - 20 // pageSize + 1000 // pageSize ); return { @@ -1188,3 +1552,5 @@ export function useAssetsByQuest( refetch }; } +*/ +// End of legacy infinite scroll implementation (commented out) diff --git a/hooks/db/useSearchTags.ts b/hooks/db/useSearchTags.ts new file mode 100644 index 000000000..2806ffdb3 --- /dev/null +++ b/hooks/db/useSearchTags.ts @@ -0,0 +1,32 @@ +import type { Tag } from '@/database_services/tagService'; +import { tagService } from '@/database_services/tagService'; +import { useQuery } from '@tanstack/react-query'; + +// Re-export Tag type for convenience +export type { Tag }; + +/** + * Returns { tags, isLoading, error } + * Searches tags by key with optional limit + */ +export function useSearchTags({ + searchTerm, + maxResults = 20, + enabled = true +}: { + searchTerm?: string; + maxResults?: number; + enabled?: boolean; +}) { + const { + data: tags, + isLoading: isTagsLoading, + ...rest + } = useQuery({ + queryKey: ['tags', 'search', searchTerm, maxResults], + queryFn: () => tagService.searchTags(searchTerm, maxResults), + enabled + }); + + return { tags, isTagsLoading, ...rest }; +} diff --git a/hooks/useAppNavigation.ts b/hooks/useAppNavigation.ts index d6b980c79..eea33519f 100644 --- a/hooks/useAppNavigation.ts +++ b/hooks/useAppNavigation.ts @@ -31,7 +31,8 @@ export function useAppNavigation() { navigationStack, setNavigationStack, addRecentQuest, - addRecentAsset + addRecentAsset, + enableVerseMarkers } = useLocalStore(); // Ensure navigationStack is always an array - safe access pattern @@ -169,6 +170,10 @@ export function useAppNavigation() { questData?: Record; projectData?: Record; }) => { + const assetView = + questData.projectData?.template === 'bible' && enableVerseMarkers + ? 'bible-assets' + : 'assets'; // Track recently visited addRecentQuest({ id: questData.id, @@ -180,7 +185,7 @@ export function useAppNavigation() { // Check if we're already at this quest if ( currentState.questId === questData.id && - currentState.view === 'assets' + currentState.view === assetView ) { // Already here, do nothing return; @@ -191,11 +196,11 @@ export function useAppNavigation() { currentState.questId === questData.id && currentState.view === 'asset-detail' ) { - goBackToView('assets'); + goBackToView(assetView); } else { // Navigate fresh, pass data forward and preserve bookId (for Bible navigation) navigate({ - view: 'assets', + view: assetView, questId: questData.id, questName: questData.name, projectId: questData.project_id, @@ -205,7 +210,7 @@ export function useAppNavigation() { }); } }, - [currentState, navigate, addRecentQuest, goBackToView] + [currentState, navigate, addRecentQuest, goBackToView, enableVerseMarkers] ); const goToAsset = useCallback( @@ -329,7 +334,8 @@ export function useAppNavigation() { goToQuest({ id: state.questId!, project_id: state.projectId!, - name: state.questName + name: state.questName, + projectData: state.projectData }) }); crumbs.push({ label: state.assetName, onPress: undefined }); diff --git a/hooks/useTagStore.ts b/hooks/useTagStore.ts new file mode 100644 index 000000000..0eb6393d2 --- /dev/null +++ b/hooks/useTagStore.ts @@ -0,0 +1,86 @@ +import { tag } from '@/db/drizzleSchema'; +import { system } from '@/db/powersync/system'; +import { eq, inArray } from 'drizzle-orm'; +import { create } from 'zustand'; +import type { Tag } from '../database_services/tagCache'; +import { tagCache } from '../database_services/tagCache'; + +export type { Tag }; + +interface TagStore { + getTag: (id: string) => Tag | undefined; + getManyTags: (ids: string[]) => Tag[]; + fetchTag: (id: string) => Promise; + fetchManyTags: (ids: string[]) => Promise; +} + +export const useTagStore = create(() => ({ + // Sync version - uses cache only + getTag: (id) => tagCache.get(id), + + // Sync version - uses cache only + getManyTags: (ids) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!ids || ids.length === 0) return []; + + return ids.map((id) => tagCache.get(id)).filter(Boolean); + }, + + // Async version - fetches from database + fetchTag: async (id) => { + // Check cache first + const cached = tagCache.get(id); + if (cached) return cached; + + // Fetch from database + const results = await system.db + .select({ id: tag.id, key: tag.key, value: tag.value }) + .from(tag) + .where(eq(tag.id, id)) + .limit(1); + + const result = results[0]; + if (result) { + // Store in cache for future use + tagCache.set(result.id, result); + } + return result; + }, + + // Async version - fetches from database + fetchManyTags: async (ids) => { + if (ids.length === 0) return []; + + // Filter out IDs already in cache + const cachedTags: Tag[] = []; + const missingIds: string[] = []; + + for (const id of ids) { + const cached = tagCache.get(id); + if (cached) { + cachedTags.push(cached); + } else { + missingIds.push(id); + } + } + + // If all tags are cached, return them + if (missingIds.length === 0) { + return cachedTags; + } + + // Fetch missing tags from database + const fetchedTags = await system.db + .select({ id: tag.id, key: tag.key, value: tag.value }) + .from(tag) + .where(inArray(tag.id, missingIds)); + + // Store fetched tags in cache + for (const fetchedTag of fetchedTags) { + tagCache.set(fetchedTag.id, fetchedTag); + } + + // Return all tags (cached + fetched) + return [...cachedTags, ...fetchedTags]; + } +})); diff --git a/package-lock.json b/package-lock.json index a70d0bf69..f97d91321 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "react-native-reanimated": "~4.1.0", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", + "react-native-sortables": "^1.9.4", "react-native-svg": "^15.11.2", "react-native-url-polyfill": "^2.0.0", "react-native-uuid": "^2.0.3", @@ -23202,6 +23203,19 @@ "react-native": "*" } }, + "node_modules/react-native-haptic-feedback": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/react-native-haptic-feedback/-/react-native-haptic-feedback-2.3.3.tgz", + "integrity": "sha512-svS4D5PxfNv8o68m9ahWfwje5NqukM3qLS48+WTdhbDkNUkOhP9rDfDSRHzlhk4zq+ISjyw95EhLeh8NkKX5vQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "example" + ], + "peerDependencies": { + "react-native": ">=0.60.0" + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", @@ -23247,9 +23261,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.0.tgz", - "integrity": "sha512-L8FqZn8VjZyBaCUMYFyx1Y+T+ZTbblaudpxReOXJ66RnOf52g6UM4Pa/IjwLD1XAw1FUxLRQrtpdjbkEc74FiQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", + "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", @@ -23299,6 +23313,21 @@ "react-native": "*" } }, + "node_modules/react-native-sortables": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/react-native-sortables/-/react-native-sortables-1.9.4.tgz", + "integrity": "sha512-a6hxT+gl14HA5Sm8UiLXJqF8KMEQVa+mUJd75OnzoVsmrxUDtjAatlMdV0kI9qTQDT/ZSFLPRmdUhOR762IA4g==", + "license": "MIT", + "optionalDependencies": { + "react-native-haptic-feedback": ">=2.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-gesture-handler": ">=2.0.0", + "react-native-reanimated": ">=3.0.0" + } + }, "node_modules/react-native-svg": { "version": "15.12.1", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", diff --git a/package.json b/package.json index ad1f72778..c4b2f78d1 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "react-native-reanimated": "~4.1.0", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", + "react-native-sortables": "^1.9.4", "react-native-svg": "^15.11.2", "react-native-url-polyfill": "^2.0.0", "react-native-uuid": "^2.0.3", diff --git a/services/localizations.ts b/services/localizations.ts index 7e807a428..aee3fe518 100644 --- a/services/localizations.ts +++ b/services/localizations.ts @@ -3444,6 +3444,48 @@ export const localizations = { tok_pisin: 'Recording...', indonesian: 'Merekam...' }, + recordTo: { + english: 'Record to', + spanish: 'Grabar en', + brazilian_portuguese: 'Gravar em', + tok_pisin: 'Rekodem long', + indonesian: 'Rekam ke' + }, + noLabelSelected: { + english: 'No label selected', + spanish: 'Sin etiqueta seleccionada', + brazilian_portuguese: 'Nenhum rΓ³tulo selecionado', + tok_pisin: 'No label i stap', + indonesian: 'Tidak ada label dipilih' + }, + startRecordingSession: { + english: 'Start Recording Session', + spanish: 'Iniciar SesiΓ³n de GrabaciΓ³n', + brazilian_portuguese: 'Iniciar SessΓ£o de GravaΓ§Γ£o', + tok_pisin: 'Stat Rekodem Taim', + indonesian: 'Mulai Sesi Rekaman' + }, + typeToConfirm: { + english: 'Type {text} to confirm', + spanish: 'Escriba {text} para confirmar', + brazilian_portuguese: 'Digite {text} para confirmar', + tok_pisin: 'Raitim {text} bilong siaim', + indonesian: 'Ketik {text} untuk mengkonfirmasi' + }, + confirmDeletion: { + english: 'Confirm Deletion', + spanish: 'Confirmar EliminaciΓ³n', + brazilian_portuguese: 'Confirmar ExclusΓ£o', + tok_pisin: 'Siaim Rausim', + indonesian: 'Konfirmasi Penghapusan' + }, + deleting: { + english: 'Deleting...', + spanish: 'Eliminando...', + brazilian_portuguese: 'Excluindo...', + tok_pisin: 'Rausim nau...', + indonesian: 'Menghapus...' + }, audioSegments: { english: 'Audio Segments', spanish: 'Pistas de Audio', @@ -6110,7 +6152,23 @@ export const localizations = { tok_pisin: 'Link kopim igo long clipboard!', indonesian: 'Tautan disalin ke clipboard!' }, - + verseMarkers: { + english: 'Verse Labels', + spanish: 'Etiquetas de VersΓ­culos', + brazilian_portuguese: 'Etiquetas de VersΓ­culos', + tok_pisin: 'Verse Labels', + indonesian: 'Label Versi' + }, + verseMarkersDescription: { + english: 'Enable verse labels to help organize Bible resources', + spanish: + 'Habilitar etiquetas de versΓ­culos para ayudar a organizar recursos de la Biblia', + brazilian_portuguese: + 'Habilitar etiquetas de versΓ­culos para ajudar a organizar recursos da BΓ­blia', + tok_pisin: 'Enable verse labels to help organize Bible resources', + indonesian: + 'Aktifkan label versi untuk membantu mengorganisir sumber daya Alkitab' + }, // Languoid Link Suggestion strings languoidLinkSuggestionTitle: { english: 'Link your language?', diff --git a/store/localStore.ts b/store/localStore.ts index b396f09d4..8fc92b4e8 100644 --- a/store/localStore.ts +++ b/store/localStore.ts @@ -11,6 +11,7 @@ export type AppView = | 'quests' | 'assets' | 'asset-detail' + | 'bible-assets' | 'profile' | 'notifications' | 'settings' @@ -120,6 +121,10 @@ export interface LocalState { setEnablePlayAll: (enabled: boolean) => void; enableQuestExport: boolean; setEnableQuestExport: (enabled: boolean) => void; + enableVerseMarkers: boolean; + setEnableVerseMarkers: (enabled: boolean) => void; + verseMarkersFeaturePrompted: boolean; + setVerseMarkersFeaturePrompted: (prompted: boolean) => void; enableTranscription: boolean; setEnableTranscription: (enabled: boolean) => void; enableLanguoidLinkSuggestions: boolean; @@ -261,6 +266,8 @@ export const useLocalStore = create()( enableAiSuggestions: false, enablePlayAll: false, enableQuestExport: false, + enableVerseMarkers: false, + verseMarkersFeaturePrompted: false, enableTranscription: false, enableLanguoidLinkSuggestions: false, @@ -369,6 +376,9 @@ export const useLocalStore = create()( set({ enableAiSuggestions: enabled }), setEnablePlayAll: (enabled) => set({ enablePlayAll: enabled }), setEnableQuestExport: (enabled) => set({ enableQuestExport: enabled }), + setEnableVerseMarkers: (enabled) => set({ enableVerseMarkers: enabled }), + setVerseMarkersFeaturePrompted: (prompted) => + set({ verseMarkersFeaturePrompted: prompted }), setEnableTranscription: (enabled) => set({ enableTranscription: enabled }), setEnableLanguoidLinkSuggestions: (enabled) => diff --git a/supabase/migrations/20251214120000_add_asset_metadata_field.sql b/supabase/migrations/20251214120000_add_asset_metadata_field.sql new file mode 100644 index 000000000..b8686a0a9 --- /dev/null +++ b/supabase/migrations/20251214120000_add_asset_metadata_field.sql @@ -0,0 +1,11 @@ +-- Migration: Add metadata field to asset table +-- Version: 2.0 β†’ 2.1 +-- Purpose: Store JSON metadata for asset-specific data (e.g., verse ranges for Bible projects) + +-- Add metadata column to asset table +alter table asset + add column if not exists metadata text; + +-- Add comment describing the field +comment on column asset.metadata is 'JSON metadata for asset-specific data (e.g., {"verse": {"from": 1, "to": 3}})'; + diff --git a/views/AppView.tsx b/views/AppView.tsx index fae03eb51..82f7f15da 100644 --- a/views/AppView.tsx +++ b/views/AppView.tsx @@ -34,6 +34,7 @@ const NextGenAssetDetailView = React.lazy( const NextGenAssetsView = React.lazy( () => import('@/views/new/NextGenAssetsView') ); +const BibleAssetsView = React.lazy(() => import('@/views/new/BibleAssetsView')); const NextGenProjectsView = React.lazy( () => import('@/views/new/NextGenProjectsView') ); @@ -63,7 +64,8 @@ import { useLocalStore } from '@/store/localStore'; // import { OTAUpdateDebugControls } from '@/components/OTAUpdateDebugControls'; function AppViewContent() { - const { currentView, canGoBack, goBack, goToProjects } = useAppNavigation(); + const { currentView, canGoBack, goBack, goToProjects, goBackToView } = + useAppNavigation(); const { isAuthenticated } = useAuth(); const authView = useLocalStore((state) => state.authView); const setAuthView = useLocalStore((state) => state.setAuthView); @@ -76,6 +78,7 @@ function AppViewContent() { const setOnboardingIsOpen = useLocalStore( (state) => state.setOnboardingIsOpen ); + const enableVerseMarkers = useLocalStore((state) => state.enableVerseMarkers); const [drawerIsVisible, setDrawerIsVisible] = useState(false); const [deferredView, setDeferredView] = useState(currentView); const { isCloudLoading } = useCloudLoading(); @@ -162,6 +165,20 @@ function AppViewContent() { } }, [currentView, isAuthenticated, goToProjects]); + // Block bible-assets view if enableVerseMarkers is disabled + // Redirect to previous view if user tries to access bible-assets without the feature enabled + useEffect(() => { + if (currentView === 'bible-assets' && !enableVerseMarkers) { + // Redirect to previous view (usually quests or assets) + if (canGoBack) { + goBack(); + } else { + // Fallback to projects if no navigation history + goToProjects(); + } + } + }, [currentView, enableVerseMarkers, canGoBack, goBack, goToProjects]); + // Track if navigation is in progress const isNavigating = currentView !== deferredView; @@ -210,6 +227,8 @@ function AppViewContent() { return ; case 'assets': return ; + case 'bible-assets': + return ; case 'asset-detail': return ; case 'profile': diff --git a/views/SettingsView.tsx b/views/SettingsView.tsx index 36eb0ec4f..f8ec69487 100644 --- a/views/SettingsView.tsx +++ b/views/SettingsView.tsx @@ -53,6 +53,7 @@ export default function SettingsView() { ); const enablePlayAll = useLocalStore((state) => state.enablePlayAll); const enableQuestExport = useLocalStore((state) => state.enableQuestExport); + const enableVerseMarkers = useLocalStore((state) => state.enableVerseMarkers); const enableTranscription = useLocalStore( (state) => state.enableTranscription ); @@ -78,6 +79,9 @@ export default function SettingsView() { const setEnableQuestExport = useLocalStore( (state) => state.setEnableQuestExport ); + const setEnableVerseMarkers = useLocalStore( + (state) => state.setEnableVerseMarkers + ); const setEnableTranscription = useLocalStore( (state) => state.setEnableTranscription ); @@ -127,6 +131,10 @@ export default function SettingsView() { console.log('Quest export:', value); }; + const handleVerseMarkersToggle = (value: boolean) => { + setEnableVerseMarkers(value); + } + const handleTranscriptionToggle = (value: boolean) => { setEnableTranscription(value); }; @@ -284,6 +292,15 @@ export default function SettingsView() { disabled: !isOnline }, { + id: 'verseMarkers', + title: t('verseMarkers') || 'Verse Labels', + description: + t('verseMarkersDescription') || + 'Enable verse labels to help organize Bible resources', + type: 'toggle', + value: enableVerseMarkers, + onPress: () => handleVerseMarkersToggle(!enableVerseMarkers) + },{ id: 'transcription', title: t('transcription') || 'Transcription', description: diff --git a/views/new/AssetListItem.tsx b/views/new/AssetListItem.tsx index a4f4975b9..94d0e1964 100644 --- a/views/new/AssetListItem.tsx +++ b/views/new/AssetListItem.tsx @@ -8,14 +8,24 @@ import { import { Icon } from '@/components/ui/icon'; import { useAuth } from '@/contexts/AuthContext'; import { LayerType, useStatusContext } from '@/contexts/StatusContext'; +// import type { Tag } from '@/database_services/tagCache'; +// import { tagService } from '@/database_services/tagService'; import type { asset as asset_type } from '@/db/drizzleSchema'; import { useAppNavigation } from '@/hooks/useAppNavigation'; import { useLocalization } from '@/hooks/useLocalization'; +// import { useTagStore } from '@/hooks/useTagStore'; import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; import type { AttachmentRecord } from '@powersync/attachments'; -import { EyeOffIcon, HardDriveIcon, PauseIcon } from 'lucide-react-native'; +import { + EyeOffIcon, + HardDriveIcon, + PauseIcon + // Plus, + // TagIcon +} from 'lucide-react-native'; import React from 'react'; import { Pressable, View } from 'react-native'; +// import { TagModal } from '../../components/TagModal'; import { useItemDownload, useItemDownloadStatus } from './useHybridData'; // Define props locally to avoid require cycle @@ -25,10 +35,13 @@ type Asset = typeof asset_type.$inferSelect; type AssetQuestLink = Asset & { quest_active: boolean; quest_visible: boolean; + // tag_ids?: string[] | undefined; }; export interface AssetListItemProps { asset: AssetQuestLink; + isPublished: boolean; questId: string; + onUpdate?: () => void; attachmentState?: AttachmentRecord; isCurrentlyPlaying?: boolean; } @@ -36,8 +49,10 @@ export interface AssetListItemProps { export const AssetListItem: React.FC = ({ asset, questId, - attachmentState, - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + isPublished: _isPublished, + onUpdate: _onUpdate, + attachmentState: _attachmentState }) => { const { goToAsset, currentProjectData, currentQuestData } = useAppNavigation(); @@ -46,12 +61,57 @@ export const AssetListItem: React.FC = ({ // Check if asset is downloaded const isDownloaded = useItemDownloadStatus(asset, currentUser?.id); + // Tags temporarily disabled + // const fetchManyTags = useTagStore((s) => s.fetchManyTags); + // const [tags, setTags] = React.useState< + // { id: string; key: string; value?: string }[] + // >([]); + + // React.useEffect(() => { + // const loadTags = async () => { + // if (asset.tag_ids && asset.tag_ids.length > 0) { + // const fetchedTags = await fetchManyTags(asset.tag_ids); + // setTags(fetchedTags); + // } + // }; + // void loadTags(); + // }, [asset.tag_ids, fetchManyTags]); + // Download mutation const { mutate: downloadAsset, isPending: isDownloading } = useItemDownload( 'asset', asset.id ); + // Tag modal state - temporarily disabled + // const [isTagModalVisible, setIsTagModalVisible] = React.useState(false); + + // const handleOpenTagModal = () => { + // console.log('Opening tag modal for asset:', asset.id); + // setIsTagModalVisible(true); + // }; + + // const handleAssignTags = async (tags: Tag[]) => { + // try { + // // Extract tag IDs from the tags array + // const tagIds = tags.map((tag) => tag.id); + + // // Use the tagService to assign tags to the asset + // await tagService.assignTagsToAssetLocal(asset.id, tagIds); + + // onUpdate?.(); + + // console.log( + // `Successfully assigned ${tagIds.length} tags to asset ${asset.id}` + // ); + // } catch (error) { + // console.error('Failed to assign tags to asset:', error); + // // TODO: Show error toast/alert to user + // } finally { + // setIsTagModalVisible(false); + // } + // }; + const layerStatus = useStatusContext(); const { allowEditing, invisible } = layerStatus.getStatusParams( LayerType.ASSET, @@ -97,6 +157,8 @@ export const AssetListItem: React.FC = ({ downloadAsset({ userId: currentUser.id, download: !isDownloaded }); }; + // const tag = tags.length > 0 ? tags[0] : null; + return ( = ({ > - - + + {(!allowEditing || invisible) && ( {invisible && ( @@ -129,6 +191,33 @@ export const AssetListItem: React.FC = ({ + {/* Tags temporarily disabled */} + {/* + + {tags.length === 0 ? ( + !isPublished && ( + + + + + ) + ) : ( + + + + + {tag && `${tag.key}${tag.value && `: ${tag.value}`}`} + + + + )} + + */} = ({ */} + + {/* Tags temporarily disabled */} + {/* setIsTagModalVisible(false)} + onAssignTags={handleAssignTags} + /> */} ); }; diff --git a/views/new/BibleAssetListItem.tsx b/views/new/BibleAssetListItem.tsx new file mode 100644 index 000000000..f4ebcc190 --- /dev/null +++ b/views/new/BibleAssetListItem.tsx @@ -0,0 +1,460 @@ +import { DownloadIndicator } from '@/components/DownloadIndicator'; +// import { Badge } from '@/components/ui/badge'; +import { + Card, + CardDescription, + CardHeader, + CardTitle +} from '@/components/ui/card'; +import { Icon } from '@/components/ui/icon'; +import { useAuth } from '@/contexts/AuthContext'; +import { LayerType, useStatusContext } from '@/contexts/StatusContext'; +// import type { Tag } from '@/database_services/tagCache'; +// import { tagService } from '@/database_services/tagService'; +import type { asset as asset_type } from '@/db/drizzleSchema'; +import { useAppNavigation } from '@/hooks/useAppNavigation'; +import { useLocalization } from '@/hooks/useLocalization'; +// import { useTagStore } from '@/hooks/useTagStore'; +import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; +import type { AttachmentRecord } from '@powersync/attachments'; +import { + CheckSquareIcon, + EyeOffIcon, + GripVerticalIcon, + HardDriveIcon, + PauseIcon, + PencilLineIcon, + PlayIcon, + // Plus, + SquareIcon + // TagIcon +} from 'lucide-react-native'; +import React from 'react'; +import { Pressable, View } from 'react-native'; +import Sortable from 'react-native-sortables'; +// import { TagModal } from '../../components/TagModal'; +import { useItemDownload, useItemDownloadStatus } from './useHybridData'; + +// Define props locally to avoid require cycle + +type Asset = typeof asset_type.$inferSelect; + +type AssetQuestLink = Asset & { + quest_active: boolean; + quest_visible: boolean; + tag_ids?: string[] | undefined; +}; +export interface BibleAssetListItemProps { + asset: AssetQuestLink; + isPublished: boolean; + questId: string; + onUpdate?: () => void; + onPlay?: (assetId: string) => void | Promise; + attachmentState?: AttachmentRecord; + isCurrentlyPlaying?: boolean; + // Drag & Drop props (replaces dragHandle ReactNode) + showDragHandle?: boolean; // Whether to show drag handle (not in selection mode, not published) + isDragFixed?: boolean; // Whether this item has fixed drag order + // Selection mode props (batch operations like merge/delete) + isSelectionMode?: boolean; + isSelected?: boolean; + onToggleSelect?: (assetId: string) => void; + onEnterSelection?: (assetId: string) => void; + // Recording insertion point selection + isSelectedForRecording?: boolean; + onSelectForRecording?: (assetId: string) => void; + // Rename asset + onRename?: (assetId: string, currentName: string | null) => void; +} + +const BibleAssetListItemComponent: React.FC = ({ + asset, + questId, + isCurrentlyPlaying = false, + isPublished, + onUpdate: _onUpdate, + onPlay, + attachmentState: _attachmentState, + showDragHandle = false, + isDragFixed = false, + isSelectionMode = false, + isSelected = false, + onToggleSelect, + onEnterSelection, + isSelectedForRecording = false, + onSelectForRecording, + onRename +}) => { + const { goToAsset, currentProjectData, currentQuestData } = + useAppNavigation(); + const { currentUser } = useAuth(); + const { t } = useLocalization(); + // Check if asset is downloaded + const isDownloaded = useItemDownloadStatus(asset, currentUser?.id); + + // Tags functionality commented out + // const fetchManyTags = useTagStore((s) => s.fetchManyTags); + // const [tags, setTags] = React.useState< + // { id: string; key: string; value?: string }[] + // >([]); + + // React.useEffect(() => { + // const loadTags = async () => { + // if (asset.tag_ids && asset.tag_ids.length > 0) { + // const fetchedTags = await fetchManyTags(asset.tag_ids); + // setTags(fetchedTags); + // } + // }; + // void loadTags(); + // }, [asset.tag_ids, fetchManyTags]); + + // Download mutation + const { mutate: downloadAsset, isPending: isDownloading } = useItemDownload( + 'asset', + asset.id + ); + + // Tag modal state - commented out + // const [isTagModalVisible, setIsTagModalVisible] = React.useState(false); + + // const handleOpenTagModal = () => { + // console.log('Opening tag modal for asset:', asset.id); + // setIsTagModalVisible(true); + // }; + + // const handleAssignTags = async (tags: Tag[]) => { + // try { + // // Extract tag IDs from the tags array + // const tagIds = tags.map((tag) => tag.id); + + // // Use the tagService to assign tags to the asset + // await tagService.assignTagsToAssetLocal(asset.id, tagIds); + + // onUpdate?.(); + + // console.log( + // `Successfully assigned ${tagIds.length} tags to asset ${asset.id}` + // ); + // } catch (error) { + // console.error('Failed to assign tags to asset:', error); + // // TODO: Show error toast/alert to user + // } finally { + // setIsTagModalVisible(false); + // } + // }; + + const layerStatus = useStatusContext(); + const { allowEditing, invisible } = layerStatus.getStatusParams( + LayerType.ASSET, + asset.id || '', + { + visible: asset.visible && asset.quest_visible, + active: asset.active && asset.quest_active, + source: asset.source + }, + questId + ); + + const handlePress = () => { + // If in selection mode, toggle selection instead of navigating + if (isSelectionMode) { + onToggleSelect?.(asset.id); + return; + } + + // If not published, select for recording (toggle) + if (!isPublished) { + onSelectForRecording?.(asset.id); + return; + } + + layerStatus.setLayerStatus( + LayerType.ASSET, + { + visible: asset.visible, + active: asset.active, + quest_active: asset.quest_active, + quest_visible: asset.quest_visible, + source: asset.source + }, + asset.id, + questId + ); + + goToAsset({ + id: asset.id, + name: asset.name || t('unnamedAsset'), + questId: questId, + projectId: asset.project_id!, + projectData: currentProjectData, // Pass project data forward! + questData: currentQuestData // Pass quest data forward! + // NOTE: Don't pass assetData - the detail view needs full asset with content/audio + // relationships which aren't loaded in the list view + }); + }; + + const handleLongPress = () => { + // Enter selection mode on long press + if (!isSelectionMode && onEnterSelection) { + onEnterSelection(asset.id); + } + }; + + const handleDownloadToggle = () => { + if (!currentUser?.id) return; + + // Toggle download status + downloadAsset({ userId: currentUser.id, download: !isDownloaded }); + }; + + // Tags display - commented out + // const tag = tags.length > 0 ? tags[0] : null; + + // Create drag handle inside component (memoized for performance) + const dragHandle = React.useMemo(() => { + if (!showDragHandle) return null; + + return ( + + + + ); + }, [showDragHandle, isDragFixed]); + + // Render selection checkbox or drag handle + const selectionOrDragElement = isSelectionMode ? ( + onToggleSelect?.(asset.id)} + className="mr-1 flex h-7 w-7 items-center justify-center" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + ) : ( + dragHandle + ); + + return ( + + + + + + + {(!allowEditing || invisible) && ( + + {invisible && ( + + )} + {!allowEditing && ( + + )} + + )} + {selectionOrDragElement} + + {asset.source === 'local' && ( + + )} + {/* Play button - only show if onPlay is provided */} + {onPlay && ( + { + e.stopPropagation(); + void onPlay(asset.id); + }} + className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/20 active:bg-primary/40" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + )} + + {asset.name || t('unnamedAsset')} + + + + {/* Tags UI - commented out */} + {/* + + {tags.length === 0 ? ( + !isPublished && ( + + + + + ) + ) : ( + + + + + {tag && `${tag.key}${tag.value && `: ${tag.value}`}`} + + + + )} + + */} + {/* Show pencil button for local assets when not published, otherwise show download indicator */} + {!isPublished && onRename && asset.source === 'local' ? ( + { + e.stopPropagation(); + onRename(asset.id, asset.name); + }} + className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/20 active:bg-primary/40" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + ) : ( + + )} + + {SHOW_DEV_ELEMENTS && ( + + {`ID: ${asset.id.substring(0, 8)}...`} + + )} + + + {/* + + + */} + + + {/* TagModal - commented out */} + {/* setIsTagModalVisible(false)} + onAssignTags={handleAssignTags} + /> */} + + ); +}; + +/** + * Custom comparison function for React.memo + * Returns TRUE if props are EQUAL (skip re-render) + * Returns FALSE if props are DIFFERENT (re-render needed) + */ +const arePropsEqual = ( + prevProps: BibleAssetListItemProps, + nextProps: BibleAssetListItemProps +): boolean => { + // 1. Compare primitive props that affect visual rendering + if ( + prevProps.questId !== nextProps.questId || + prevProps.isPublished !== nextProps.isPublished || + prevProps.isCurrentlyPlaying !== nextProps.isCurrentlyPlaying || + prevProps.showDragHandle !== nextProps.showDragHandle || + prevProps.isDragFixed !== nextProps.isDragFixed || + prevProps.isSelectionMode !== nextProps.isSelectionMode || + prevProps.isSelected !== nextProps.isSelected || + prevProps.isSelectedForRecording !== nextProps.isSelectedForRecording + ) { + return false; // Props changed, need to re-render + } + + // 2. Compare asset object (only fields that affect UI) + const prevAsset = prevProps.asset; + const nextAsset = nextProps.asset; + + if ( + prevAsset.id !== nextAsset.id || + prevAsset.name !== nextAsset.name || + prevAsset.order_index !== nextAsset.order_index || + prevAsset.visible !== nextAsset.visible || + prevAsset.active !== nextAsset.active || + prevAsset.quest_visible !== nextAsset.quest_visible || + prevAsset.quest_active !== nextAsset.quest_active || + prevAsset.source !== nextAsset.source + ) { + return false; // Asset changed, need to re-render + } + + // 3. Compare metadata (small object, JSON.stringify is fast) + const prevMetadata = JSON.stringify(prevAsset.metadata); + const nextMetadata = JSON.stringify(nextAsset.metadata); + if (prevMetadata !== nextMetadata) { + return false; // Metadata changed, need to re-render + } + + // 4. Compare attachmentState (only the state field matters for UI) + const prevState = prevProps.attachmentState?.state; + const nextState = nextProps.attachmentState?.state; + if (prevState !== nextState) { + return false; // Attachment state changed, need to re-render + } + + // 5. Ignore function props (they're stable from Part 2 optimization) + // onUpdate, onPlay, onToggleSelect, onEnterSelection, onSelectForRecording, onRename + // These are ignored because they're memoized in the parent component + + return true; // Props are equal, skip re-render βœ… +}; + +/** + * Memoized BibleAssetListItem component + * Only re-renders when props that affect visual output change + */ +export const BibleAssetListItem = React.memo( + BibleAssetListItemComponent, + arePropsEqual +); + +BibleAssetListItem.displayName = 'BibleAssetListItem'; diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx new file mode 100644 index 000000000..4a46a0d49 --- /dev/null +++ b/views/new/BibleAssetsView.tsx @@ -0,0 +1,3741 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +import { AssetsDeletionDrawer } from '@/components/AssetsDeletionDrawer'; +import { QuestSettingsModal } from '@/components/QuestSettingsModal'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/ui/icon'; +import { Input } from '@/components/ui/input'; +import { + SpeedDial, + SpeedDialItem, + SpeedDialItems, + SpeedDialTrigger +} from '@/components/ui/speed-dial'; +import { Text } from '@/components/ui/text'; +import { useAudio } from '@/contexts/AudioContext'; +import { useAuth } from '@/contexts/AuthContext'; +import { LayerType, useStatusContext } from '@/contexts/StatusContext'; +import type { asset } from '@/db/drizzleSchema'; +import { + asset_content_link, + project, + quest as questTable +} from '@/db/drizzleSchema'; +import { system } from '@/db/powersync/system'; +import { useDebouncedState } from '@/hooks/use-debounced-state'; +import { + useAppNavigation, + useCurrentNavigation +} from '@/hooks/useAppNavigation'; +import { useAttachmentStates } from '@/hooks/useAttachmentStates'; +import { useLocalization } from '@/hooks/useLocalization'; +import { useQuestDownloadStatusLive } from '@/hooks/useQuestDownloadStatusLive'; +import { useUserPermissions } from '@/hooks/useUserPermissions'; +import { useLocalStore } from '@/store/localStore'; +import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; +import RNAlert from '@blazejkustra/react-native-alert'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Audio } from 'expo-av'; +import { + BookmarkPlusIcon, + BrushCleaning, + CheckCheck, + ChevronRight, + CloudUpload, + FlagIcon, + InfoIcon, + LockIcon, + MicIcon, + PauseIcon, + PlayIcon, + RefreshCwIcon, + SearchIcon, + SettingsIcon, + UserPlusIcon +} from 'lucide-react-native'; +import React from 'react'; +import { ActivityIndicator, Pressable, View } from 'react-native'; +import Animated, { + cancelAnimation, + Easing, + runOnJS, + useAnimatedRef, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useHybridData } from './useHybridData'; + +import { AssetListSkeleton } from '@/components/AssetListSkeleton'; +import { ExportButton } from '@/components/ExportButton'; +import { ModalDetails } from '@/components/ModalDetails'; +import { ReportModal } from '@/components/NewReportModal'; +import { PrivateAccessGate } from '@/components/PrivateAccessGate'; +import { QuestOffloadVerificationDrawer } from '@/components/QuestOffloadVerificationDrawer'; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerScrollView, + DrawerTitle +} from '@/components/ui/drawer'; +import { VerseAssigner } from '@/components/VerseAssigner'; +import { VerseRangeSelector } from '@/components/VerseRangeSelector'; +import { VerseSeparator } from '@/components/VerseSeparator'; +import { BIBLE_BOOKS } from '@/constants/bibleStructure'; +import type { AssetUpdatePayload } from '@/database_services/assetService'; +import { + batchUpdateAssetMetadata, + renameAsset +} from '@/database_services/assetService'; +import { audioSegmentService } from '@/database_services/audioSegmentService'; +import { AppConfig } from '@/db/supabase/AppConfig'; +import { useAssetsByQuest, useLocalAssetsByQuest } from '@/hooks/db/useAssets'; +import { useBlockedAssetsCount } from '@/hooks/useBlockedCount'; +import { useQuestOffloadVerification } from '@/hooks/useQuestOffloadVerification'; +import { useHasUserReported } from '@/hooks/useReports'; +import { resolveTable } from '@/utils/dbUtils'; +import { fileExists, getLocalAttachmentUriWithOPFS } from '@/utils/fileUtils'; +import { publishQuest as publishQuestUtils } from '@/utils/publishUtils'; +import { offloadQuest } from '@/utils/questOffloadUtils'; +import { getThemeColor } from '@/utils/styleUtils'; +import { toCompilableQuery } from '@powersync/drizzle-driver'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { and, asc, eq, gte, lte } from 'drizzle-orm'; +import { ScrollView as GHScrollView } from 'react-native-gesture-handler'; +import Sortable from 'react-native-sortables'; +import { BibleAssetListItem } from './BibleAssetListItem'; +import BibleRecordingView from './recording/components/BibleRecordingView'; +import { BibleSelectionControls } from './recording/components/BibleSelectionControls'; +import { RenameAssetDrawer } from './recording/components/RenameAssetDrawer'; +import { useSelectionMode } from './recording/hooks/useSelectionMode'; +// import RecordingViewSimplified from './recording/components/RecordingViewSimplified'; + +type Asset = typeof asset.$inferSelect; + +interface AssetMetadata { + verse?: { + from: number; + to: number; + }; +} + +type AssetQuestLink = Asset & { + quest_active: boolean; + quest_visible: boolean; + tag_ids?: string[] | undefined; + metadata?: AssetMetadata | null; +}; + +// List item types for rendering +interface ListItemAsset { + type: 'asset'; + content: AssetQuestLink; + key: string; +} + +interface ListItemSeparator { + type: 'separator'; + from?: number; + to?: number; + key: string; +} + +type ListItem = ListItemAsset | ListItemSeparator; + +// Manual separator type used for verse grouping +interface ManualSeparator { + from: number; + to: number; + key: string; + assetId?: string; +} + +const RecordingPlaceIndicator = () => ( + + {/* */} + + REC + +); + +// ============================================================================ +// HELPER FUNCTIONS (moved outside component for better performance) +// ============================================================================ + +/** + * Builds the final list of items (assets + separators) for rendering. + * This is extracted as a pure function to avoid recreation on each render. + */ +function buildFinalList( + assetsWithMeta: AssetQuestLink[], + assetsWithoutMeta: AssetQuestLink[], + separatorsWithAssetId: ManualSeparator[], + sortedSeparatorsWithoutAssetId: ManualSeparator[], + allManualSeparators: ManualSeparator[] +): ListItem[] { + // Build list with auto-generated separators + assets with metadata + const result: ListItem[] = []; + let currentFrom: number | undefined; + let currentTo: number | undefined; + + for (const asset of assetsWithMeta) { + const from = asset.metadata?.verse?.from; + const to = asset.metadata?.verse?.to; + + // Add separator when verse range changes + if (from !== currentFrom || to !== currentTo) { + result.push({ + type: 'separator', + from, + to, + key: `sep-${from}-${to}` + }); + currentFrom = from; + currentTo = to; + } + + result.push({ + type: 'asset', + content: asset, + key: asset.id + }); + } + + // Build unassigned block (assets without verse metadata) + const unassignedBlock: ListItem[] = []; + if (assetsWithoutMeta.length > 0) { + unassignedBlock.push({ + type: 'separator', + key: 'sep-unassigned' + }); + + for (const asset of assetsWithoutMeta) { + unassignedBlock.push({ + type: 'asset', + content: asset, + key: asset.id + }); + } + } + + // Insert separators that target a specific asset + for (const sep of separatorsWithAssetId) { + if (!sep.assetId) continue; + + const assetIndex = result.findIndex( + (item) => item.type === 'asset' && item.content.id === sep.assetId + ); + + const sepItem: ListItemSeparator = { + type: 'separator', + from: sep.from, + to: sep.to, + key: sep.key + }; + + if (assetIndex !== -1) { + result.splice(assetIndex, 0, sepItem); + } else { + // Asset is in unassignedBlock, insert at end of result + result.push(sepItem); + } + } + + // Insert separators without assetId by verse order + for (const sep of sortedSeparatorsWithoutAssetId) { + const sepItem: ListItemSeparator = { + type: 'separator', + from: sep.from, + to: sep.to, + key: sep.key + }; + + let insertIdx = result.findIndex( + (item) => + item.type === 'separator' && + item.from !== undefined && + sep.from < item.from + ); + if (insertIdx === -1) { + insertIdx = result.length; + } + result.splice(insertIdx, 0, sepItem); + } + + // Combine: result + unassigned block + const combined: ListItem[] = [...result, ...unassignedBlock]; + + // Build set of manual separator ranges for deduplication + const manualSeparatorRanges = new Set(); + const manualSeparatorKeys = new Set(); + for (const sep of allManualSeparators) { + manualSeparatorRanges.add(`${sep.from ?? 'none'}-${sep.to ?? 'none'}`); + manualSeparatorKeys.add(sep.key); + } + + // Deduplicate separators (prefer manual over auto-generated) + const seenSeparatorRanges = new Set(); + const deduped: ListItem[] = []; + + for (const item of combined) { + if (item.type === 'separator') { + const sepRange = `${item.from ?? 'none'}-${item.to ?? 'none'}`; + const isManualSeparator = manualSeparatorKeys.has(item.key); + const hasManualSeparatorForRange = manualSeparatorRanges.has(sepRange); + + // Skip if we've already seen this range + if (seenSeparatorRanges.has(sepRange)) { + continue; + } + + // Skip auto-generated if manual exists for this range + if (!isManualSeparator && hasManualSeparatorForRange) { + continue; + } + + seenSeparatorRanges.add(sepRange); + } + deduped.push(item); + } + + return deduped; +} + +export default function BibleAssetsView() { + const { + currentQuestId, + currentProjectId, + currentProjectData, + currentQuestData, + currentBookId + } = useCurrentNavigation(); + const { goBack } = useAppNavigation(); + const { currentUser } = useAuth(); + const audioContext = useAudio(); + const queryClient = useQueryClient(); + const insets = useSafeAreaInsets(); + + // Selection mode for batch operations + const { + isSelectionMode, + selectedAssetIds, + enterSelection, + toggleSelect, + cancelSelection + } = useSelectionMode(); + const [debouncedSearchQuery, searchQuery, setSearchQuery] = useDebouncedState( + '', + 300 + ); + const { t } = useLocalization(); + const [showDetailsModal, setShowDetailsModal] = React.useState(false); + const [showSettingsModal, setShowSettingsModal] = React.useState(false); + const [showReportModal, setShowReportModal] = React.useState(false); + const [showOffloadDrawer, setShowOffloadDrawer] = React.useState(false); + const [showDeleteAllDrawer, setShowDeleteAllDrawer] = React.useState(false); + const [verseSelectorState, setVerseSelectorState] = React.useState<{ + isOpen: boolean; + key: string | null; + from?: number; + to?: number; + }>({ isOpen: false, key: null }); + + // State for adding new label (not editing existing) + const [newLabelSelectorState, setNewLabelSelectorState] = React.useState<{ + isOpen: boolean; + from?: number; + to?: number; + }>({ isOpen: false }); + + // State for adding verse label above a specific asset + const [assetVerseSelectorState, setAssetVerseSelectorState] = React.useState<{ + isOpen: boolean; + assetId: string | null; + from?: number; + to?: number; + }>({ isOpen: false, assetId: null }); + + // State for editing an existing separator + const [editSeparatorState, setEditSeparatorState] = React.useState<{ + isOpen: boolean; + separatorKey: string | null; + from?: number; + to?: number; + }>({ isOpen: false, separatorKey: null }); + + // State for renaming assets + const [showRenameDrawer, setShowRenameDrawer] = React.useState(false); + const [renameAssetId, setRenameAssetId] = React.useState(null); + const [renameAssetName, setRenameAssetName] = React.useState(''); + + // State for batch verse assignment + const [showVerseAssignerDrawer, setShowVerseAssignerDrawer] = + React.useState(false); + + // Manual verse separators created by the user + const [manualSeparators, setManualSeparators] = React.useState< + { from: number; to: number; key: string; assetId?: string }[] + >([]); + + // Track which separators have been processed for auto-assignment + const processedSeparatorsRef = React.useRef>(new Set()); + + // Function to add a new verse separator + // If assetId is provided, insert the separator right above that asset + const addVerseSeparator = React.useCallback( + (from: number, to: number, assetId?: string) => { + const newSeparator = { + from, + to, + key: `manual-sep-${from}-${to}-${Date.now()}`, + assetId // Store assetId to know where to insert it + }; + setManualSeparators((prev) => [...prev, newSeparator]); + }, + [] + ); + + const [showPrivateAccessModal, setShowPrivateAccessModal] = + React.useState(false); + const [isOffloading, setIsOffloading] = React.useState(false); + const [isRefreshing, setIsRefreshing] = React.useState(false); + // Track which asset is currently playing during play-all + const [currentlyPlayingAssetId, setCurrentlyPlayingAssetId] = React.useState< + string | null + >(null); + const assetUriMapRef = React.useRef>(new Map()); // URI -> assetId + const assetOrderRef = React.useRef([]); // Ordered list of asset IDs + const uriOrderRef = React.useRef([]); // Ordered list of URIs matching assetOrderRef + const segmentDurationsRef = React.useRef([]); // Duration of each URI segment in ms + const fixedItemsIndexesRef = React.useRef([0]); + // Ref to allow handlePlayAsset to be used in renderItem before it's defined + const handlePlayAssetRef = React.useRef< + (assetId: string) => void | Promise + >((_assetId: string) => { + // No-op: will be replaced by handlePlayAsset when defined + }); + + const scrollableRef = useAnimatedRef(); + + // Animation for refresh button + const spinValue = useSharedValue(0); + + React.useEffect(() => { + if (isRefreshing) { + spinValue.value = withRepeat( + withTiming(1, { duration: 1000, easing: Easing.linear }), + -1 + ); + } else { + cancelAnimation(spinValue); + spinValue.value = 0; + } + }, [isRefreshing, spinValue]); + + const spinStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${spinValue.value * 360}deg` }] + })); + + type Quest = typeof questTable.$inferSelect; + + // Use passed quest data if available (instant!), otherwise query + const { data: queriedQuestData, refetch: refetchQuest } = useHybridData({ + dataType: 'current-quest', + queryKeyParams: [currentQuestId], + offlineQuery: toCompilableQuery( + system.db.query.quest.findFirst({ + where: eq(questTable.id, currentQuestId!) + }) + ), + cloudQueryFn: async () => { + const { data, error } = await system.supabaseConnector.client + .from('quest') + .select('*') + .eq('id', currentQuestId) + .overrideTypes(); + if (error) throw error; + return data; + }, + enableCloudQuery: !!currentQuestId, + enableOfflineQuery: !!currentQuestId, + getItemId: (item) => item.id + }); + + // Prefer queried data (fresh) over navigation data (may be stale) + // This ensures UI updates immediately after publishing without needing to navigate away + const selectedQuest = React.useMemo(() => { + // If we have queried data, prefer it (it's fresh from refetch) + // Otherwise fall back to currentQuestData for instant initial rendering + const questData = + queriedQuestData && queriedQuestData.length > 0 + ? queriedQuestData + : currentQuestData + ? [currentQuestData as Quest] + : undefined; + return questData?.[0]; + }, [currentQuestData, queriedQuestData]); + + // Check if quest is published (source is 'synced') + const isPublished = selectedQuest?.source === 'synced'; + + // Store book name and chapter number for VerseSeparator label + const bookChapterLabelRef = React.useRef('Verse'); + + // Calculate book chapter label (short name for separators) + const bookChapterLabel = React.useMemo(() => { + if (!selectedQuest || !currentBookId) { + return 'Verse'; + } + + // Extract chapter number from metadata.bible.chapter + let chapterNum: number | undefined; + if (selectedQuest.metadata) { + try { + const metadata: unknown = + typeof selectedQuest.metadata === 'string' + ? JSON.parse(selectedQuest.metadata) + : selectedQuest.metadata; + if ( + metadata && + typeof metadata === 'object' && + 'bible' in metadata && + metadata.bible && + typeof metadata.bible === 'object' && + 'chapter' in metadata.bible + ) { + chapterNum = + typeof metadata.bible.chapter === 'number' + ? metadata.bible.chapter + : undefined; + } + } catch { + // Ignore parse errors + } + } + + if (typeof chapterNum !== 'number') return 'Verse'; + const book = BIBLE_BOOKS.find((b) => b.id === currentBookId); + + if (book?.name && chapterNum) { + return `${book.shortName} ${chapterNum}`; + } + + return 'Verse'; + }, [selectedQuest, currentBookId]); + + // Update ref when label changes + React.useEffect(() => { + bookChapterLabelRef.current = bookChapterLabel; + }, [bookChapterLabel]); + + // Get verse count for current chapter + // Use selectedQuest instead of currentQuestData to ensure we have the metadata from the database + const verseCount = React.useMemo(() => { + if (!selectedQuest || !currentBookId) return 0; + + // Extract chapter number from metadata.bible.chapter + let chapterNum: number | undefined; + if (selectedQuest.metadata) { + try { + const metadata: unknown = + typeof selectedQuest.metadata === 'string' + ? JSON.parse(selectedQuest.metadata) + : selectedQuest.metadata; + if ( + metadata && + typeof metadata === 'object' && + 'bible' in metadata && + metadata.bible && + typeof metadata.bible === 'object' && + 'chapter' in metadata.bible + ) { + chapterNum = + typeof metadata.bible.chapter === 'number' + ? metadata.bible.chapter + : undefined; + } + } catch { + // Ignore parse errors + } + } + + if (typeof chapterNum !== 'number') return 0; + const book = BIBLE_BOOKS.find((b) => b.id === currentBookId); + return book?.verses[chapterNum - 1] ?? 0; + }, [selectedQuest, currentBookId]); + + // Query project data to get privacy status if not passed + const { data: queriedProjectData } = useHybridData({ + dataType: 'project-privacy-assets', + queryKeyParams: [currentProjectId], + offlineQuery: toCompilableQuery( + system.db.query.project.findFirst({ + where: eq(project.id, currentProjectId!), + columns: { id: true, private: true, creator_id: true } + }) + ), + cloudQueryFn: async () => { + if (!currentProjectId) return []; + const { data, error } = await system.supabaseConnector.client + .from('project') + .select('id, private, creator_id') + .eq('id', currentProjectId); + if (error) throw error; + return data as Pick< + typeof project.$inferSelect, + 'id' | 'private' | 'creator_id' + >[]; + }, + enableCloudQuery: !!currentProjectId && !currentProjectData, + enableOfflineQuery: !!currentProjectId && !currentProjectData, + getItemId: (item) => item.id + }); + + // Prefer passed project data for instant rendering + const projectPrivacyData = currentProjectData + ? { + private: currentProjectData.private, + creator_id: currentProjectData.creator_id + } + : queriedProjectData?.[0]; + const isPrivateProject = projectPrivacyData?.private ?? false; + + const [showRecording, setShowRecording] = React.useState(false); + + // Track selected item for recording insertion + // Can be an asset (insert after) or a separator (insert at beginning of verse) + const [selectedForRecording, setSelectedForRecording] = React.useState<{ + type: 'asset' | 'separator'; + assetId?: string; // Only for type === 'asset' + separatorKey?: string; // Only for type === 'separator' + orderIndex: number; + metadata: AssetMetadata | null; + verseName: string; // e.g., "1:5" or "1:5-7" + } | null>(null); + + const { membership } = useUserPermissions( + currentProjectId || '', + 'open_project', + !!isPrivateProject + ); + + const isOwner = membership === 'owner'; + const isMember = membership === 'member' || membership === 'owner'; + // Check if user is creator + const isCreator = currentUser?.id === projectPrivacyData?.creator_id; + // User can see published badge if they are creator, member, or owner + const canSeePublishedBadge = isCreator || isMember; + + // Initialize offload verification hook + const verificationState = useQuestOffloadVerification(currentQuestId || ''); + + // Query SQLite directly - single source of truth, no cache, no race conditions + const isQuestDownloaded = useQuestDownloadStatusLive(currentQuestId || null); + + // Clean deeper layers + const currentStatus = useStatusContext(); + currentStatus.layerStatus(LayerType.QUEST, currentQuestId || ''); + const showInvisibleContent = useLocalStore((s) => s.showHiddenContent); + + // Call both hooks unconditionally to comply with React Hooks rules + const publishedAssets = useAssetsByQuest( + currentQuestId || '', + debouncedSearchQuery, + showInvisibleContent + ); + const localAssets = useLocalAssetsByQuest( + currentQuestId || '', + debouncedSearchQuery, + showInvisibleContent + ); + + // Use the appropriate hook result based on isPublished condition + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isOnline, + isFetching, + refetch + //} = publishedAssets; + } = isPublished ? publishedAssets : localAssets; + + // Flatten all pages into a single array and deduplicate + // Prefer synced over local when the same asset ID appears in both + const assets = React.useMemo(() => { + const allAssets = data.pages.flatMap((page) => page.data); + const assetMap = new Map(); + + // First pass: collect all assets, preferring synced over local + for (const asset of allAssets) { + const existing = assetMap.get(asset.id); + if (!existing) { + assetMap.set(asset.id, asset); + } else { + // Prefer synced over local + if (asset.source === 'synced' && existing.source !== 'synced') { + assetMap.set(asset.id, asset); + } + } + } + + return Array.from(assetMap.values()); + }, [data.pages]); + + // Infinite scroll - load more when reaching end of list + const loadMoreAssets = React.useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + 'worklet'; + const { layoutMeasurement, contentOffset, contentSize } = event; + const paddingToBottom = 200; // pixels before end to trigger loading + + const isCloseToBottom = + layoutMeasurement.height + contentOffset.y >= + contentSize.height - paddingToBottom; + + if (isCloseToBottom) { + runOnJS(loadMoreAssets)(); + } + } + }); + + // ============================================================================ + // OPTIMIZED LIST BUILDING - Split into smaller memoized steps + // ============================================================================ + + // Step 1: Separate and sort assets with metadata (only recomputes when assets change) + const assetsWithMeta = React.useMemo(() => { + const filtered = assets.filter((a) => a.metadata?.verse?.from != null); + // Sort by verse.from first, then by order_index within each verse group + // This preserves the user's ordering within each verse + return [...filtered].sort((a, b) => { + const aFrom = a.metadata?.verse?.from ?? 0; + const bFrom = b.metadata?.verse?.from ?? 0; + if (aFrom !== bFrom) { + return aFrom - bFrom; + } + // Same verse - sort by order_index to maintain user's ordering + return (a.order_index ?? 0) - (b.order_index ?? 0); + }); + }, [assets]); + + // Step 2: Get assets without metadata (only recomputes when assets change) + // Sorted by order_index to maintain user's ordering + const assetsWithoutMeta = React.useMemo(() => { + return assets + .filter((a) => a.metadata?.verse?.from == null) + .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)); + }, [assets]); + + // Calculate the last order_index for unassigned assets (verse 999) + // This is used when opening BibleRecordingView without a selected verse + // to continue from where we left off instead of starting from DEFAULT_ORDER_INDEX + const lastUnassignedOrderIndex = React.useMemo(() => { + if (assetsWithoutMeta.length === 0) { + return undefined; // No unassigned assets, use default + } + // Get the highest order_index from unassigned assets + const lastAsset = assetsWithoutMeta[assetsWithoutMeta.length - 1]; + return lastAsset?.order_index; + }, [assetsWithoutMeta]); + + // Step 3: Split manual separators by type (only recomputes when separators change) + const separatorsWithAssetId = React.useMemo(() => { + return manualSeparators.filter((sep) => sep.assetId); + }, [manualSeparators]); + + const sortedSeparatorsWithoutAssetId = React.useMemo(() => { + return manualSeparators + .filter((sep) => !sep.assetId) + .sort((a, b) => a.from - b.from); + }, [manualSeparators]); + + // Step 4: Build final list using pure function (recomputes only when dependencies change) + const listItems = React.useMemo((): ListItem[] => { + return buildFinalList( + assetsWithMeta, + assetsWithoutMeta, + separatorsWithAssetId, + sortedSeparatorsWithoutAssetId, + manualSeparators + ); + }, [ + assetsWithMeta, + assetsWithoutMeta, + separatorsWithAssetId, + sortedSeparatorsWithoutAssetId, + manualSeparators + ]); + + // Keep a ref to assets for stable callback (avoids recreating on every asset change) + const assetsRef = React.useRef(assets); + React.useEffect(() => { + assetsRef.current = assets; + }, [assets]); + + // Handler for selecting/deselecting an asset for recording insertion + // Optimized with ref to avoid recreation on every asset change + const handleSelectForRecording = React.useCallback( + (assetId: string) => { + // Toggle: if same asset clicked, deselect + if ( + selectedForRecording?.type === 'asset' && + selectedForRecording?.assetId === assetId + ) { + setSelectedForRecording(null); + return; + } + + // Find the asset using ref (stable across renders) + const asset = assetsRef.current.find((a) => a.id === assetId); + + if (!asset) { + console.warn('Asset not found:', assetId); + return; + } + + const metadata = asset.metadata as AssetMetadata | null; + const orderIndex = asset.order_index ?? 0; + + // Build verse name from metadata + let verseName = ''; + if (metadata?.verse) { + const { from, to } = metadata.verse; + if (from === to || to === undefined) { + verseName = `${from}`; + } else { + verseName = `${from}-${to}`; + } + } + + setSelectedForRecording({ + type: 'asset', + assetId, + orderIndex, + metadata, + verseName + }); + }, + [selectedForRecording?.type, selectedForRecording?.assetId] + ); + + // Handler for selecting/deselecting a separator for recording insertion + // When a separator is selected, recordings start at the BEGINNING of that verse + // order_index = verse * 1000 * 1000 (e.g., verse 7 β†’ 7000000) + const handleSelectSeparatorForRecording = React.useCallback( + (separatorKey: string, from?: number, to?: number) => { + // Toggle: if same separator clicked, deselect + if ( + selectedForRecording?.type === 'separator' && + selectedForRecording?.separatorKey === separatorKey + ) { + setSelectedForRecording(null); + return; + } + + // Calculate order_index: verse * 1000 * 1000 to position BEFORE first asset + // For unassigned (sep-unassigned), use 999 + const verse = from ?? 999; + const orderIndex = verse * 1000 * 1000; + + // Build verse name + let verseName = ''; + if (from !== undefined) { + if (from === to || to === undefined) { + verseName = `${from}`; + } else { + verseName = `${from}-${to}`; + } + } + + // Build metadata + const metadata: AssetMetadata | null = + from !== undefined ? { verse: { from, to: to ?? from } } : null; + + setSelectedForRecording({ + type: 'separator', + separatorKey, + orderIndex, + metadata, + verseName + }); + + }, + [selectedForRecording?.type, selectedForRecording?.separatorKey] + ); + + // Handle batch delete of selected assets + // Handle delete all assets + const handleDeleteAllAssets = React.useCallback(async () => { + if (!currentQuestId) return; + + // Filter assets that are local (not cloud-only) + const localAssets = assets.filter((a) => a.source !== 'cloud'); + + if (localAssets.length < 1) { + RNAlert.alert(t('info'), 'No local assets to delete.'); + return; + } + + try { + console.log(`πŸ—‘οΈ Starting deletion of ${localAssets.length} assets...`); + + for (const asset of localAssets) { + await audioSegmentService.deleteAudioSegment(asset.id); + } + + // Reset the name counter for this quest + const counterKey = `bible_recording_counter_${currentQuestId}`; + await AsyncStorage.removeItem(counterKey); + + setSelectedForRecording(null); + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + + console.log( + `βœ… Delete all completed: ${localAssets.length} assets deleted` + ); + RNAlert.alert( + t('success'), + `${localAssets.length} assets deleted successfully.` + ); + } catch (e) { + console.error('Failed to delete all assets', e); + RNAlert.alert(t('error'), 'Failed to delete assets. Please try again.'); + } + }, [assets, currentQuestId, queryClient, t, refetch]); + + const handleBatchDeleteSelected = React.useCallback(() => { + // Filter selected assets that are local (not cloud-only) + const selectedAssets = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + + if (selectedAssets.length < 1) return; + + RNAlert.alert( + 'Delete Assets', + `Are you sure you want to delete ${selectedAssets.length} asset${selectedAssets.length > 1 ? 's' : ''}? This action cannot be undone.`, + [ + { + text: t('cancel'), + style: 'cancel' + }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + void (async () => { + try { + for (const asset of selectedAssets) { + await audioSegmentService.deleteAudioSegment(asset.id); + } + + cancelSelection(); + setSelectedForRecording(null); + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + + console.log( + `βœ… Batch delete completed: ${selectedAssets.length} assets` + ); + } catch (e) { + console.error('Failed to batch delete assets', e); + RNAlert.alert( + t('error'), + 'Failed to delete assets. Please try again.' + ); + } + })(); + } + } + ] + ); + }, [assets, selectedAssetIds, cancelSelection, queryClient, t, refetch]); + + // Handle batch merge of selected assets + const handleBatchMergeSelected = React.useCallback(() => { + // Filter selected assets that are local (not cloud-only) + const selectedAssets = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + + if (selectedAssets.length < 2) return; + + RNAlert.alert( + 'Merge Assets', + `Are you sure you want to merge ${selectedAssets.length} assets? The audio segments will be combined into the first selected asset, and the others will be deleted.`, + [ + { + text: t('cancel'), + style: 'cancel' + }, + { + text: 'Merge', + style: 'destructive', + onPress: () => { + void (async () => { + try { + if (!currentUser) return; + + const target = selectedAssets[0]!; + const rest = selectedAssets.slice(1); + const contentLocal = resolveTable('asset_content_link', { + localOverride: true + }); + + for (const src of rest) { + // Find all content links for the source asset + const srcContent = await system.db + .select() + .from(asset_content_link) + .where(eq(asset_content_link.asset_id, src.id)); + + // Insert them for the target asset + for (const c of srcContent) { + if (!c.audio) continue; + await system.db.insert(contentLocal).values({ + asset_id: target.id, + source_language_id: c.source_language_id, + languoid_id: + c.languoid_id ?? c.source_language_id ?? null, + text: c.text || '', + audio: c.audio, + download_profiles: [currentUser.id] + }); + } + + // Delete the source asset + await audioSegmentService.deleteAudioSegment(src.id); + } + + cancelSelection(); + setSelectedForRecording(null); + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + + console.log( + `βœ… Batch merge completed: ${selectedAssets.length} assets merged into ${target.id.slice(0, 8)}` + ); + } catch (e) { + console.error('Failed to batch merge assets', e); + RNAlert.alert( + t('error'), + 'Failed to merge assets. Please try again.' + ); + } + })(); + } + } + ] + ); + }, [ + assets, + selectedAssetIds, + currentUser, + cancelSelection, + queryClient, + t, + refetch + ]); + + // ============================================================================ + // RENAME ASSET + // ============================================================================ + + const handleRenameAsset = React.useCallback( + (assetId: string, currentName: string | null) => { + setRenameAssetId(assetId); + setRenameAssetName(currentName ?? ''); + setShowRenameDrawer(true); + }, + [] + ); + + const handleSaveRename = React.useCallback( + async (newName: string) => { + if (!renameAssetId) return; + + try { + // renameAsset will validate that this is a local-only asset + // and throw if it's synced (immutable) + await renameAsset(renameAssetId, newName); + + // Invalidate queries to refresh the list + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + + } catch (error) { + console.error('❌ Failed to rename asset:', error); + if (error instanceof Error) { + console.warn('⚠️ Rename blocked:', error.message); + RNAlert.alert(t('error'), error.message); + } + } + }, + [renameAssetId, queryClient, refetch, t] + ); + + // Auto-assign labels to assets when a separator is created with assetId + React.useEffect(() => { + const processNewSeparators = async () => { + // Find separators with assetId that haven't been processed yet + const unprocessedSeparators = manualSeparators.filter( + (sep) => sep.assetId && !processedSeparatorsRef.current.has(sep.key) + ); + + if (unprocessedSeparators.length === 0) return; + + // Process each unprocessed separator + for (const separator of unprocessedSeparators) { + if (!separator.assetId) continue; + + // Mark as processed immediately to avoid duplicate processing + processedSeparatorsRef.current.add(separator.key); + + // Find the target asset to determine its position + const targetAsset = assets.find((a) => a.id === separator.assetId); + if (!targetAsset) { + console.warn( + `⚠️ Asset ${separator.assetId} not found in assets list, skipping auto-assignment` + ); + processedSeparatorsRef.current.delete(separator.key); + continue; + } + + // Check if asset is in unassigned (no metadata) + const isUnassigned = !targetAsset.metadata?.verse?.from; + + // Find all assets to update (with order_index calculation) + const assetsToUpdate: AssetUpdatePayload[] = []; + let sequentialInGroup = 1; // Start at 1 (e.g., verse 7 β†’ 7001, 7002...) + + if (isUnassigned) { + // Asset is in unassigned block - find it and all assets below it + // until we hit another separator or the end + const targetAssetIndex = listItems.findIndex( + (item) => + item.type === 'asset' && item.content.id === separator.assetId + ); + + if (targetAssetIndex === -1) { + console.warn( + `⚠️ Asset ${separator.assetId} not found in listItems, skipping` + ); + processedSeparatorsRef.current.delete(separator.key); + continue; + } + + // Start from the target asset and go down + for (let i = targetAssetIndex; i < listItems.length; i++) { + const item = listItems[i]; + if (!item) continue; + + // Stop if we encounter a separator (not the "No Verse Assigned" separator) + if ( + item.type === 'separator' && + item.key !== 'sep-unassigned' && + item.key !== separator.key + ) { + break; + } + + // If it's an asset, add it to the update list with order_index + if (item.type === 'asset') { + const newOrderIndex = + (separator.from * 1000 + sequentialInGroup) * 1000; + sequentialInGroup++; + + assetsToUpdate.push({ + assetId: item.content.id, + metadata: { + verse: { + from: separator.from, + to: separator.to ?? separator.from + } + }, + order_index: newOrderIndex + }); + + } + } + } else { + // Asset already has metadata - find separator and assets below it + const separatorIndex = listItems.findIndex( + (item) => item.type === 'separator' && item.key === separator.key + ); + + if (separatorIndex === -1) { + console.warn( + `⚠️ Separator ${separator.key} not found in listItems, skipping` + ); + processedSeparatorsRef.current.delete(separator.key); + continue; + } + + // Start from the position right after the separator + for (let i = separatorIndex + 1; i < listItems.length; i++) { + const item = listItems[i]; + if (!item) continue; + + // Stop if we encounter another separator + if (item.type === 'separator') { + break; + } + + // If it's an asset, add it to the update list with order_index + if (item.type === 'asset') { + const newOrderIndex = + (separator.from * 1000 + sequentialInGroup) * 1000; + sequentialInGroup++; + + assetsToUpdate.push({ + assetId: item.content.id, + metadata: { + verse: { + from: separator.from, + to: separator.to ?? separator.from + } + }, + order_index: newOrderIndex + }); + + } + } + } + + // Batch update all affected assets + if (assetsToUpdate.length > 0) { + try { + await batchUpdateAssetMetadata(assetsToUpdate); + + // Invalidate queries to refresh the UI + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + } catch (err: unknown) { + console.error('Failed to update asset metadata:', err); + // Remove from processed set so it can be retried + processedSeparatorsRef.current.delete(separator.key); + } + } else { + console.warn( + `⚠️ No assets found below separator ${separator.key} to update` + ); + } + } + }; + + void processNewSeparators(); + }, [manualSeparators, listItems, assets, queryClient, refetch]); + + // Clean up manual separators that have been persisted to asset metadata + // This ensures the UI correctly reflects which verses are available after metadata updates + React.useEffect(() => { + // Find manual separators that have been processed and can be removed + // A separator can be removed if: + // 1. It has been processed (metadata was updated for assets below it), OR + // 2. Its range is already covered by auto-generated separators from asset metadata + const separatorsToRemove: string[] = []; + + for (const sep of manualSeparators) { + // If this separator was already processed, it can be removed + // The auto-generated separators from asset metadata will take over + if (processedSeparatorsRef.current.has(sep.key)) { + separatorsToRemove.push(sep.key); + continue; + } + + // Also check if any asset already has metadata with this exact verse range + // This handles cases where metadata was updated outside of the normal flow + // (e.g., via _handleSorting) + const hasMatchingAsset = assets.some((asset) => { + const metadata = asset.metadata; + if (!metadata?.verse) return false; + return metadata.verse.from === sep.from && metadata.verse.to === sep.to; + }); + + if (hasMatchingAsset) { + separatorsToRemove.push(sep.key); + } + } + + if (separatorsToRemove.length > 0) { + setManualSeparators((prev) => + prev.filter((sep) => !separatorsToRemove.includes(sep.key)) + ); + // Also clean up the processed refs + for (const key of separatorsToRemove) { + processedSeparatorsRef.current.delete(key); + } + } + }, [assets, manualSeparators]); + + // Function to update an existing separator and all assets below it (until next separator) + const updateVerseSeparator = React.useCallback( + async ( + separatorKey: string, + oldFrom: number | undefined, + oldTo: number | undefined, + newFrom: number, + newTo: number + ) => { + // Update the separator in state + setManualSeparators((prev) => + prev.map((sep) => + sep.key === separatorKey ? { ...sep, from: newFrom, to: newTo } : sep + ) + ); + + // Find the separator in the listItems to get its position + const separatorIndex = listItems.findIndex( + (item) => item.type === 'separator' && item.key === separatorKey + ); + + if (separatorIndex === -1) { + console.warn( + `⚠️ Separator ${separatorKey} not found in listItems, skipping asset update` + ); + return; + } + + // Find all assets below this separator until we hit another separator + const assetsToUpdate: AssetUpdatePayload[] = []; + let sequentialInGroup = 1; // Start at 1 (e.g., verse 7 β†’ 7001, 7002...) + + for (let i = separatorIndex + 1; i < listItems.length; i++) { + const item = listItems[i]; + if (!item) continue; + + // Stop if we encounter another separator + if (item.type === 'separator') { + break; + } + + // If it's an asset, add it to the update list with order_index + if (item.type === 'asset') { + const newOrderIndex = (newFrom * 1000 + sequentialInGroup) * 1000; + sequentialInGroup++; + + assetsToUpdate.push({ + assetId: item.content.id, + metadata: { + verse: { + from: newFrom, + to: newTo + } + }, + order_index: newOrderIndex + }); + + } + } + + // Batch update all affected assets + if (assetsToUpdate.length > 0) { + try { + await batchUpdateAssetMetadata(assetsToUpdate); + // Invalidate queries to refresh the UI + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + } catch (err: unknown) { + console.error('Failed to update asset metadata:', err); + } + } else { + console.warn( + `⚠️ No assets found below separator ${separatorKey} to update` + ); + } + }, + [listItems, queryClient, refetch] + ); + + // Compute the allowed range for a new separator based on existing separators + // The AddVerseLabelButton is above the current separator, so: + // - rangeFrom = previous separator's "to" + 1 (or 1 if no previous) + // - rangeTo = CURRENT separator's "from" - 1 (or verseCount if current has no "from") + // Note: Currently unused but kept for potential future use + const _computeAllowedRange = React.useCallback( + (separatorKey: string) => { + const currentIdx = listItems.findIndex((i) => i.key === separatorKey); + if (currentIdx === -1) { + return { from: 1, to: verseCount || 1 }; + } + + const currentSep = listItems[currentIdx]; + + // Get the CURRENT separator's "from" value (this is the ceiling for new range) + let currentFrom: number | undefined; + if (currentSep && currentSep.type === 'separator') { + currentFrom = currentSep.from; + } + + // Look backward for the PREVIOUS separator to get its "to" value + let prevTo: number | undefined; + for (let i = currentIdx - 1; i >= 0; i--) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.to !== undefined) { + prevTo = item.to; + break; + } + } + + // Calculate range: + // - From: previous separator's "to" + 1, or 1 if no previous + // - To: CURRENT separator's "from" - 1, or verseCount if current has no "from" + const rangeFrom = prevTo !== undefined ? prevTo + 1 : 1; + const rangeTo = + currentFrom !== undefined ? currentFrom - 1 : verseCount || 1; + + // Ensure valid range (from <= to) + const finalFrom = Math.max(1, rangeFrom); + const finalTo = Math.max(finalFrom, Math.min(rangeTo, verseCount || 1)); + + return { from: finalFrom, to: finalTo }; + }, + [listItems, verseCount] + ); + + // Compute available ranges for a new label (not editing existing) + // Returns all gaps between existing separators + // Note: Currently unused but kept for potential future use + const _computeAvailableRanges = React.useCallback(() => { + const ranges: { from: number; to: number }[] = []; + + // Get all separators with valid from/to values, sorted by 'from' + const separators = listItems + .filter( + (item): item is ListItemSeparator => + item.type === 'separator' && + item.from !== undefined && + item.to !== undefined + ) + .sort((a, b) => (a.from ?? 0) - (b.from ?? 0)); + + // First gap: from 1 to first separator's from - 1 + if (separators.length > 0) { + const first = separators[0]; + if (first?.from !== undefined) { + const firstFrom = first.from; + if (firstFrom > 1) { + ranges.push({ from: 1, to: firstFrom - 1 }); + } + } + } else { + // No separators, entire range is available + ranges.push({ from: 1, to: verseCount || 1 }); + } + + // Gaps between separators + for (let i = 0; i < separators.length - 1; i++) { + const current = separators[i]; + const next = separators[i + 1]; + if ( + current && + next && + current.to !== undefined && + next.from !== undefined && + current.to < next.from - 1 + ) { + ranges.push({ from: current.to + 1, to: next.from - 1 }); + } + } + + // Last gap: from last separator's to + 1 to verseCount + if (separators.length > 0) { + const last = separators[separators.length - 1]; + if (last?.to !== undefined && last.to < (verseCount || 1)) { + ranges.push({ from: last.to + 1, to: verseCount || 1 }); + } + } + + return ranges; + }, [listItems, verseCount]); + + // Get all available verses (not occupied by separators) + const getAvailableVerses = React.useCallback(() => { + const occupiedVerses = new Set(); + + // Get all separators with valid from/to values + const separators = listItems.filter( + (item): item is ListItemSeparator => + item.type === 'separator' && + item.from !== undefined && + item.to !== undefined + ); + + // Mark all occupied verses + for (const sep of separators) { + if (sep.from !== undefined && sep.to !== undefined) { + for (let verse = sep.from; verse <= sep.to; verse++) { + occupiedVerses.add(verse); + } + } + } + + // Return array of available verses (1 to verseCount, excluding occupied) + const available: number[] = []; + for (let verse = 1; verse <= (verseCount || 1); verse++) { + if (!occupiedVerses.has(verse)) { + available.push(verse); + } + } + + return available; + }, [listItems, verseCount]); + + // Given a selected 'from' value, find the maximum 'to' value allowed + // This prevents overlapping ranges by limiting to the next occupied verse + const getMaxToForFrom = React.useCallback( + (selectedFrom: number) => { + const availableVerses = getAvailableVerses(); + + // Find the index of selectedFrom in available verses + const fromIndex = availableVerses.indexOf(selectedFrom); + if (fromIndex === -1) { + // If selectedFrom is not available, return selectedFrom + return selectedFrom; + } + + // Find the next occupied verse after the available range + // We need to find where the next separator starts + const separators = listItems + .filter( + (item): item is ListItemSeparator => + item.type === 'separator' && + item.from !== undefined && + item.to !== undefined + ) + .sort((a, b) => (a.from ?? 0) - (b.from ?? 0)); + + // Find the first separator that starts after selectedFrom + const nextSeparator = separators.find( + (sep) => sep.from !== undefined && sep.from > selectedFrom + ); + + if (nextSeparator?.from !== undefined) { + // Return the verse just before the next separator + return nextSeparator.from - 1; + } + + // No separator after selectedFrom, can go to the end + return verseCount || 1; + }, + [getAvailableVerses, listItems, verseCount] + ); + + // Get existing labels from separators for quick selection in VerseAssigner + const existingLabels = React.useMemo(() => { + const labels: { from: number; to: number }[] = []; + const seen = new Set(); + + for (const item of listItems) { + if ( + item.type === 'separator' && + item.from !== undefined && + item.to !== undefined + ) { + const key = `${item.from}-${item.to}`; + if (!seen.has(key)) { + seen.add(key); + labels.push({ from: item.from, to: item.to }); + } + } + } + + // Sort by from value + return labels.sort((a, b) => a.from - b.from); + }, [listItems]); + + // Calculate nextVerse and limitVerse for automatic progression + const { nextVerse, limitVerse } = React.useMemo(() => { + // If no verse count, can't calculate + if (!verseCount || verseCount === 0) { + return { nextVerse: null, limitVerse: null }; + } + + // Get the current verse range from selectedForRecording + const currentVerse = selectedForRecording?.metadata?.verse; + + // If no labels exist yet, start from verse 1 + if (existingLabels.length === 0) { + const result = { nextVerse: 1, limitVerse: verseCount }; + return result; + } + + // If no selection or no verse in selection, find the last gap + if (!currentVerse) { + // Find the last occupied verse + const lastLabel = existingLabels[existingLabels.length - 1]; + if (!lastLabel) { + const result = { nextVerse: 1, limitVerse: verseCount }; + return result; + } + + // If there's space after the last label + if (lastLabel.to < verseCount) { + const result = { nextVerse: lastLabel.to + 1, limitVerse: verseCount }; + return result; + } + + // No space available + const result = { nextVerse: null, limitVerse: null }; + return result; + } + + // Find the next available verse after the current selection + const currentTo = currentVerse.to; + + // Find the next label that starts after currentTo + const nextLabel = existingLabels.find((label) => label.from > currentTo); + + if (nextLabel) { + // There's a next label - check if there's space between current and next + if (currentTo + 1 < nextLabel.from) { + // There's a gap + const result = { + nextVerse: currentTo + 1, + limitVerse: nextLabel.from - 1 + }; + return result; + } else { + // No gap - next verse is already occupied + const result = { nextVerse: null, limitVerse: null }; + return result; + } + } else { + // No next label - check if there's space until the end + if (currentTo < verseCount) { + const result = { nextVerse: currentTo + 1, limitVerse: verseCount }; + return result; + } else { + // Already at the end + const result = { nextVerse: null, limitVerse: null }; + return result; + } + } + }, [selectedForRecording, existingLabels, verseCount]); + + // Check if any selected assets already have labels + const selectedAssetsHaveLabels = React.useMemo(() => { + for (const assetId of selectedAssetIds) { + const asset = assets.find((a) => a.id === assetId); + if (asset?.metadata) { + try { + const meta = + typeof asset.metadata === 'string' + ? (JSON.parse(asset.metadata) as AssetMetadata | null) + : (asset.metadata as AssetMetadata | null); + if (meta?.verse?.from !== undefined) { + return true; + } + } catch { + // Ignore parse errors + } + } + } + return false; + }, [selectedAssetIds, assets]); + + // Handle applying verse label to selected assets + const handleAssignVerseToSelected = React.useCallback( + async (from: number, to: number) => { + const selectedAssets = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + + if (selectedAssets.length === 0) return; + + try { + const verseBase = from; + const minOrderIndex = verseBase * 1000 * 1000; + const maxOrderIndex = (verseBase + 1) * 1000 * 1000 - 1; + + // Find the highest order_index already assigned to this verse + // (excluding selected assets since they might be moving from another verse) + let lastSequential = 0; + for (const asset of assets) { + if (selectedAssetIds.has(asset.id)) continue; // Skip assets being reassigned + if ( + asset.order_index >= minOrderIndex && + asset.order_index <= maxOrderIndex + ) { + // Extract sequential part: order_index = (verseBase * 1000 + seq) * 1000 + // seq = (order_index / 1000) - (verseBase * 1000) + const seq = Math.floor(asset.order_index / 1000) - verseBase * 1000; + if (seq > lastSequential) { + lastSequential = seq; + } + } + } + + // Calculate order_index continuing from the last existing asset + const updates: AssetUpdatePayload[] = selectedAssets.map( + (asset, index) => ({ + assetId: asset.id, + metadata: { + verse: { from, to } + }, + order_index: + (verseBase * 1000 + (lastSequential + index + 1)) * 1000 + }) + ); + + await batchUpdateAssetMetadata(updates); + + // Close drawer and clear selection + setShowVerseAssignerDrawer(false); + cancelSelection(); + setSelectedForRecording(null); + + // Refresh the list + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + } catch (error) { + console.error('Failed to assign verse to assets:', error); + RNAlert.alert(t('error'), 'Failed to assign verse. Please try again.'); + } + }, + [assets, selectedAssetIds, cancelSelection, queryClient, refetch, t] + ); + + // Handle removing labels from selected assets + const handleRemoveLabelFromSelected = React.useCallback(async () => { + const selectedAssets = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + + if (selectedAssets.length === 0) return; + + try { + const verseBase = UNASSIGNED_VERSE_BASE; + const minOrderIndex = verseBase * 1000 * 1000; + const maxOrderIndex = (verseBase + 1) * 1000 * 1000 - 1; + + // Find the highest order_index among unassigned assets + let lastSequential = 0; + for (const asset of assets) { + if (selectedAssetIds.has(asset.id)) continue; // Skip assets being moved + if ( + asset.order_index >= minOrderIndex && + asset.order_index <= maxOrderIndex + ) { + const seq = Math.floor(asset.order_index / 1000) - verseBase * 1000; + if (seq > lastSequential) { + lastSequential = seq; + } + } + } + + // Set metadata to null and assign order_index at end of unassigned list + const updates: AssetUpdatePayload[] = selectedAssets.map( + (asset, index) => ({ + assetId: asset.id, + metadata: null, + order_index: (verseBase * 1000 + (lastSequential + index + 1)) * 1000 + }) + ); + + await batchUpdateAssetMetadata(updates); + + // Close drawer and clear selection + setShowVerseAssignerDrawer(false); + cancelSelection(); + setSelectedForRecording(null); + + // Refresh the list + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + } catch (error) { + console.error('Failed to remove labels from assets:', error); + RNAlert.alert(t('error'), 'Failed to remove labels. Please try again.'); + } + }, [assets, selectedAssetIds, cancelSelection, queryClient, refetch, t]); + + const assetIds = React.useMemo(() => { + return assets.map((asset) => asset.id).filter((id): id is string => !!id); + }, [assets]); + + const { attachmentStates, isLoading: isAttachmentStatesLoading } = + useAttachmentStates(assetIds); + + const safeAttachmentStates = attachmentStates; + + const _blockedCount = useBlockedAssetsCount(currentQuestId || ''); + + const attachmentStateSummary = React.useMemo(() => { + if (safeAttachmentStates.size === 0) { + return {}; + } + + const states = Array.from(safeAttachmentStates.values()); + const summary = states.reduce( + (acc, attachment) => { + acc[attachment.state] = (acc[attachment.state] || 0) + 1; + return acc; + }, + {} as Record + ); + return summary; + // Use memo key instead of Map reference for stable dependencies (always 1 string) + }, [safeAttachmentStates]); + + const handleAssetUpdate = React.useCallback(async () => { + // await queryClient.invalidateQueries({ + // // queryKey: ['assets', 'by-quest', currentQuestId], + // queryKey: ['by-quest', currentQuestId], + // exact: false + // }); + await queryClient.invalidateQueries({ + queryKey: ['assets'] + }); + }, [queryClient]); + + // ============================================================================ + // ORDER_INDEX NORMALIZATION + // When returning from BibleRecordingView, normalize order_index for recorded verses + // Recording uses unit scale (7001001, 7001002) but Assets view uses thousand scale (7001000, 7002000) + // This function reads assets from DB and reassigns order_index with thousand scale + // ============================================================================ + const normalizeOrderIndexForVerses = React.useCallback( + async (verses: number[]) => { + if (!currentQuestId || verses.length === 0) return; + + const assetTable = resolveTable('asset', { localOverride: true }); + const questAssetLinkTable = resolveTable('quest_asset_link', { + localOverride: true + }); + + for (const verse of verses) { + // Calculate order_index range for this verse + // Formula: verse * 1000 * 1000 to (verse + 1) * 1000 * 1000 - 1 + // Example: verse 7 β†’ 7000000 to 7999999 + const minOrderIndex = verse * 1000 * 1000; + const maxOrderIndex = (verse + 1) * 1000 * 1000 - 1; + + try { + // Query assets by order_index range using join with quest_asset_link + // This ensures we only get assets that belong to this quest + const assetsInVerse = await system.db + .select({ + id: assetTable.id, + name: assetTable.name, + order_index: assetTable.order_index + }) + .from(assetTable) + .innerJoin( + questAssetLinkTable, + eq(assetTable.id, questAssetLinkTable.asset_id) + ) + .where( + and( + eq(questAssetLinkTable.quest_id, currentQuestId), + gte(assetTable.order_index, minOrderIndex), + lte(assetTable.order_index, maxOrderIndex) + ) + ) + .orderBy(asc(assetTable.order_index)); + + if (assetsInVerse.length === 0) { + continue; + } + + // Recalculate order_index with thousand scale + // Formula: (verse * 1000 + sequential) * 1000 + // sequential starts at 1: 7001000, 7002000, 7003000... + const updates: AssetUpdatePayload[] = []; + let hasChanges = false; + + for (let i = 0; i < assetsInVerse.length; i++) { + const asset = assetsInVerse[i]; + if (!asset) continue; + + const sequential = i + 1; // 1-based + const newOrderIndex = (verse * 1000 + sequential) * 1000; + + // Only update if order_index changed + if (asset.order_index !== newOrderIndex) { + hasChanges = true; + updates.push({ + assetId: asset.id, + order_index: newOrderIndex + }); + } + } + + if (hasChanges && updates.length > 0) { + await batchUpdateAssetMetadata(updates); + console.log( + ` βœ… Verse ${verse}: normalized ${updates.length} of ${assetsInVerse.length} asset(s)` + ); + } + } catch (error) { + console.error(` ❌ Failed to normalize verse ${verse}:`, error); + } + } + + }, + [currentQuestId] + ); + + // Calculate available range for adding verse label above a specific asset + // Returns only verses between the previous separator's "to" and next separator's "from" + const getRangeForAsset = React.useCallback( + (assetId: string) => { + const assetIndex = listItems.findIndex( + (item) => item.type === 'asset' && item.content.id === assetId + ); + + if (assetIndex === -1) { + return { from: 1, to: verseCount || 1, availableVerses: [] }; + } + + // Find previous separator (looking backward) + let prevTo: number | undefined; + for (let i = assetIndex - 1; i >= 0; i--) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.to !== undefined) { + prevTo = item.to; + break; + } + } + + // Find next separator (looking forward) + let nextFrom: number | undefined; + for (let i = assetIndex + 1; i < listItems.length; i++) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.from !== undefined) { + nextFrom = item.from; + break; + } + } + + // Calculate range - only between prevTo and nextFrom + const rangeFrom = prevTo !== undefined ? prevTo + 1 : 1; + const rangeTo = nextFrom !== undefined ? nextFrom - 1 : verseCount || 1; + + // Ensure valid range and check if there's actually space available + const finalFrom = Math.max(1, rangeFrom); + const finalTo = Math.max(finalFrom, Math.min(rangeTo, verseCount || 1)); + + // Check if there's actually space between separators + // If prevTo + 1 > nextFrom - 1, there's no space + if ( + prevTo !== undefined && + nextFrom !== undefined && + prevTo + 1 > nextFrom - 1 + ) { + return { + from: finalFrom, + to: finalTo, + availableVerses: [] + }; + } + + // Generate array of available verses only in this range + const availableVerses: number[] = []; + for ( + let verse = finalFrom; + verse <= finalTo && verse <= (verseCount || 1); + verse++ + ) { + availableVerses.push(verse); + } + + return { + from: finalFrom, + to: finalTo, + availableVerses + }; + }, + [listItems, verseCount] + ); + + // Get available verses for editing a separator (between previous and next separators) + const getRangeForSeparator = React.useCallback( + (separatorKey: string) => { + const separatorIndex = listItems.findIndex( + (item) => item.type === 'separator' && item.key === separatorKey + ); + + if (separatorIndex === -1) { + return { from: 1, to: verseCount || 1, availableVerses: [] }; + } + + // Find previous separator (looking backward) + let prevTo: number | undefined; + for (let i = separatorIndex - 1; i >= 0; i--) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.to !== undefined) { + prevTo = item.to; + break; + } + } + + // Find next separator (looking forward) + let nextFrom: number | undefined; + for (let i = separatorIndex + 1; i < listItems.length; i++) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.from !== undefined) { + nextFrom = item.from; + break; + } + } + + // Calculate range - only between prevTo and nextFrom + const rangeFrom = prevTo !== undefined ? prevTo + 1 : 1; + const rangeTo = nextFrom !== undefined ? nextFrom - 1 : verseCount || 1; + + // Ensure valid range + const finalFrom = Math.max(1, rangeFrom); + const finalTo = Math.max(finalFrom, Math.min(rangeTo, verseCount || 1)); + + // Generate array of available verses only in this range + const availableVerses: number[] = []; + for ( + let verse = finalFrom; + verse <= finalTo && verse <= (verseCount || 1); + verse++ + ) { + availableVerses.push(verse); + } + + return { + from: finalFrom, + to: finalTo, + availableVerses + }; + }, + [listItems, verseCount] + ); + + // Get max 'to' value for editing a separator (limited to available range) + const getMaxToForFromSeparator = React.useCallback( + (separatorKey: string, selectedFrom: number): number => { + const range = getRangeForSeparator(separatorKey); + const availableVerses = range.availableVerses; + + // Find the index of selectedFrom in available verses + const fromIndex = availableVerses.indexOf(selectedFrom); + if (fromIndex === -1) { + // If selectedFrom is not available, return selectedFrom + return selectedFrom; + } + + // Find the next occupied verse after selectedFrom + // Look for the next separator's 'from' value + const separatorIndex = listItems.findIndex( + (item) => item.type === 'separator' && item.key === separatorKey + ); + + let nextFrom: number | undefined; + for (let i = separatorIndex + 1; i < listItems.length; i++) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.from !== undefined) { + nextFrom = item.from; + break; + } + } + + // The maximum 'to' is the verse before the next separator's 'from', or the last available verse + const maxTo = nextFrom !== undefined ? nextFrom - 1 : range.to; + + // Find the index of maxTo in available verses, or use the last available verse + const maxToIndex = availableVerses.indexOf(maxTo); + if (maxToIndex !== -1 && maxToIndex >= fromIndex) { + const result = availableVerses[maxToIndex]; + if (result !== undefined) { + return result; + } + } + + // If maxTo is not in available verses, return the last available verse from selectedFrom onwards + const remainingVerses = availableVerses.slice(fromIndex); + if (remainingVerses.length > 0) { + const lastVerse = remainingVerses[remainingVerses.length - 1]; + if (lastVerse !== undefined) { + return lastVerse; + } + } + + return selectedFrom; + }, + [listItems, getRangeForSeparator] + ); + + // Stable wrapper for onPlay callback (avoids creating new function in renderItem) + const stableOnPlay = React.useCallback( + (assetId: string) => handlePlayAssetRef.current(assetId), + [] + ); + + const renderItem = React.useCallback( + ({ + item, + isPublished, + index + }: { + item: ListItem; + isPublished: boolean; + index: number; + }) => { + if (item.type === 'separator') { + // Check if this separator is selected for recording + const isSeparatorSelected = + selectedForRecording?.type === 'separator' && + selectedForRecording?.separatorKey === item.key; + + return ( + + { + setEditSeparatorState({ + isOpen: true, + separatorKey: item.key, + from: item.from, + to: item.to + }); + } + : undefined + } + // Recording selection - clicking the separator text selects it for recording + isSelectedForRecording={!isPublished && isSeparatorSelected} + onSelectForRecording={ + !isPublished + ? () => + handleSelectSeparatorForRecording( + item.key, + item.from, + item.to + ) + : undefined + } + dragHandleComponent={!isPublished ? Sortable.Handle : undefined} + dragHandleProps={ + !isPublished + ? { + mode: fixedItemsIndexesRef.current.includes(index) + ? 'fixed-order' + : 'draggable' + } + : undefined + } + /> + {!isPublished && !isSelectionMode && isSeparatorSelected && ( + + )} + + ); + } + + // Handle asset items + const asset = item.content; + const isPlaying = + audioContext.isPlaying && + (audioContext.currentAudioId === asset.id || // Individual play + (audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && + currentlyPlayingAssetId === asset.id)); // Play all + + const isSelected = selectedAssetIds.has(asset.id); + + const isAssetSelectedForRecording = + !isPublished && + selectedForRecording?.type === 'asset' && + selectedForRecording?.assetId === asset.id; + + // Only calculate range if this asset is selected for recording (performance optimization) + const assetRange = isAssetSelectedForRecording + ? getRangeForAsset(asset.id) + : null; + const hasAvailableVerses = assetRange + ? assetRange.availableVerses.length > 0 + : false; + + return ( + + {/* Add verse button - centered, only shown when asset is selected for recording */} + {!isPublished && + !isSelectionMode && + isAssetSelectedForRecording && + hasAvailableVerses && ( + + { + const range = getRangeForAsset(asset.id); + setAssetVerseSelectorState({ + isOpen: true, + assetId: asset.id, + from: range.from, + to: range.to + }); + }} + className="rounded-full bg-primary/80 p-1 shadow-sm active:bg-primary" + > + + + + )} + + {!isPublished && !isSelectionMode && isAssetSelectedForRecording && ( + + )} + + ); + }, + [ + currentQuestId, + safeAttachmentStates, + audioContext.isPlaying, + audioContext.currentAudioId, + currentlyPlayingAssetId, + handleAssetUpdate, + stableOnPlay, + getRangeForAsset, + isSelectionMode, + selectedAssetIds, + toggleSelect, + enterSelection, + selectedForRecording?.type, + selectedForRecording?.assetId, + selectedForRecording?.separatorKey, + handleSelectForRecording, + handleSelectSeparatorForRecording, + handleRenameAsset + ] + ); + + const _onEndReached = React.useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + // footer handled inline in ListFooterComponent + + const statusText = React.useMemo(() => { + const cloudCount = assets.filter((a) => a.source === 'cloud').length; + const offlineCount = assets.length - cloudCount; + return `${isOnline ? '🟒' : 'πŸ”΄'} Offline: ${offlineCount} | Cloud: ${isOnline ? cloudCount : 'N/A'} | Total: ${assets.length}`; + }, [isOnline, assets]); + + const attachmentSummaryText = React.useMemo(() => { + return Object.entries(attachmentStateSummary) + .map(([state, count]) => { + const stateNames = { + '0': `⏳ ${t('queued')}`, + '1': `πŸ”„ ${t('syncing')}`, + '2': `βœ… ${t('synced')}`, + '3': `❌ ${t('failed')}`, + '4': `πŸ“₯ ${t('downloading')}` + }; + return `${stateNames[state as keyof typeof stateNames] || `${t('state')} ${state}`}: ${count}`; + }) + .join(' | '); + }, [attachmentStateSummary, t]); + + const { + hasReported, + // isLoading: isReportLoading, + refetch: refetchReport + } = useHasUserReported(currentQuestId || '', 'quests'); + + const statusContext = useStatusContext(); + const { allowSettings } = statusContext.getStatusParams( + LayerType.QUEST, + currentQuestId + ); + + // Special audio ID for "play all" mode + const PLAY_ALL_AUDIO_ID = 'play-all-assets'; + + // Fetch audio URIs for an asset (similar to RecordingViewSimplified) + // Includes fallback logic for local-only files when server records are removed + const getAssetAudioUris = React.useCallback( + async (assetId: string): Promise => { + try { + // Get content links from both synced and local tables + const assetContentLinkSynced = resolveTable('asset_content_link', { + localOverride: false + }); + const contentLinksSynced = await system.db + .select() + .from(assetContentLinkSynced) + .where(eq(assetContentLinkSynced.asset_id, assetId)); + + const assetContentLinkLocal = resolveTable('asset_content_link', { + localOverride: true + }); + const contentLinksLocal = await system.db + .select() + .from(assetContentLinkLocal) + .where(eq(assetContentLinkLocal.asset_id, assetId)); + + // Prefer synced links, but merge with local for fallback + const allContentLinks = [...contentLinksSynced, ...contentLinksLocal]; + + // Deduplicate by ID (prefer synced over local) + const seenIds = new Set(); + const uniqueLinks = allContentLinks.filter((link) => { + if (seenIds.has(link.id)) { + return false; + } + seenIds.add(link.id); + return true; + }); + + if (uniqueLinks.length === 0) { + return []; + } + + // Get audio values from content links (can be URIs or attachment IDs) + const audioValues = uniqueLinks + .flatMap((link) => { + const audioArray = link.audio ?? []; + return audioArray; + }) + .filter((value): value is string => !!value); + + if (audioValues.length === 0) { + return []; + } + + // Process each audio value - can be either a local URI or an attachment ID + const uris: string[] = []; + for (const audioValue of audioValues) { + // Check if this is already a local URI (starts with 'local/' or 'file://') + if (audioValue.startsWith('local/')) { + // It's a direct local URI from saveAudioLocally() + const constructedUri = + await getLocalAttachmentUriWithOPFS(audioValue); + // Check if file exists at constructed path + if (await fileExists(constructedUri)) { + uris.push(constructedUri); + } else { + // File doesn't exist at expected path - try to find it in attachment queue + console.log( + `⚠️ Local URI ${audioValue} not found at ${constructedUri}, searching attachment queue...` + ); + + if (system.permAttachmentQueue) { + // Extract filename from local path (e.g., "local/uuid.wav" -> "uuid.wav") + const filename = audioValue.replace(/^local\//, ''); + // Extract UUID part (without extension) for more flexible matching + const uuidPart = filename.split('.')[0]; + + // Search attachment queue by filename or UUID + let attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE filename = ? OR filename LIKE ? OR id = ? OR id LIKE ? LIMIT 1`, + [filename, `%${uuidPart}%`, filename, `%${uuidPart}%`] + ); + + // If not found, try searching all attachments for this asset's content links + if (!attachment && uniqueLinks.length > 0) { + const allAttachmentIds = uniqueLinks + .flatMap((link) => link.audio ?? []) + .filter( + (av): av is string => + typeof av === 'string' && + !av.startsWith('local/') && + !av.startsWith('file://') + ); + if (allAttachmentIds.length > 0) { + const placeholders = allAttachmentIds + .map(() => '?') + .join(','); + attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE id IN (${placeholders}) LIMIT 1`, + allAttachmentIds + ); + } + } + + if (attachment?.local_uri) { + const foundUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + // Verify the found file actually exists + if (await fileExists(foundUri)) { + uris.push(foundUri); + console.log( + `βœ… Found attachment in queue for local URI ${audioValue.slice(0, 20)}` + ); + } else { + console.warn( + `⚠️ Attachment found in queue but file doesn't exist: ${foundUri}` + ); + } + } else { + // Try fallback to local table for alternative audio values + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + console.log(`βœ… Found fallback file URI`); + break; + } + } + } + } + } + } + } + } else if (audioValue.startsWith('file://')) { + // Already a full file URI - verify it exists + if (await fileExists(audioValue)) { + uris.push(audioValue); + } else { + console.warn(`File URI does not exist: ${audioValue}`); + // Try to find in attachment queue by extracting filename from path + if (system.permAttachmentQueue) { + const filename = audioValue.split('/').pop(); + if (filename) { + const attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE filename = ? OR id = ? LIMIT 1`, + [filename, filename] + ); + + if (attachment?.local_uri) { + const foundUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + if (await fileExists(foundUri)) { + uris.push(foundUri); + console.log(`βœ… Found attachment in queue for file URI`); + } + } + } + } + } + } else { + // It's an attachment ID - look it up in the attachment queue + if (!system.permAttachmentQueue) { + // No attachment queue - try fallback to local table + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('local/')) { + const fallbackUri = + await getLocalAttachmentUriWithOPFS(fallbackAudioValue); + if (await fileExists(fallbackUri)) { + uris.push(fallbackUri); + break; + } + } else if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + break; + } + } + } + } + continue; + } + + const attachment = await system.powersync.getOptional<{ + id: string; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE id = ?`, + [audioValue] + ); + + if (attachment?.local_uri) { + const localUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + if (await fileExists(localUri)) { + uris.push(localUri); + } + } else { + // Attachment ID not found in queue - try fallback to local table + console.log( + `⚠️ Attachment ID ${audioValue.slice(0, 8)} not found in queue, checking local table fallback...` + ); + + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('local/')) { + const fallbackUri = + await getLocalAttachmentUriWithOPFS(fallbackAudioValue); + if (await fileExists(fallbackUri)) { + uris.push(fallbackUri); + console.log( + `βœ… Found fallback local URI for attachment ${audioValue.slice(0, 8)}` + ); + break; + } + } else if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + console.log( + `βœ… Found fallback file URI for attachment ${audioValue.slice(0, 8)}` + ); + break; + } + } + } + } else { + // Try to get cloud URL if local not available + try { + if (!AppConfig.supabaseBucket) { + continue; + } + const { data } = system.supabaseConnector.client.storage + .from(AppConfig.supabaseBucket) + .getPublicUrl(audioValue); + if (data.publicUrl) { + uris.push(data.publicUrl); + } + } catch (error) { + console.error('Failed to get cloud audio URL:', error); + } + } + } + } + } + + return uris; + } catch (error) { + console.error('Failed to fetch audio URIs:', error); + return []; + } + }, + [] + ); + + // Asset ranges for play-all: maps each asset to its time range + const assetTimeRangesRef = React.useRef< + { assetId: string; startMs: number; endMs: number }[] + >([]); + + // Calculate which asset should be highlighted based on position (useMemo for performance) + const derivedCurrentlyPlayingAssetId = React.useMemo(() => { + // Not playing at all + if (!audioContext.isPlaying) { + return null; + } + + // Playing a single asset (not play-all mode) + if (audioContext.currentAudioId !== PLAY_ALL_AUDIO_ID) { + return audioContext.currentAudioId; + } + + // Play-all mode: Find asset by time range + const position = audioContext.position; + const ranges = assetTimeRangesRef.current; + + if (ranges.length === 0) { + // Fallback: use first asset in order + return assetOrderRef.current[0] || null; + } + + // Find which range the current position falls into + for (const range of ranges) { + if (position >= range.startMs && position < range.endMs) { + return range.assetId; + } + } + + // If position is beyond all ranges, return the last asset + return ranges[ranges.length - 1]?.assetId || null; + }, [ + audioContext.isPlaying, + audioContext.currentAudioId, + audioContext.position + ]); + + // Update state only when the derived value actually changes + React.useEffect(() => { + setCurrentlyPlayingAssetId(derivedCurrentlyPlayingAssetId); + }, [derivedCurrentlyPlayingAssetId]); + + // Handle play all assets + const handlePlayAllAssets = React.useCallback(async () => { + try { + const isPlayingAll = + audioContext.isPlaying && + audioContext.currentAudioId === PLAY_ALL_AUDIO_ID; + + if (isPlayingAll) { + await audioContext.stopCurrentSound(); + setCurrentlyPlayingAssetId(null); + assetUriMapRef.current.clear(); + assetOrderRef.current = []; + uriOrderRef.current = []; + segmentDurationsRef.current = []; + assetTimeRangesRef.current = []; + } else { + if (assets.length === 0) { + console.warn('⚠️ No assets to play'); + return; + } + + // Collect all URIs from all assets in order, tracking which asset each URI belongs to + const allUris: string[] = []; + assetUriMapRef.current.clear(); + assetOrderRef.current = []; + uriOrderRef.current = []; + segmentDurationsRef.current = []; + assetTimeRangesRef.current = []; + + // Build time ranges for each asset + let cumulativeTime = 0; + for (const asset of assets) { + const uris = await getAssetAudioUris(asset.id); + if (uris.length > 0) { + const assetStartTime = cumulativeTime; + assetOrderRef.current.push(asset.id); + + // Add all URIs for this asset + for (const uri of uris) { + allUris.push(uri); + uriOrderRef.current.push(uri); + assetUriMapRef.current.set(uri, asset.id); + + // Load duration for this URI + try { + const { sound } = await Audio.Sound.createAsync({ uri }); + const status = await sound.getStatusAsync(); + await sound.unloadAsync(); + if (status.isLoaded) { + const duration = status.durationMillis ?? 0; + segmentDurationsRef.current.push(duration); + cumulativeTime += duration; + } else { + segmentDurationsRef.current.push(0); + } + } catch { + segmentDurationsRef.current.push(0); + } + } + + // Store the time range for this asset + assetTimeRangesRef.current.push({ + assetId: asset.id, + startMs: assetStartTime, + endMs: cumulativeTime + }); + + console.log( + `πŸ“Š Asset ${asset.id.slice(0, 8)}: ${Math.round(assetStartTime)}ms - ${Math.round(cumulativeTime)}ms (${uris.length} segments)` + ); + } + } + + if (allUris.length === 0) { + console.error('❌ No audio URIs found for any assets'); + return; + } + + console.log( + `▢️ Playing ${allUris.length} audio segments from ${assets.length} assets (total: ${Math.round(cumulativeTime)}ms)` + ); + + // Start playing (AudioContext will handle sequence playback) + await audioContext.playSoundSequence(allUris, PLAY_ALL_AUDIO_ID); + } + } catch (error) { + console.error('❌ Failed to play all assets:', error); + setCurrentlyPlayingAssetId(null); + assetUriMapRef.current.clear(); + assetOrderRef.current = []; + uriOrderRef.current = []; + segmentDurationsRef.current = []; + } + }, [audioContext, getAssetAudioUris, assets]); + + // Handle play individual asset + const handlePlayAsset = React.useCallback( + async (assetId: string) => { + try { + const isThisAssetPlaying = + audioContext.isPlaying && audioContext.currentAudioId === assetId; + + if (isThisAssetPlaying) { + console.log('⏸️ Stopping asset:', assetId.slice(0, 8)); + await audioContext.stopCurrentSound(); + setCurrentlyPlayingAssetId(null); + } else { + console.log('▢️ Playing asset:', assetId.slice(0, 8)); + const uris = await getAssetAudioUris(assetId); + + if (uris.length === 0) { + console.warn('⚠️ No audio URIs found for asset:', assetId); + return; + } + + // Set the asset as currently playing immediately for visual feedback + setCurrentlyPlayingAssetId(assetId); + + if (uris.length === 1 && uris[0]) { + console.log('▢️ Playing single segment'); + await audioContext.playSound(uris[0], assetId); + } else if (uris.length > 1) { + console.log(`▢️ Playing ${uris.length} segments in sequence`); + await audioContext.playSoundSequence(uris, assetId); + } + } + } catch (error) { + console.error('❌ Failed to play audio:', error); + setCurrentlyPlayingAssetId(null); + } + }, + [audioContext, getAssetAudioUris] + ); + + // Update ref so renderItem can use it + handlePlayAssetRef.current = handlePlayAsset; + + // Handle publish button press with useMutation + const { mutate: publishQuest, isPending: isPublishing } = useMutation({ + mutationFn: async () => { + if (!currentQuestId || !currentProjectId) { + throw new Error('Missing quest or project ID'); + } + console.log(`πŸ“€ Publishing quest ${currentQuestId}...`); + const result = await publishQuestUtils(currentQuestId, currentProjectId); + return result; + }, + onSuccess: async (result) => { + if (result.success) { + // Wait for PowerSync to sync the published quest before invalidating + await new Promise((resolve) => setTimeout(resolve, 1500)); + + console.log('πŸ“₯ [Publish Quest] Invalidating queries...'); + + // Invalidate the quest query used by this component + await queryClient.invalidateQueries({ + queryKey: ['current-quest', 'offline', currentQuestId] + }); + await queryClient.invalidateQueries({ + queryKey: ['current-quest', 'cloud', currentQuestId] + }); + + // Invalidate general quest queries + await queryClient.invalidateQueries({ + queryKey: ['quests', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'infinite', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'offline', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'cloud', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests'] + }); + + // Invalidate assets queries to refresh the assets list + await queryClient.invalidateQueries({ + queryKey: ['assets'] + }); + + // Refetch quest data to update the selectedQuest immediately + void refetchQuest(); + + // Refetch assets to update download indicators + void refetch(); + + console.log('βœ… [Publish Quest] All queries invalidated'); + + RNAlert.alert(t('success'), result.message, [{ text: t('ok') }]); + } else { + RNAlert.alert(t('error'), result.message || t('error'), [ + { text: t('ok') } + ]); + } + }, + onError: (error) => { + console.error('Publish error:', error); + RNAlert.alert( + t('error'), + error instanceof Error ? error.message : t('failedCreateTranslation'), + [{ text: t('ok') }] + ); + } + }); + + // Handle offload button click - start verification + const handleOffloadClick = () => { + console.log('πŸ—‘οΈ [Offload] Opening verification drawer'); + setShowOffloadDrawer(true); + verificationState.startVerification(); + }; + + // Handle offload confirmation - execute offload + const handleOffloadConfirm = async () => { + console.log('πŸ—‘οΈ [Offload] User confirmed, executing offload'); + setIsOffloading(true); + try { + await offloadQuest({ + questId: currentQuestId || '', + verifiedIds: verificationState.verifiedIds, + onProgress: (progress, message) => { + console.log(`πŸ—‘οΈ [Offload Progress] ${progress}%: ${message}`); + } + }); + + console.log('πŸ—‘οΈ [Offload] Complete - waiting for PowerSync to sync...'); + // Wait for PowerSync to sync the removal before invalidating + await new Promise((resolve) => setTimeout(resolve, 1500)); + + console.log('πŸ—‘οΈ [Offload] Invalidating all queries...'); + + // Invalidate download status queries + await queryClient.invalidateQueries({ + queryKey: ['download-status', 'quest', currentQuestId] + }); + await queryClient.invalidateQueries({ + queryKey: ['download-status', 'project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quest-download-status', currentQuestId] + }); + await queryClient.invalidateQueries({ + queryKey: ['project-download-status', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['download-status'] + }); + + // Invalidate ALL quest queries (comprehensive like create quest) + await queryClient.invalidateQueries({ + queryKey: ['quests', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'infinite', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'offline', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'cloud', 'for-project', currentProjectId] + }); + // Also invalidate generic quest queries + await queryClient.invalidateQueries({ + queryKey: ['quests'] + }); + + // Invalidate project queries + await queryClient.invalidateQueries({ + queryKey: ['projects'] + }); + + // Invalidate assets queries to refresh the assets list + await queryClient.invalidateQueries({ + queryKey: ['assets'] + }); + + // Invalidate quest closure data + await queryClient.invalidateQueries({ + queryKey: ['quest-closure', currentQuestId] + }); + + console.log('βœ… [Offload] All queries invalidated'); + + RNAlert.alert(t('success'), t('offloadComplete')); + setShowOffloadDrawer(false); + + // Navigate back to project directory view (quests view) + goBack(); + } catch (error) { + console.error('Failed to offload quest:', error); + RNAlert.alert(t('error'), t('offloadError')); + } finally { + setIsOffloading(false); + } + }; + + // ============================================================================ + // SORTING HANDLER (memoized for performance) + // ============================================================================ + const UNASSIGNED_VERSE_BASE = 999; // High value so unassigned assets appear at the end + + const handleSorting = React.useCallback( + async (params: { indexToKey: string[]; data: ListItem[] }) => { + // Build a map of key -> item for quick lookup + const keyToItem = new Map(params.data.map((item) => [item.key, item])); + + // Iterate through the new order and update asset metadata + order_index + // based on the preceding separator + let currentSeparator: ListItemSeparator | null = null; + let sequentialInGroup = 1; // Tracks position within current verse group (starts at 1) + const updates: AssetUpdatePayload[] = []; + + for (const key of params.indexToKey) { + const item = keyToItem.get(key); + if (!item) continue; + + if (item.type === 'separator') { + currentSeparator = item; + sequentialInGroup = 1; // Reset counter for new group (starts at 1) + } else if (item.type === 'asset') { + // Calculate order_index: (from * 1000 + sequential) * 1000 + const verseBase = currentSeparator?.from ?? UNASSIGNED_VERSE_BASE; + const newOrderIndex = (verseBase * 1000 + sequentialInGroup) * 1000; + sequentialInGroup++; + + // Determine the metadata based on the current separator + const newMetadata: AssetMetadata | null = currentSeparator?.from + ? { + verse: { + from: currentSeparator.from, + to: currentSeparator.to ?? currentSeparator.from + } + } + : null; + + // Check if metadata or order_index has changed + const currentMetadata = item.content.metadata; + const currentOrderIndex = item.content.order_index; + + const metadataChanged = + JSON.stringify(newMetadata) !== JSON.stringify(currentMetadata); + const orderIndexChanged = newOrderIndex !== currentOrderIndex; + + if (metadataChanged || orderIndexChanged) { + const update: AssetUpdatePayload = { + assetId: item.content.id + }; + + // Only include changed fields + if (metadataChanged) { + update.metadata = newMetadata; + } + if (orderIndexChanged) { + update.order_index = newOrderIndex; + } + + updates.push(update); + } + } + } + + // Batch update all changed assets + if (updates.length > 0) { + try { + await batchUpdateAssetMetadata(updates); + + // Invalidate queries to refresh the UI + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); // Refresh current assets to remove stale separators + } catch (err: unknown) { + console.error('Failed to update assets:', err); + } + } + }, + [queryClient, refetch] + ); + + if (!currentQuestId) { + return ( + + {t('noQuestSelected')} + + ); + } + + // Recording mode UI + if (showRecording) { + // Calculate initialOrderIndex: + // - If an asset is selected, use its order_index + // - Otherwise, use the last unassigned order_index (to continue from where we left off) + // - If no unassigned assets exist, undefined will trigger default in BibleRecordingView + const recordingOrderIndex = + selectedForRecording?.orderIndex ?? lastUnassignedOrderIndex; + + // Pass existing assets as initial data for instant rendering + return ( + { + setShowRecording(false); + setSelectedForRecording(null); // Clear selection when exiting + + // Normalize order_index for recorded verses before refetching + // This converts unit-scale (7001001) to thousand-scale (7001000) + if (recordedVerses && recordedVerses.length > 0) { + await normalizeOrderIndexForVerses(recordedVerses); + } + + // Refetch to show newly recorded assets with normalized order_index + void refetch(); + }} + initialAssets={assets} + label={selectedForRecording?.verseName} + initialOrderIndex={recordingOrderIndex} + verse={selectedForRecording?.metadata?.verse} + bookChapterLabel={bookChapterLabel} + bookChapterLabelFull={selectedQuest?.name} + nextVerse={nextVerse} + limitVerse={limitVerse} + /> + ); + } + + // Check if quest is published (source is 'synced') + // const isPublished = selectedQuest?.source === 'synced'; + + // Get project name for PrivateAccessGate + // Note: queriedProjectData doesn't include name, so we only use currentProjectData + const projectName = currentProjectData?.name || ''; + + return ( + + + {/* Left side: Quest name on top, Assets below */} + + {selectedQuest?.name && ( + + {selectedQuest.name.length > 25 + ? `${selectedQuest.name.slice(0, 25)}...` + : selectedQuest.name} + + )} + + {t('assets')} + + + + {/* Right side: Icons */} + + + {assets.length > 0 && ( + + )} + {isPublished ? ( + // Only show cloud-check icon if user is creator, member, or owner + canSeePublishedBadge ? ( + <> + + {currentQuestId && currentProjectId && ( + + )} + + ) : ( + // Show membership request button for non-members viewing published quest + isPrivateProject && ( + + ) + ) + ) : ( + // Only show publish/record buttons for authenticated users + currentUser && ( + + + {!isPublished && ( + + )} + {currentQuestId && currentProjectId && ( + + )} + + ) + )} + + + + + ) : undefined + } + suffixStyling={false} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + /> + + {SHOW_DEV_ELEMENTS && ( + {statusText} + )} + + {SHOW_DEV_ELEMENTS && + !isAttachmentStatesLoading && + safeAttachmentStates.size > 0 && ( + + + πŸ“Ž {t('liveAttachmentStates')}: + + + {attachmentSummaryText} + + + )} + + {isLoading || (isFetching && assets.length === 0) ? ( + searchQuery.trim().length > 0 ? ( + + + {t('searching')} + + ) : ( + + ) + ) : ( + + + renderItem({ item, isPublished, index }) + } + rowGap={3} + scrollableRef={scrollableRef} // required for auto scroll + overDrag="vertical" + onDragEnd={(params) => void handleSorting(params)} + customHandle + sortEnabled={!isSelectionMode} // Disable sorting in selection mode + // autoScrollActivationOffset={75} + // autoScrollSpeed={1} + // autoScrollEnabled={true} + /> + {/* Loading indicator for infinite scroll */} + {isFetchingNextPage && ( + + + + {t('loading')}... + + + )} + {/* End of list indicator */} + {!hasNextPage && assets.length > 0 && ( + + β€’β€’β€’ + + )} + + )} + + {/* Hide SpeedDial in selection mode */} + {!isSelectionMode && ( + + + + {/* For anonymous users, only show info button */} + {currentUser ? ( + <> + {allowSettings && isOwner ? ( + setShowSettingsModal(true)} + + /> + ) : !hasReported ? ( + setShowReportModal(true)} + /> + ) : null} + + ) : null} + {!isPublished && ( + setShowDeleteAllDrawer(true)} + /> + )} + {/* Info button always visible */} + { + console.log('πŸ“‹ [Info] Opening details modal', { + selectedQuest: selectedQuest?.id, + isDownloaded: isQuestDownloaded, + storageBytes: verificationState.estimatedStorageBytes + }); + setShowDetailsModal(true); + // Start verification to get storage estimate if quest is downloaded + if (isQuestDownloaded && !verificationState.isVerifying) { + verificationState.startVerification(); + } + }} + /> + + + + + )} + + {/* Sticky Record Button Footer - only show for authenticated users */} + {!isPublished && currentUser && ( + + {isSelectionMode ? ( + setShowVerseAssignerDrawer(true)} + /> + ) : ( + setShowRecording(true)} + > + + + + + {t('startRecordingSession')} + + + {selectedForRecording?.verseName + ? `${bookChapterLabelRef.current}:${selectedForRecording.verseName}` + : t('noLabelSelected')} + {/* {selectedForRecording?.verseName + ? `${t('doRecord')} ${bookChapterLabelRef.current}:${selectedForRecording.verseName}` + : t('doRecord')} */} + + + + + + )} + + )} + + + {allowSettings && isOwner && showSettingsModal && ( + setShowSettingsModal(false)} + questId={currentQuestId} + projectId={currentProjectId || ''} + /> + )} + + {/* Delete All Assets Drawer */} + {showDeleteAllDrawer && ( + setShowDeleteAllDrawer(false)} + onConfirm={() => void handleDeleteAllAssets()} + title="Delete All Assets?" + description="All assets in this quest will be permanently deleted. This action is irreversible and cannot be undone." + confirmationString={selectedQuest?.name || 'DELETE'} + /> + )} + {showDetailsModal && selectedQuest && ( + setShowDetailsModal(false)} + isDownloaded={isQuestDownloaded} + estimatedStorageBytes={verificationState.estimatedStorageBytes} + onOffloadClick={handleOffloadClick} + /> + )} + {showReportModal && ( + setShowReportModal(false)} + recordId={currentQuestId} + recordTable="quest" + hasAlreadyReported={hasReported} + creatorId={selectedQuest?.creator_id ?? undefined} + onReportSubmitted={() => refetchReport()} + /> + )} + + {/* Offload Verification Drawer */} + {showOffloadDrawer && ( + { + if (!open && !isOffloading) { + setShowOffloadDrawer(false); + verificationState.cancel(); + } + }} + onContinue={handleOffloadConfirm} + verificationState={verificationState} + isOffloading={isOffloading} + /> + )} + + {/* Rename Asset Drawer */} + {showRenameDrawer && ( + { + setShowRenameDrawer(open); + if (!open) { + setRenameAssetId(null); + } + }} + onSave={handleSaveRename} + /> + )} + + {/* Batch Verse Assignment Drawer */} + {showVerseAssignerDrawer && ( + { + setShowVerseAssignerDrawer(open); + }} + snapPoints={['40%']} + enableDynamicSizing={false} + > + + + Assign Verse + + Select verse range for {selectedAssetIds.size} selected asset + {selectedAssetIds.size !== 1 ? 's' : ''} + + + { + void handleAssignVerseToSelected(from, to); + }} + onCancel={() => setShowVerseAssignerDrawer(false)} + onRemove={handleRemoveLabelFromSelected} + hasSelectedAssetsWithLabels={selectedAssetsHaveLabels} + className="mx-4" + ScrollViewComponent={DrawerScrollView} + /> + + + )} + + {/* Private Access Gate Modal for Membership Requests */} + {isPrivateProject && showPrivateAccessModal && ( + setShowPrivateAccessModal(false)} + /> + )} + + {/* Verse Range Selector Drawer for editing existing separator */} + {verseSelectorState.isOpen && ( + { + if (!open) { + setVerseSelectorState({ isOpen: false, key: null }); + } + }} + snapPoints={['40%']} + enableDynamicSizing={false} + > + + + Select Verse Range + + + { + addVerseSeparator(from, to); + // Clear recording selection when any label is added + setSelectedForRecording(null); + setVerseSelectorState({ isOpen: false, key: null }); + }} + onCancel={() => + setVerseSelectorState({ isOpen: false, key: null }) + } + /> + + + + )} + + {/* Verse Range Selector Drawer for adding new label */} + {newLabelSelectorState.isOpen && ( + { + if (!open) { + setNewLabelSelectorState({ isOpen: false }); + } + }} + snapPoints={['40%']} + enableDynamicSizing={false} + > + + + Add Verse Label + + + { + addVerseSeparator(from, to); + // Clear recording selection when any label is added + setSelectedForRecording(null); + setNewLabelSelectorState({ isOpen: false }); + }} + onCancel={() => setNewLabelSelectorState({ isOpen: false })} + /> + + + + )} + + {/* Verse Range Selector Drawer for adding label above asset */} + {assetVerseSelectorState.isOpen && ( + { + if (!open) { + setAssetVerseSelectorState({ isOpen: false, assetId: null }); + } + }} + snapPoints={['40%']} + enableDynamicSizing={false} + > + + + Add Verse Label + + + { + if (assetVerseSelectorState.assetId) { + addVerseSeparator(from, to, assetVerseSelectorState.assetId); + } else { + addVerseSeparator(from, to); + } + // Clear recording selection when any label is added + setSelectedForRecording(null); + setAssetVerseSelectorState({ isOpen: false, assetId: null }); + }} + onCancel={() => + setAssetVerseSelectorState({ isOpen: false, assetId: null }) + } + /> + + + + )} + + {/* Verse Range Selector Drawer for editing separator */} + {editSeparatorState.isOpen && ( + { + if (!open) { + setEditSeparatorState({ isOpen: false, separatorKey: null }); + } + }} + snapPoints={['40%']} + enableDynamicSizing={false} + > + + + Edit Verse Label + + + {editSeparatorState.separatorKey && ( + + getMaxToForFromSeparator( + editSeparatorState.separatorKey!, + selectedFrom + ) + } + onApply={async (from, to) => { + if (editSeparatorState.separatorKey) { + await updateVerseSeparator( + editSeparatorState.separatorKey, + editSeparatorState.from, + editSeparatorState.to, + from, + to + ); + } + // Clear recording selection when any label is edited + // This ensures we don't have stale order_index references + setSelectedForRecording(null); + setEditSeparatorState({ isOpen: false, separatorKey: null }); + }} + onCancel={() => + setEditSeparatorState({ isOpen: false, separatorKey: null }) + } + /> + )} + + + + )} + + ); +} diff --git a/views/new/BibleBookList.tsx b/views/new/BibleBookList.tsx index d9d973898..1c0ee238e 100644 --- a/views/new/BibleBookList.tsx +++ b/views/new/BibleBookList.tsx @@ -1,8 +1,10 @@ +import { QuestionModal } from '@/components/QuestionModal'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { BIBLE_BOOKS } from '@/constants/bibleStructure'; +import { useLocalStore } from '@/store/localStore'; import { BOOK_ICON_MAP } from '@/utils/BOOK_GRAPHICS'; import { cn, useThemeColor } from '@/utils/styleUtils'; import { LegendList } from '@legendapp/list'; @@ -38,6 +40,28 @@ export function BibleBookList({ const buttonWidth = 110; const gap = 12; const padding = 16; + const verseMarkersFeaturePrompted = useLocalStore( + (state) => state.verseMarkersFeaturePrompted + ); + const setVerseMarkersFeaturePrompted = useLocalStore( + (state) => state.setVerseMarkersFeaturePrompted + ); + const setEnableVerseMarkers = useLocalStore( + (state) => state.setEnableVerseMarkers + ); + // Show modal if verseMarkersFeaturePrompted is false + const showPromptModal = verseMarkersFeaturePrompted === false; + + const handleYes = () => { + setVerseMarkersFeaturePrompted(true); + setEnableVerseMarkers(true); + }; + + const handleNo = () => { + setVerseMarkersFeaturePrompted(true); + setEnableVerseMarkers(false); + }; + const availableWidth = screenWidth - padding * 2; const buttonsPerRow = Math.max( 2, @@ -112,6 +136,13 @@ export function BibleBookList({ return ( + (typeof item === 'string' ? item : item.id)} diff --git a/views/new/NextGenAssetsView.tsx b/views/new/NextGenAssetsView.tsx index c81671c1b..4a3eaa26e 100644 --- a/views/new/NextGenAssetsView.tsx +++ b/views/new/NextGenAssetsView.tsx @@ -86,6 +86,7 @@ type Asset = typeof asset.$inferSelect; type AssetQuestLink = Asset & { quest_active: boolean; quest_visible: boolean; + tag_ids?: string[] | undefined; }; export default function NextGenAssetsView() { @@ -311,8 +312,25 @@ export default function NextGenAssetsView() { // Use memo key instead of Map reference for stable dependencies (always 1 string) }, [safeAttachmentStates]); + const handleAssetUpdate = React.useCallback(async () => { + // await queryClient.invalidateQueries({ + // // queryKey: ['assets', 'by-quest', currentQuestId], + // queryKey: ['by-quest', currentQuestId], + // exact: false + // }); + await queryClient.invalidateQueries({ + queryKey: ['assets'] + }); + }, [queryClient]); + const renderItem = React.useCallback( - ({ item }: { item: AssetQuestLink & { source?: HybridDataSource } }) => { + ({ + item, + isPublished + }: { + item: AssetQuestLink & { source?: HybridDataSource }; + isPublished: boolean; + }) => { const isPlaying = audioContext.isPlaying && audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && @@ -324,13 +342,17 @@ export default function NextGenAssetsView() { } return ( - + <> + + ); }, // Use stable memo key instead of Map reference to prevent hook dependency issues @@ -340,7 +362,8 @@ export default function NextGenAssetsView() { safeAttachmentStates, audioContext.isPlaying, audioContext.currentAudioId, - currentlyPlayingAssetId + currentlyPlayingAssetId, + handleAssetUpdate ] ); @@ -1276,7 +1299,7 @@ export default function NextGenAssetsView() { data={assets} keyExtractor={(item) => item.id} extraData={currentlyPlayingAssetId} - renderItem={({ item }) => renderItem({ item })} + renderItem={({ item }) => renderItem({ item, isPublished })} onEndReached={onEndReached} onEndReachedThreshold={0.5} estimatedItemSize={120} diff --git a/views/new/recording/components/AssetCard.tsx b/views/new/recording/components/AssetCard.tsx index b6fb25c66..6a50866ac 100644 --- a/views/new/recording/components/AssetCard.tsx +++ b/views/new/recording/components/AssetCard.tsx @@ -27,12 +27,12 @@ import { cn } from '@/utils/styleUtils'; import { CheckCircleIcon, CircleIcon } from 'lucide-react-native'; import React from 'react'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; import Animated, { Extrapolation, interpolate, useAnimatedStyle, - useDerivedValue, - type SharedValue + useDerivedValue } from 'react-native-reanimated'; import type { HybridDataSource } from '../../useHybridData'; diff --git a/views/new/recording/components/BibleRecordingView.tsx b/views/new/recording/components/BibleRecordingView.tsx new file mode 100644 index 000000000..0e78f59d0 --- /dev/null +++ b/views/new/recording/components/BibleRecordingView.tsx @@ -0,0 +1,3104 @@ +import type { ArrayInsertionWheelHandle } from '@/components/ArrayInsertionWheel'; +import ArrayInsertionWheel from '@/components/ArrayInsertionWheel'; +import { VersePill } from '@/components/VersePill'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { useAudio } from '@/contexts/AudioContext'; +import { useAuth } from '@/contexts/AuthContext'; +import { renameAsset } from '@/database_services/assetService'; +import { audioSegmentService } from '@/database_services/audioSegmentService'; +import { asset_content_link, project_language_link } from '@/db/drizzleSchema'; +import { system } from '@/db/powersync/system'; +import { useProjectById } from '@/hooks/db/useProjects'; +import { useCurrentNavigation } from '@/hooks/useAppNavigation'; +import { useLocalization } from '@/hooks/useLocalization'; +import { useLocalStore } from '@/store/localStore'; +import { resolveTable } from '@/utils/dbUtils'; +import { + fileExists, + getLocalAttachmentUriWithOPFS, + saveAudioLocally +} from '@/utils/fileUtils'; +import RNAlert from '@blazejkustra/react-native-alert'; +import { toCompilableQuery } from '@powersync/drizzle-driver'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useQueryClient } from '@tanstack/react-query'; +import { and, asc, eq } from 'drizzle-orm'; +import { Audio } from 'expo-av'; +import { ArrowDownNarrowWide, ArrowLeft, ChevronLeft, Mic, PauseIcon, PlayIcon, Plus } from 'lucide-react-native'; +import React, { useMemo } from 'react'; +import { InteractionManager, View } from 'react-native'; +import { useSharedValue } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useHybridData } from '../../useHybridData'; +import { useSelectionMode } from '../hooks/useSelectionMode'; +import { useVADRecording } from '../hooks/useVADRecording'; +import { saveRecording } from '../services/recordingService'; +import { AssetCard } from './AssetCard'; +import { FullScreenVADOverlay } from './FullScreenVADOverlay'; +import { RecordingControls } from './RecordingControls'; +import { RenameAssetDrawer } from './RenameAssetDrawer'; +import { SelectionControls } from './SelectionControls'; +import { VADSettingsDrawer } from './VADSettingsDrawer'; + +// Feature flag: true = use ArrayInsertionWheel, false = use LegendList +// const USE_INSERTION_WHEEL = true; +const DEBUG_MODE = false; +function debugLog(...args: unknown[]) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (DEBUG_MODE) { + console.log(...args); + } +} + +interface UIAsset { + type: 'asset'; + id: string; + name: string; + created_at: string; + order_index: number; + source: 'local' | 'synced' | 'cloud'; + segmentCount: number; + duration?: number; // Total duration in milliseconds + verse?: { from: number; to: number } | null; // Verse metadata (can be a range like 1-3) +} + +interface VersePillItem { + type: 'pill'; + id: string; // Unique ID for the pill (e.g., 'pill-verse-5') + order_index: number; + verse: { from: number; to: number } | null; // Verse metadata +} + +// Union type for items in the list (assets or verse pills) +type ListItem = UIAsset | VersePillItem; + +// Type guard to check if item is an asset +const isAsset = (item: ListItem): item is UIAsset => item.type === 'asset'; + +// Type guard to check if item is a pill +const isPill = (item: ListItem): item is VersePillItem => item.type === 'pill'; + +// Default order_index for unassigned verses: (999 * 1000 + 1) * 1000 = 999001000 +// Sequence starts at 1, not 0 (e.g., verse 7 β†’ 7001000, 7002000...) +// The extra *1000 leaves space for future insertions between assets +const DEFAULT_ORDER_INDEX = 999001000; + +// Verse metadata type +interface VerseRange { + from: number; + to: number; +} + +// Asset metadata type (prefixed with _ to allow unused) +interface _AssetMetadata { + verse?: VerseRange; +} + +interface BibleRecordingViewProps { + // Callback when user navigates back - receives array of verse numbers that were recorded + // Used by parent to normalize order_index for those verses + onBack: (recordedVerses?: number[]) => void; + // Pass existing assets as initial data to avoid redundant query + initialAssets?: unknown[]; + // Label for the recording session (e.g., verse reference like "5" or "5-7") + label?: string; + // Initial order_index for new recordings (default: 999001 for unassigned) + initialOrderIndex?: number; + // Verse metadata from the selected asset + verse?: VerseRange; + // Book chapter label for separators (short name, e.g., "Gen 1" or "Mat 3") + bookChapterLabel?: string; + // Book chapter label for header (full name from quest.name, e.g., "Genesis 1" or "Matthew 3") + bookChapterLabelFull?: string; + // Next verse number to record (for automatic progression) + nextVerse?: number | null; + // Limit verse number (for stopping automatic progression) + limitVerse?: number | null; +} + +const BibleRecordingView = ({ + onBack, + initialAssets: _initialAssets, // Not used - session mode starts with empty list + label: _label = '', // TODO: Display label in header + initialOrderIndex: _initialOrderIndex = DEFAULT_ORDER_INDEX, // TODO: Use for order_index calculation + verse: _verse, // TODO: Use for verse tracking and metadata + bookChapterLabel = 'Verse', // Book chapter label for separators (short name, e.g., "Gen 1") + bookChapterLabelFull, // Book chapter label for header (full name from quest, e.g., "Genesis 1") + nextVerse = null, // Next verse number to record (for automatic progression) + limitVerse = null // Limit verse number (for stopping automatic progression) +}: BibleRecordingViewProps) => { + // Log props on mount + React.useEffect(() => { + console.log( + `πŸ“₯ BibleRecordingView props | initialOrderIndex: ${_initialOrderIndex} | label: "${_label}" | verse: ${_verse ? `${_verse.from}-${_verse.to}` : 'null'} | nextVerse: ${nextVerse} | limitVerse: ${limitVerse}` + ); + }, [_initialOrderIndex, _label, _verse, nextVerse, limitVerse]); + + const queryClient = useQueryClient(); + const { t } = useLocalization(); + const navigation = useCurrentNavigation(); + const { currentQuestId, currentProjectId } = navigation; + const { currentUser } = useAuth(); + const { project: currentProject } = useProjectById(currentProjectId); + const audioContext = useAudio(); + const insets = useSafeAreaInsets(); + + // Get target languoid_id from project_language_link + const { data: targetLanguoidLink = [] } = useHybridData<{ + languoid_id: string | null; + }>({ + dataType: 'project-target-languoid-id', + queryKeyParams: [currentProjectId || ''], + offlineQuery: toCompilableQuery( + system.db + .select({ languoid_id: project_language_link.languoid_id }) + .from(project_language_link) + .where( + and( + eq(project_language_link.project_id, currentProjectId!), + eq(project_language_link.language_type, 'target') + ) + ) + .limit(1) + ), + cloudQueryFn: async () => { + if (!currentProjectId) return []; + const { data, error } = await system.supabaseConnector.client + .from('project_language_link') + .select('languoid_id') + .eq('project_id', currentProjectId) + .eq('language_type', 'target') + .not('languoid_id', 'is', null) + .limit(1) + .overrideTypes<{ languoid_id: string | null }[]>(); + if (error) throw error; + return data; + }, + enableCloudQuery: !!currentProjectId, + enableOfflineQuery: !!currentProjectId + }); + + const targetLanguoidId = targetLanguoidLink[0]?.languoid_id; + + // Recording state + const [isRecording, setIsRecording] = React.useState(false); + const [isVADLocked, setIsVADLocked] = React.useState(false); + + // VAD settings - persisted in local store for consistent UX + // These settings are automatically saved to AsyncStorage and restored on app restart + // Default: threshold=0.03 (normal sensitivity), silenceDuration=1000ms (1 second pause) + const vadThreshold = useLocalStore((state) => state.vadThreshold); + const setVadThreshold = useLocalStore((state) => state.setVadThreshold); + const vadSilenceDuration = useLocalStore((state) => state.vadSilenceDuration); + const setVadSilenceDuration = useLocalStore( + (state) => state.setVadSilenceDuration + ); + const vadDisplayMode = useLocalStore((state) => state.vadDisplayMode); + const setVadDisplayMode = useLocalStore((state) => state.setVadDisplayMode); + const enablePlayAll = useLocalStore((state) => state.enablePlayAll); + const [showVADSettings, setShowVADSettings] = React.useState(false); + const [autoCalibrateOnOpen, setAutoCalibrateOnOpen] = React.useState(false); + + // Track current recording order index and verse + const currentRecordingOrderRef = React.useRef(0); + const currentRecordingVerseRef = React.useRef<{ + from: number; + to: number; + } | null>(null); + const vadCounterRef = React.useRef(null); + const dbWriteQueueRef = React.useRef>(Promise.resolve()); + + // Track pending asset names to prevent duplicates when recording multiple assets quickly + const pendingAssetNamesRef = React.useRef>(new Set()); + + // Sequential name counter - persisted per quest in AsyncStorage + // Key format: `bible_recording_counter_${questId}` + // This counter is independent of order_index and VAD mode + const nameCounterRef = React.useRef(1); + const nameCounterLoadedRef = React.useRef(false); + + // Track which verses were recorded during this session + // Used to normalize order_index when returning to BibleAssetsView + const recordedVersesRef = React.useRef>(new Set()); + + // Track if the user is allowed to add a new verse + const allowAddVerseRef = React.useRef(true); + + // Load name counter from AsyncStorage on mount + React.useEffect(() => { + if (!currentQuestId || nameCounterLoadedRef.current) return; + + const loadCounter = async () => { + try { + const key = `bible_recording_counter_${currentQuestId}`; + const saved = await AsyncStorage.getItem(key); + if (saved) { + const value = parseInt(saved, 10); + if (!isNaN(value) && value > 0) { + nameCounterRef.current = value; + console.log( + `πŸ“Š Loaded name counter: ${value} for quest ${currentQuestId.slice(0, 8)}` + ); + } + } + nameCounterLoadedRef.current = true; + } catch (error) { + console.error('Failed to load name counter:', error); + nameCounterLoadedRef.current = true; + } + }; + + void loadCounter(); + }, [currentQuestId]); + + // Helper to save counter to AsyncStorage + const saveNameCounter = React.useCallback( + async (value: number) => { + if (!currentQuestId) return; + try { + const key = `bible_recording_counter_${currentQuestId}`; + await AsyncStorage.setItem(key, String(value)); + console.log( + `πŸ’Ύ Saved name counter: ${value} for quest ${currentQuestId.slice(0, 8)}` + ); + } catch (error) { + console.error('Failed to save name counter:', error); + } + }, + [currentQuestId] + ); + + // Track which asset is currently playing during play-all + const [currentlyPlayingAssetId, setCurrentlyPlayingAssetId] = React.useState< + string | null + >(null); + const assetUriMapRef = React.useRef>(new Map()); // URI -> assetId + const segmentDurationsRef = React.useRef([]); // Duration of each URI segment in ms + // Track segment ranges for each asset (start position, end position, duration) + const assetSegmentRangesRef = React.useRef< + Map + >(new Map()); + // Track last scrolled asset to avoid scrolling to the same asset multiple times + const lastScrolledAssetIdRef = React.useRef(null); + + // Track setTimeout IDs for cleanup + const timeoutIdsRef = React.useRef>>( + new Set() + ); + + // Track AbortController for batch loading cleanup + const batchLoadingControllerRef = React.useRef(null); + + // Create SharedValues for each asset's progress (0-100 percentage) + // We need to create them at the top level, so we'll create a pool and map them + // Store the mapping in a ref that gets updated when assets change + const assetProgressSharedMapRef = React.useRef< + Map>> + >(new Map()); + + // Create SharedValues for assets (max 100 assets supported) + // We create a pool and reuse them - must create at top level (hooks rule) + const progressPool0 = useSharedValue(0); + const progressPool1 = useSharedValue(0); + const progressPool2 = useSharedValue(0); + const progressPool3 = useSharedValue(0); + const progressPool4 = useSharedValue(0); + const progressPool5 = useSharedValue(0); + const progressPool6 = useSharedValue(0); + const progressPool7 = useSharedValue(0); + const progressPool8 = useSharedValue(0); + const progressPool9 = useSharedValue(0); + // Create more if needed (extend this pattern or use a different approach) + const progressPool = React.useRef([ + progressPool0, + progressPool1, + progressPool2, + progressPool3, + progressPool4, + progressPool5, + progressPool6, + progressPool7, + progressPool8, + progressPool9 + ]).current; + + // Insertion wheel state + const [insertionIndex, setInsertionIndex] = React.useState(0); + const wheelRef = React.useRef(null); + + // Track footer height for proper scrolling + const [footerHeight, setFooterHeight] = React.useState(0); + const ROW_HEIGHT = 80; + + // Dynamic verse tracking for automatic progression + // Starts as null - only set when user clicks "Add verse" button + // This allows the initial verse (_verse) to be displayed first + const [currentDynamicVerse, setCurrentDynamicVerse] = React.useState< + number | null + >(null); + + // Persist initial props in refs - these should NOT change during the recording session + // even when invalidateQueries causes re-renders. We capture them once on mount. + const persistedNextVerseRef = React.useRef(nextVerse); + const persistedLimitVerseRef = React.useRef(limitVerse); + const persistedVerseRef = React.useRef(_verse); + + // Log initial values on mount + React.useEffect(() => { + console.log( + `πŸ“Œ Persisted initial props | nextVerse: ${nextVerse} | limitVerse: ${limitVerse} | _verse: ${_verse?.from}-${_verse?.to}` + ); + persistedNextVerseRef.current = nextVerse; + persistedLimitVerseRef.current = limitVerse; + persistedVerseRef.current = _verse; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only run on mount - these values are persisted for the session + + // Debounced insertion index to prevent button flickering when scrolling fast + const [debouncedIsAtEnd, setDebouncedIsAtEnd] = React.useState(false); + + // Selection mode for batch operations (merge, delete) + const { + isSelectionMode, + selectedAssetIds, + enterSelection, + toggleSelect, + cancelSelection, + selectMultiple + } = useSelectionMode(); + + // Rename drawer state + const [showRenameDrawer, setShowRenameDrawer] = React.useState(false); + const [renameAssetId, setRenameAssetId] = React.useState(null); + const [renameAssetName, setRenameAssetName] = React.useState(''); + + // Track segment counts for each asset (loaded lazily) + const [assetSegmentCounts, setAssetSegmentCounts] = React.useState< + Map + >(new Map()); + + // Track durations for each asset (loaded lazily) + const [assetDurations, setAssetDurations] = React.useState< + Map + >(new Map()); + + // SESSION-ONLY ITEMS: Assets and verse pills created during this recording session + // When user exits and returns, the list starts with just the initial verse pill + // Assets are still saved to database, but we don't load existing ones + const [sessionItems, setSessionItems] = React.useState(() => { + // Initialize with the initial verse pill + if (!_verse) return []; + const initialVerse = _verse; + const initialPill: VersePillItem = { + type: 'pill', + id: `pill-initial-${_initialOrderIndex}`, + order_index: _initialOrderIndex, + verse: initialVerse + }; + console.log( + `🏷️ Initial pill created | order_index: ${_initialOrderIndex} | verse: ${initialVerse.from}-${initialVerse.to}` + ); + return [initialPill]; + }); + + // Track the "append" order_index (used when recording at the end of the list) + // Initialized from props or DEFAULT_ORDER_INDEX, increments by 1 for each recording at end + const appendOrderIndexRef = React.useRef(_initialOrderIndex + 1); + + // Helper to add a new asset to the session list + // Replicates the shift logic from recordingService.ts to keep UI in sync with DB + const addSessionAsset = React.useCallback( + (newAsset: { + id: string; + name: string; + order_index: number; + verse?: { from: number; to: number } | null; + }) => { + const targetOrderIndex = newAsset.order_index; + + setSessionItems((prev) => { + // 1. Shift existing items (both assets and pills) with order_index >= targetOrderIndex + // This mirrors the logic in recordingService.ts + const shifted = prev.map((item) => { + if (item.order_index >= targetOrderIndex) { + const itemName = isAsset(item) + ? item.name + : `pill-${item.verse?.from ?? 'null'}`; + console.log( + `πŸ“Š UI Shift: "${itemName}" ${item.order_index} β†’ ${item.order_index + 1}` + ); + return { ...item, order_index: item.order_index + 1 }; + } + return item; + }); + + // 2. Create new asset with the target order_index and verse + const uiAsset: UIAsset = { + type: 'asset', + id: newAsset.id, + name: newAsset.name, + created_at: new Date().toISOString(), + order_index: targetOrderIndex, + source: 'local', + segmentCount: 1, + duration: undefined, + verse: newAsset.verse ?? null + }; + + if (!newAsset.verse) { + allowAddVerseRef.current = false; + } + + console.log( + `βž• Adding "${newAsset.name}" with order_index: ${targetOrderIndex} | verse: ${newAsset.verse ? `${newAsset.verse.from}-${newAsset.verse.to}` : 'null'}` + ); + + // 3. Add new asset and sort by order_index + const newList = [...shifted, uiAsset]; + return newList.sort((a, b) => { + if (a.order_index === b.order_index) { + // Pills come before assets at the same order_index + if (isPill(a) && isAsset(b)) return -1; + if (isAsset(a) && isPill(b)) return 1; + // Both are same type - sort by created_at for assets, keep order for pills + if (isAsset(a) && isAsset(b)) { + return a.created_at.localeCompare(b.created_at); + } + return 0; + } + return a.order_index > b.order_index ? 1 : -1; + }); + }); + }, + [] + ); + + // Helper to add a new verse pill to the session list + const addVersePill = React.useCallback( + (verse: number, orderIndex: number) => { + const newPill: VersePillItem = { + type: 'pill', + id: `pill-verse-${verse}-${Date.now()}`, + order_index: orderIndex, + verse: { from: verse, to: verse } + }; + + console.log( + `🏷️ Adding pill for verse ${verse} with order_index: ${orderIndex}` + ); + + setSessionItems((prev) => { + const newList = [...prev, newPill]; + return newList.sort((a, b) => { + if (a.order_index === b.order_index) { + // Pills come before assets at the same order_index + if (isPill(a) && isAsset(b)) return -1; + if (isAsset(a) && isPill(b)) return 1; + if (isAsset(a) && isAsset(b)) { + return a.created_at.localeCompare(b.created_at); + } + return 0; + } + return a.order_index > b.order_index ? 1 : -1; + }); + }); + }, + [] + ); + + // Use session items - filter to get only assets for backward compatibility + const rawAssets = React.useMemo( + () => sessionItems.filter(isAsset), + [sessionItems] + ); + + // All items (assets + pills) for the wheel + const allItems = sessionItems; + + // Normalize assets + // ARCHITECTURE: + // - Asset: A single recording or merged group of recordings + // - Segment: One content_link row (merged assets have multiple segments) + // - Audio file: Individual audio file (each segment has audio[] array) + // + // METADATA (loaded lazily in background): + // - segmentCount: Number of content_link rows for this asset + // - duration: Sum of all audio files' durations across all segments + const assets = React.useMemo((): UIAsset[] => { + const result = rawAssets + .filter((a) => { + const obj = a as { + id?: string; + name?: string; + created_at?: string; + source?: string; + } | null; + return obj?.id && obj.name && obj.created_at && obj.source; + }) + .map((a, index) => { + const obj = a as { + id: string; + name: string; + created_at: string; + order_index?: number | null; + source: 'local' | 'synced' | 'cloud'; + verse?: { from: number; to: number } | null; + }; + // Get segment count and duration from lazy-loaded maps + // Default to 1 segment if not loaded yet, undefined for duration (shows loading state) + const segmentCount = assetSegmentCounts.get(obj.id) ?? 1; + const duration = assetDurations.get(obj.id); // undefined if not loaded yet + + // DEBUG: Log assets with multiple segments + if (segmentCount > 1) { + debugLog( + `πŸ“Š Asset "${obj.name}" (${obj.id.slice(0, 8)}) has ${segmentCount} segments` + ); + } + + return { + type: 'asset' as const, + id: obj.id, + name: obj.name, + created_at: obj.created_at, + order_index: + typeof obj.order_index === 'number' ? obj.order_index : index, + source: obj.source, + segmentCount, + duration, + verse: obj.verse ?? null + }; + }); + + // DEBUG: Summary of segment counts + const multiSegmentAssets = result.filter((a) => a.segmentCount > 1); + if (multiSegmentAssets.length > 0) { + debugLog( + `πŸ“Š Total assets with multiple segments: ${multiSegmentAssets.length}` + ); + } + + return result; + }, [rawAssets, assetSegmentCounts, assetDurations]); + + // Check if we're at the end of the list (for add verse button behavior) + const isAtEndOfList = React.useMemo( + () => allItems.length === 0 || insertionIndex >= allItems.length, + [allItems.length, insertionIndex] + ); + + // Get the item at the current insertion position (center of wheel) + // This can be either an asset or a verse pill + const highlightedItem = React.useMemo(() => { + if (allItems.length === 0) return null; + // insertionIndex can be 0 to allItems.length (inclusive) + // When at the end (insertionIndex >= allItems.length), we still want to show the last item + const idx = Math.min(insertionIndex, allItems.length - 1); + return allItems[idx] ?? null; + }, [allItems, insertionIndex]); + + // Get the highlighted item as an asset (null if it's a pill) + // Prefixed with _ as it may not be used directly but kept for potential future use + const _highlightedAsset = React.useMemo(() => { + if (!highlightedItem || isPill(highlightedItem)) return null; + return highlightedItem; + }, [highlightedItem]); + + // Get verse metadata from highlighted item (works for both assets and pills) + // This can be a range like { from: 1, to: 3 } + const highlightedItemVerse = React.useMemo(() => { + if (!highlightedItem) return null; + return highlightedItem.verse ?? null; + }, [highlightedItem]); + + // Legacy alias for backward compatibility + const highlightedAssetVerse = highlightedItemVerse; + + // Helper to format verse range as text + const formatVerseRange = React.useCallback( + (verse: { from: number; to: number } | null | undefined) => { + if (!verse?.from) return null; + const verseText = + verse.from === verse.to ? `${verse.from}` : `${verse.from}-${verse.to}`; + return `${bookChapterLabel}:${verseText}`; + }, + [bookChapterLabel] + ); + + // Build verse pill text based on context: + // - Always show the verse of the asset in the center of the wheel (highlightedAsset) + // - EXCEPT when user clicked "Add verse" button - then show the new verse + // - If no assets exist, show the initial verse from props or "No Label Assigned" + const versePillText = React.useMemo(() => { + // If user clicked "Add verse" button, show the new dynamic verse + if (currentDynamicVerse !== null) { + return ( + formatVerseRange({ + from: currentDynamicVerse, + to: currentDynamicVerse + }) ?? 'No Label Assigned' + ); + } + + // If there are assets, show the verse of the asset in the center + if (highlightedAssetVerse) { + return formatVerseRange(highlightedAssetVerse) ?? 'No Label Assigned'; + } + + // No assets yet - show the initial verse from props + if (persistedVerseRef.current) { + return formatVerseRange(persistedVerseRef.current) ?? 'No Label Assigned'; + } + + return 'No Label Assigned'; + }, [highlightedAssetVerse, formatVerseRange, currentDynamicVerse]); + + // Debounce logic for showing add verse button + // Uses isAtEndOfList calculated above + React.useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedIsAtEnd(isAtEndOfList); + }, 300); // 300ms debounce + + return () => clearTimeout(timeout); + }, [isAtEndOfList]); + + // Calculate the next verse to add (what the button will show) + // Uses persisted refs to avoid issues with query invalidation re-renders + // If user already clicked Add, show currentDynamicVerse + 1 (if within limit) + // If user hasn't clicked yet, show persisted nextVerse + const verseToAdd = React.useMemo(() => { + const limit = persistedLimitVerseRef.current; + + if (currentDynamicVerse !== null) { + // User already clicked Add - next verse is current + 1 + const next = currentDynamicVerse + 1; + // Check if next is within limit + if (limit !== null && next > limit) { + return null; // No more verses to add + } + return next; + } + // User hasn't clicked Add yet - use persisted nextVerse + return persistedNextVerseRef.current; + }, [currentDynamicVerse]); + + // Show add verse button if: + // 1. At the end of the list (debounced) + // 2. There's a verse available to add (verseToAdd not null) + const showAddVerseButton = React.useMemo( + () => debouncedIsAtEnd && verseToAdd !== null, + [debouncedIsAtEnd, verseToAdd] + ); + + // Log for debugging button visibility and verse pill + React.useEffect(() => { + const highlightedVerseStr = highlightedAssetVerse + ? `${highlightedAssetVerse.from}-${highlightedAssetVerse.to}` + : 'null'; + console.log( + `πŸ”˜ State | insertionIdx: ${insertionIndex} | assetsLen: ${assets.length} | isAtEnd: ${isAtEndOfList} | debouncedIsAtEnd: ${debouncedIsAtEnd} | highlightedVerse: ${highlightedVerseStr} | verseToAdd: ${verseToAdd} | currentDynamic: ${currentDynamicVerse} | pillText: ${versePillText}` + ); + }, [ + insertionIndex, + assets.length, + isAtEndOfList, + debouncedIsAtEnd, + highlightedAssetVerse, + verseToAdd, + currentDynamicVerse, + showAddVerseButton, + versePillText + ]); + + // Handle adding next verse metadata + // When clicked, adds a pill at the end of the list + // The button will then show the next verse (verseToAdd + 1) + const handleAddNextVerse = React.useCallback(() => { + if (verseToAdd === null) return; + + console.log(`βž• Adding verse ${verseToAdd} as pill to list`); + + // Calculate order_index for the first asset of this verse + // Formula: (verse * 1000 + 1) * 1000 + // This positions it at the beginning of the verse range + const newOrderIndex = (verseToAdd * 1000 + 1) * 1000; + + // Update appendOrderIndexRef to point to after this new pill + appendOrderIndexRef.current = newOrderIndex + 1; + + console.log( + `πŸ“Š Adding pill for verse ${verseToAdd} | order_index: ${newOrderIndex} | next append: ${appendOrderIndexRef.current}` + ); + + // Mark that a pill was added (so auto-scroll moves to end) + wasPillAddedRef.current = true; + + // Add the verse pill to the list + addVersePill(verseToAdd, newOrderIndex); + + // Set currentDynamicVerse to this verse (for button calculation) + setCurrentDynamicVerse(verseToAdd); + + // Move insertion index to the end (where the pill was added) + // This is done in the next useEffect that monitors allItems.length change + + // If VAD is active, update recording context to use the new pill + if (isVADLocked) { + const newVerse = { from: verseToAdd, to: verseToAdd }; + currentRecordingVerseRef.current = newVerse; + vadCounterRef.current = newOrderIndex + 1; + console.log( + `🎯 VAD: Updated to verse ${verseToAdd} | order_index: ${newOrderIndex + 1}` + ); + } + }, [verseToAdd, isVADLocked, addVersePill]); + + // Map assets to SharedValues from the pool (after assets is declared) + const assetIdsKey = React.useMemo( + () => assets.map((a) => a.id).join(','), + [assets] + ); + React.useEffect(() => { + if (assets.length === 0) { + assetProgressSharedMapRef.current.clear(); + return; + } + + const map = assetProgressSharedMapRef.current; + map.clear(); + + // Assign SharedValues from pool to assets + for (let i = 0; i < Math.min(assets.length, progressPool.length); i++) { + const asset = assets[i]; + if (asset) { + // Reset the SharedValue + progressPool[i]!.value = 0; + map.set(asset.id, progressPool[i]!); + } + } + }, [assetIdsKey, assets, progressPool]); + + // Stable item list that only updates when content actually changes + // We intentionally use assetContentKey instead of allItems to prevent re-renders + // when items array reference changes but content is identical + const itemsForWheel = React.useMemo(() => allItems, [allItems]); + + // Clamp insertion index when item count changes + React.useEffect(() => { + const maxIndex = allItems.length; // Can insert at 0..N (after last item) + if (insertionIndex > maxIndex) { + setInsertionIndex(maxIndex); + } + }, [allItems.length, insertionIndex]); + + // Track item count to detect new insertions + const previousItemCountRef = React.useRef(allItems.length); + + // Track if the last recording was in the middle (not at end) + // Used to determine if we should move insertionIndex to the new item + const wasRecordingInMiddleRef = React.useRef(false); + + // Track if a pill was just added (to distinguish from asset recording) + const wasPillAddedRef = React.useRef(false); + + // Auto-scroll behavior differs between list and wheel + React.useEffect(() => { + const currentCount = allItems.length; + const previousCount = previousItemCountRef.current; + + // Only scroll if a new item was added (count increased) + if (currentCount > previousCount && currentCount > 0) { + console.log( + `πŸ“œ Item added | prevCount: ${previousCount} β†’ ${currentCount} | insertionIndex: ${insertionIndex} | wasInMiddle: ${wasRecordingInMiddleRef.current} | wasPillAdded: ${wasPillAddedRef.current}` + ); + + console.log( + '[recording in the middle]>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>', + wasRecordingInMiddleRef.current + ); + + const wasInMiddle = wasRecordingInMiddleRef.current; + const wasPillAdded = wasPillAddedRef.current; + + // Reset the flags + wasRecordingInMiddleRef.current = false; + wasPillAddedRef.current = false; + + if (wasPillAdded) { + // A pill was added - move to the end + console.log( + `πŸ“ Pill added - moving to end: ${insertionIndex} β†’ ${currentCount}` + ); + setInsertionIndex(currentCount); + + // Scroll to the end + const timeoutId = setTimeout(() => { + try { + wheelRef.current?.scrollToInsertionIndex(currentCount, true); + } catch (error) { + console.error('Failed to scroll after pill added:', error); + } + timeoutIdsRef.current.delete(timeoutId); + }, 100); + timeoutIdsRef.current.add(timeoutId); + } else if (wasInMiddle) { + // Recorded in the middle - move to the new asset + const newIndex = insertionIndex + 1; + console.log( + `πŸ“ Moving to new asset (recorded in middle): ${insertionIndex} β†’ ${newIndex}` + ); + setInsertionIndex(newIndex); + + // Scroll to the new item + const timeoutId = setTimeout(() => { + try { + wheelRef.current?.scrollToInsertionIndex(newIndex, true); + } catch (error) { + console.error('Failed to scroll:', error); + } + timeoutIdsRef.current.delete(timeoutId); + }, 100); + timeoutIdsRef.current.add(timeoutId); + } else { + // Asset appended at the end - move to stay at end + console.log( + `πŸ“ Moving to end (appended): ${insertionIndex} β†’ ${currentCount}` + ); + setInsertionIndex(currentCount); + + // Scroll to the end + const timeoutId = setTimeout(() => { + try { + wheelRef.current?.scrollToInsertionIndex(currentCount, true); + } catch (error) { + console.error('Failed to scroll:', error); + } + timeoutIdsRef.current.delete(timeoutId); + }, 100); + timeoutIdsRef.current.add(timeoutId); + } + } + + previousItemCountRef.current = currentCount; + }, [allItems.length, insertionIndex]); + + // ============================================================================ + // AUDIO PLAYBACK + // ============================================================================ + + // Fetch audio URIs for an asset + // Includes fallback logic for local-only files when server records are removed + const getAssetAudioUris = React.useCallback( + async (assetId: string): Promise => { + try { + // Get content links from both synced and local tables + const assetContentLinkSynced = resolveTable('asset_content_link', { + localOverride: false + }); + const contentLinksSynced = await system.db + .select() + .from(assetContentLinkSynced) + .where(eq(assetContentLinkSynced.asset_id, assetId)); + + const assetContentLinkLocal = resolveTable('asset_content_link', { + localOverride: true + }); + const contentLinksLocal = await system.db + .select() + .from(assetContentLinkLocal) + .where(eq(assetContentLinkLocal.asset_id, assetId)); + + // Prefer synced links, but merge with local for fallback + const allContentLinks = [...contentLinksSynced, ...contentLinksLocal]; + + // Deduplicate by ID (prefer synced over local) + const seenIds = new Set(); + const uniqueLinks = allContentLinks.filter((link) => { + if (seenIds.has(link.id)) { + return false; + } + seenIds.add(link.id); + return true; + }); + + debugLog( + `πŸ“€ Found ${uniqueLinks.length} content link(s) for asset ${assetId.slice(0, 8)} (${contentLinksSynced.length} synced, ${contentLinksLocal.length} local)` + ); + + if (uniqueLinks.length === 0) { + debugLog('No content links found for asset:', assetId); + return []; + } + + // Get audio values from content links (can be URIs or attachment IDs) + const audioValues = uniqueLinks + .flatMap((link) => { + const audioArray = link.audio ?? []; + debugLog( + ` πŸ“Ž Content link has ${audioArray.length} audio file(s):`, + audioArray + ); + return audioArray; + }) + .filter((value): value is string => !!value); + + debugLog(`πŸ“Š Total audio files for asset: ${audioValues.length}`); + + if (audioValues.length === 0) { + debugLog('No audio values found in content links'); + return []; + } + + // Process each audio value - can be either a local URI or an attachment ID + const uris: string[] = []; + for (const audioValue of audioValues) { + // Check if this is already a local URI (starts with 'local/' or 'file://') + if (audioValue.startsWith('local/')) { + // It's a direct local URI from saveAudioLocally() + const constructedUri = + await getLocalAttachmentUriWithOPFS(audioValue); + // Check if file exists at constructed path + if (await fileExists(constructedUri)) { + uris.push(constructedUri); + debugLog( + 'βœ… Using direct local URI:', + constructedUri.slice(0, 80) + ); + } else { + // File doesn't exist at expected path - try to find it in attachment queue + debugLog( + `⚠️ Local URI ${audioValue} not found at ${constructedUri}, searching attachment queue...` + ); + + if (system.permAttachmentQueue) { + // Extract filename from local path (e.g., "local/uuid.wav" -> "uuid.wav") + const filename = audioValue.replace(/^local\//, ''); + // Extract UUID part (without extension) for more flexible matching + const uuidPart = filename.split('.')[0]; + + // Search attachment queue by filename or UUID + let attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE filename = ? OR filename LIKE ? OR id = ? OR id LIKE ? LIMIT 1`, + [filename, `%${uuidPart}%`, filename, `%${uuidPart}%`] + ); + + // If not found, try searching all attachments for this asset's content links + if (!attachment && uniqueLinks.length > 0) { + const allAttachmentIds = uniqueLinks + .flatMap((link) => link.audio ?? []) + .filter( + (av): av is string => + typeof av === 'string' && + !av.startsWith('local/') && + !av.startsWith('file://') + ); + if (allAttachmentIds.length > 0) { + const placeholders = allAttachmentIds + .map(() => '?') + .join(','); + attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE id IN (${placeholders}) LIMIT 1`, + allAttachmentIds + ); + } + } + + if (attachment?.local_uri) { + const foundUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + // Verify the found file actually exists + if (await fileExists(foundUri)) { + uris.push(foundUri); + debugLog( + `βœ… Found attachment in queue for local URI ${audioValue.slice(0, 20)}` + ); + } else { + debugLog( + `⚠️ Attachment found in queue but file doesn't exist: ${foundUri}` + ); + } + } else { + // Try fallback to local table for alternative audio values + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + debugLog(`βœ… Found fallback file URI`); + break; + } + } + } + } + } + } + } + } else if (audioValue.startsWith('file://')) { + // Already a full file URI - verify it exists + if (await fileExists(audioValue)) { + uris.push(audioValue); + debugLog('βœ… Using full file URI:', audioValue.slice(0, 80)); + } else { + debugLog(`⚠️ File URI does not exist: ${audioValue}`); + // Try to find in attachment queue by extracting filename from path + if (system.permAttachmentQueue) { + const filename = audioValue.split('/').pop(); + if (filename) { + const attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE filename = ? OR id = ? LIMIT 1`, + [filename, filename] + ); + + if (attachment?.local_uri) { + const foundUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + if (await fileExists(foundUri)) { + uris.push(foundUri); + debugLog(`βœ… Found attachment in queue for file URI`); + } + } + } + } + } + } else { + // It's an attachment ID - look it up in the attachment queue + if (!system.permAttachmentQueue) { + // No attachment queue - try fallback to local table + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('local/')) { + const fallbackUri = + await getLocalAttachmentUriWithOPFS(fallbackAudioValue); + if (await fileExists(fallbackUri)) { + uris.push(fallbackUri); + break; + } + } else if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + break; + } + } + } + } + continue; + } + + const attachment = await system.powersync.getOptional<{ + id: string; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE id = ?`, + [audioValue] + ); + + if (attachment?.local_uri) { + const localUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + if (await fileExists(localUri)) { + uris.push(localUri); + debugLog('βœ… Found attachment URI:', localUri.slice(0, 60)); + } + } else { + // Attachment ID not found in queue - try fallback to local table + debugLog( + `⚠️ Attachment ID ${audioValue.slice(0, 8)} not found in queue, checking local table fallback...` + ); + + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('local/')) { + const fallbackUri = + await getLocalAttachmentUriWithOPFS(fallbackAudioValue); + if (await fileExists(fallbackUri)) { + uris.push(fallbackUri); + debugLog( + `βœ… Found fallback local URI for attachment ${audioValue.slice(0, 8)}` + ); + break; + } + } else if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + debugLog( + `βœ… Found fallback file URI for attachment ${audioValue.slice(0, 8)}` + ); + break; + } + } + } + } else { + debugLog(`⚠️ Audio ${audioValue} not downloaded yet`); + } + } + } + } + + return uris; + } catch (error) { + console.error('Failed to fetch audio URIs:', error); + return []; + } + }, + [] + ); + + // Special audio ID for "play all" mode + const PLAY_ALL_AUDIO_ID = 'play-all-assets'; + + // Handle asset playback + const handlePlayAsset = React.useCallback( + async (assetId: string) => { + try { + const isThisAssetPlaying = + audioContext.isPlaying && audioContext.currentAudioId === assetId; + + if (isThisAssetPlaying) { + debugLog('⏸️ Stopping asset:', assetId.slice(0, 8)); + await audioContext.stopCurrentSound(); + } else { + debugLog('▢️ Playing asset:', assetId.slice(0, 8)); + const uris = await getAssetAudioUris(assetId); + + if (uris.length === 0) { + console.error('❌ No audio URIs found for asset:', assetId); + return; + } + + if (uris.length === 1 && uris[0]) { + debugLog('▢️ Playing single segment'); + await audioContext.playSound(uris[0], assetId); + } else if (uris.length > 1) { + debugLog(`▢️ Playing ${uris.length} segments in sequence`); + await audioContext.playSoundSequence(uris, assetId); + } + } + } catch (error) { + console.error('❌ Failed to play audio:', error); + } + }, + [audioContext, getAssetAudioUris] + ); + + // Track currently playing asset based on audio position during play-all + React.useEffect(() => { + if ( + !audioContext.isPlaying || + audioContext.currentAudioId !== PLAY_ALL_AUDIO_ID + ) { + setCurrentlyPlayingAssetId(null); + return; + } + + // Calculate which asset is playing based on cumulative position + // Also update progress for each asset based on its segment range + const checkCurrentAsset = () => { + const uris = Array.from(assetUriMapRef.current.keys()); + const durations = segmentDurationsRef.current; + const ranges = assetSegmentRangesRef.current; + + if (uris.length === 0) return; + + const position = audioContext.position; // Position in milliseconds + + // Update progress for each asset based on its segment range + const progressMap = assetProgressSharedMapRef.current; + for (const [assetId, range] of ranges.entries()) { + const progressShared = progressMap.get(assetId); + if (!progressShared) { + debugLog( + `⚠️ No progress SharedValue found for asset ${assetId.slice(0, 8)}` + ); + continue; + } + + if (position < range.startMs) { + // Before this asset's segments - no progress + progressShared.value = 0; + } else if (position >= range.endMs) { + // After this asset's segments - fully complete + progressShared.value = 100; + } else { + // Within this asset's segments - calculate progress + const assetPosition = position - range.startMs; + const progressPercent = (assetPosition / range.durationMs) * 100; + const clampedProgress = Math.min(100, Math.max(0, progressPercent)); + progressShared.value = clampedProgress; + debugLog( + `πŸ“Š Asset ${assetId.slice(0, 8)} progress: ${Math.round(clampedProgress)}% (position: ${Math.round(position)}ms, range: [${Math.round(range.startMs)}-${Math.round(range.endMs)}]ms)` + ); + } + } + + // Find which asset is currently playing + let newPlayingAssetId: string | null = null; + + // If we don't have durations yet, use simple percentage-based approach + if (durations.length === 0 || durations.every((d) => d === 0)) { + const duration = audioContext.duration; + if (duration === 0) return; + + // Fallback: use percentage-based calculation + const positionPercent = position / duration; + const uriIndex = Math.min( + Math.floor(positionPercent * uris.length), + uris.length - 1 + ); + + const currentUri = uris[uriIndex]; + if (currentUri) { + const assetId = assetUriMapRef.current.get(currentUri); + if (assetId) { + newPlayingAssetId = assetId; + } + } + } else { + // Calculate which segment we're in based on cumulative durations + let cumulativeDuration = 0; + for (let i = 0; i < uris.length; i++) { + const segmentDuration = durations[i] || 0; + const segmentStart = cumulativeDuration; + cumulativeDuration += segmentDuration; + + // If position is within this segment's range + if ( + (position >= segmentStart && position <= cumulativeDuration) || + (i === uris.length - 1 && position >= segmentStart) + ) { + const currentUri = uris[i]; + if (currentUri) { + const assetId = assetUriMapRef.current.get(currentUri); + if (assetId) { + newPlayingAssetId = assetId; + } + } + break; + } + } + } + + // Update currently playing asset ID and scroll to it + if (newPlayingAssetId) { + setCurrentlyPlayingAssetId((prev) => { + if (newPlayingAssetId !== prev) { + debugLog( + `🎡 Highlighting asset ${newPlayingAssetId.slice(0, 8)} (was: ${prev?.slice(0, 8) ?? 'none'})` + ); + + // Scroll to the currently playing asset (only if it changed) + if ( + wheelRef.current && + newPlayingAssetId !== lastScrolledAssetIdRef.current + ) { + // Find the index of the asset in the assets array + const assetIndex = assets.findIndex( + (a) => a.id === newPlayingAssetId + ); + if (assetIndex >= 0) { + debugLog( + `πŸ“œ Scrolling to asset at index ${assetIndex} (asset ${newPlayingAssetId.slice(0, 8)})` + ); + // Scroll the item to the top of the wheel + // scrollItemToTop adds 1 internally, so subtract 1 to get correct position + wheelRef.current.scrollItemToTop(assetIndex - 1, true); + lastScrolledAssetIdRef.current = newPlayingAssetId; + } else { + debugLog( + `⚠️ Could not find asset ${newPlayingAssetId.slice(0, 8)} in assets array` + ); + } + } + + return newPlayingAssetId; + } + return prev; + }); + } + }; + + // Check immediately and then periodically while playing + checkCurrentAsset(); + const interval = setInterval(checkCurrentAsset, 200); // Check every 200ms + return () => clearInterval(interval); + // Note: We intentionally read audioContext.position and audioContext.duration inside the callback + // rather than including them as dependencies, because they change frequently (every ~200ms) + // and we don't want to re-run the effect that often. The interval handles the updates. + // assetProgressSharedMap is a ref, so we access it directly in the callback. + // assets is included to find the asset index for scrolling. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [audioContext.isPlaying, audioContext.currentAudioId, assets]); + + // Handle play all assets + const handlePlayAllAssets = React.useCallback(async () => { + try { + const isPlayingAll = + audioContext.isPlaying && + audioContext.currentAudioId === PLAY_ALL_AUDIO_ID; + + if (isPlayingAll) { + debugLog('⏸️ Stopping play all'); + await audioContext.stopCurrentSound(); + setCurrentlyPlayingAssetId(null); + assetUriMapRef.current.clear(); + segmentDurationsRef.current = []; + assetSegmentRangesRef.current.clear(); + lastScrolledAssetIdRef.current = null; + // Reset all asset progress + for (const progressShared of assetProgressSharedMapRef.current.values()) { + progressShared.value = 0; + } + } else { + debugLog('▢️ Playing all assets'); + if (assets.length === 0) { + console.warn('⚠️ No assets to play'); + return; + } + + // Collect all URIs from all assets in order, tracking which asset each URI belongs to + const allUris: string[] = []; + assetUriMapRef.current.clear(); + segmentDurationsRef.current = []; + + for (const asset of assets) { + const uris = await getAssetAudioUris(asset.id); + for (const uri of uris) { + allUris.push(uri); + // Map each URI to its asset ID + assetUriMapRef.current.set(uri, asset.id); + } + } + + if (allUris.length === 0) { + console.error('❌ No audio URIs found for any assets'); + return; + } + + debugLog( + `▢️ Playing ${allUris.length} audio segments from ${assets.length} assets` + ); + + // Preload durations for accurate highlighting and calculate asset segment ranges + try { + const durations: number[] = []; + for (const uri of allUris) { + try { + const { sound } = await Audio.Sound.createAsync({ uri }); + const status = await sound.getStatusAsync(); + await sound.unloadAsync(); + durations.push( + status.isLoaded ? (status.durationMillis ?? 0) : 0 + ); + } catch (error) { + debugLog( + `Failed to get duration for ${uri.slice(0, 30)}:`, + error + ); + durations.push(0); + } + } + segmentDurationsRef.current = durations; + debugLog( + `πŸ“Š Loaded durations for ${durations.length} segments:`, + durations.map((d) => Math.round(d / 1000)).join('s, ') + 's' + ); + + // Calculate segment ranges for each asset + assetSegmentRangesRef.current.clear(); + let cumulativeStart = 0; + for (const asset of assets) { + const assetUris = allUris.filter( + (uri) => assetUriMapRef.current.get(uri) === asset.id + ); + if (assetUris.length === 0) continue; + + // Find the indices of this asset's URIs in the allUris array + const assetUriIndices: number[] = []; + for (let i = 0; i < allUris.length; i++) { + const uri = allUris[i]; + if (uri && assetUriMapRef.current.get(uri) === asset.id) { + assetUriIndices.push(i); + } + } + + // Calculate total duration for this asset's segments + const assetDuration = assetUriIndices.reduce( + (sum, idx) => sum + (durations[idx] || 0), + 0 + ); + + const startMs = cumulativeStart; + const endMs = cumulativeStart + assetDuration; + + assetSegmentRangesRef.current.set(asset.id, { + startMs, + endMs, + durationMs: assetDuration + }); + + // Reset progress for this asset + const progressShared = assetProgressSharedMapRef.current.get( + asset.id + ); + if (progressShared) { + progressShared.value = 0; + debugLog(`πŸ”„ Reset progress for asset ${asset.id.slice(0, 8)}`); + } else { + debugLog( + `⚠️ No progress SharedValue found for asset ${asset.id.slice(0, 8)} when setting up ranges` + ); + } + + debugLog( + `πŸ“Š Asset ${asset.id.slice(0, 8)} segments: ${assetUriIndices.length} segments, ${Math.round(assetDuration / 1000)}s total, range [${Math.round(startMs)}-${Math.round(endMs)}]ms` + ); + + cumulativeStart = endMs; + } + } catch (error) { + debugLog('Failed to preload durations:', error); + // Continue anyway - will use percentage-based fallback + } + + // Set the first asset as currently playing and scroll to it + if (assets.length > 0 && assets[0]) { + const firstAssetId = assets[0].id; + setCurrentlyPlayingAssetId(firstAssetId); + lastScrolledAssetIdRef.current = null; // Reset to allow immediate scroll + + // Scroll to first asset immediately + if (wheelRef.current) { + debugLog( + `πŸ“œ Scrolling to first asset at index 0 (asset ${firstAssetId.slice(0, 8)})` + ); + // scrollItemToTop adds 1 internally, so subtract 1 to get correct position (0 -> -1 -> 0) + wheelRef.current.scrollItemToTop(-1, true); + lastScrolledAssetIdRef.current = firstAssetId; + } + } + + await audioContext.playSoundSequence(allUris, PLAY_ALL_AUDIO_ID); + } + } catch (error) { + console.error('❌ Failed to play all assets:', error); + setCurrentlyPlayingAssetId(null); + assetUriMapRef.current.clear(); + segmentDurationsRef.current = []; + assetSegmentRangesRef.current.clear(); + lastScrolledAssetIdRef.current = null; + // Reset all asset progress + for (const progressShared of assetProgressSharedMapRef.current.values()) { + progressShared.value = 0; + } + } + }, [audioContext, getAssetAudioUris, assets]); + + // ============================================================================ + // RECORDING HANDLERS + // ============================================================================ + + /** + * CENTRALIZED INSERTION CONTEXT + * This function calculates where and how to insert new recordings + * based on the current wheel position and list state. + * + * Returns: + * - orderIndex: The order_index to use for the new recording + * - verse: The verse metadata to use for the new recording + * - isAtEnd: Whether we're inserting at the end of the list + */ + const getInsertionContext = React.useCallback( + (currentIndex: number = insertionIndex) => { + const isAtEnd = allItems.length === 0 || currentIndex >= allItems.length; + console.log( + 'πŸ” getInsertionContext | currentIndex:', + currentIndex, + '| allItems.length:', + allItems.length, + '| isAtEnd:', + isAtEnd + ); + + if (isAtEnd) { + // At end: calculate order_index based on last item, not appendOrderIndexRef + // appendOrderIndexRef is only used as a cache and gets updated after recording + const lastItem = allItems[allItems.length - 1]; + const orderIndex = lastItem + ? lastItem.order_index + 1 + : appendOrderIndexRef.current; // Fallback for empty list + const verse = lastItem?.verse ?? persistedVerseRef.current ?? null; + + console.log( + 'πŸ” At END | lastItem order_index:', + lastItem?.order_index, + '| calculated orderIndex:', + orderIndex, + '| appendOrderIndexRef:', + appendOrderIndexRef.current + ); + + return { orderIndex, verse, isAtEnd: true }; + } else { + // In middle: use selected item's context + const selectedItem = allItems[currentIndex]; + const orderIndex = selectedItem + ? selectedItem.order_index + 1 + : currentIndex + 1; + const verse = selectedItem?.verse ?? null; + + return { orderIndex, verse, isAtEnd: false }; + } + }, + [allItems, insertionIndex] + ); + + // Store insertion index in ref to prevent stale closure issues + const insertionIndexRef = React.useRef(insertionIndex); + React.useEffect(() => { + insertionIndexRef.current = insertionIndex; + }, [insertionIndex]); + + // Track if we're currently in the middle of the list (for VAD continuous recording) + // When VAD starts, we capture the position and use same order_index for all segments + // until VAD stops or user moves the wheel + const vadInsertionIndexRef = React.useRef(null); + // Track if VAD was started at the end of the list (append mode) + // This is captured once when VAD activates and doesn't change during the session + const vadIsAtEndRef = React.useRef(false); + + // Initialize VAD counter and verse when VAD mode activates + React.useEffect(() => { + if (isVADLocked && vadCounterRef.current === null) { + // Capture current position when VAD starts + vadInsertionIndexRef.current = insertionIndexRef.current; + + // Get insertion context based on current position + const { + orderIndex, + verse, + isAtEnd: contextIsAtEnd + } = getInsertionContext(insertionIndexRef.current); + + vadCounterRef.current = orderIndex; + currentRecordingVerseRef.current = verse; + + const selectedItem = contextIsAtEnd + ? allItems[allItems.length - 1] + : allItems[insertionIndexRef.current]; + const itemName = selectedItem + ? isPill(selectedItem) + ? `pill-${selectedItem.verse?.from ?? 'null'}` + : selectedItem.name + : 'none'; + + debugLog( + `🎯 VAD initialized ${contextIsAtEnd ? 'at END' : 'in MIDDLE'} | index: ${insertionIndexRef.current} | item: "${itemName}" | order_index: ${orderIndex} | verse: ${verse ? `${verse.from}-${verse.to}` : 'null'}` + ); + } else if (!isVADLocked) { + vadCounterRef.current = null; + vadInsertionIndexRef.current = null; + console.log( + '[INSERTION INDEX REF 000X VAD]>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>', + vadInsertionIndexRef.current, + insertionIndexRef.current + ); + vadIsAtEndRef.current = false; + } + }, [ + isVADLocked, + allItems, + currentDynamicVerse, + assets.length, + getInsertionContext + ]); + + // Manual recording handlers + const handleRecordingStart = React.useCallback(() => { + if (isRecording) return; + + const currentInsertionIndex = insertionIndexRef.current; + + console.log( + `🎬 Recording START | insertionIndex: ${currentInsertionIndex} | allItems.length: ${allItems.length}` + ); + + // Get insertion context (order_index and verse) based on current position + const { orderIndex, verse, isAtEnd } = getInsertionContext( + currentInsertionIndex + ); + + // Store values for use during recording + currentRecordingOrderRef.current = orderIndex; + currentRecordingVerseRef.current = verse; + + // Track if we're recording in the middle (for auto-scroll behavior) + wasRecordingInMiddleRef.current = !isAtEnd; + + // Update appendOrderIndexRef to point to next position after this recording + // This serves as a fallback for empty lists or initial state + if (isAtEnd) { + appendOrderIndexRef.current = orderIndex + 1; + console.log( + 'πŸ“Š Updated appendOrderIndexRef:', + appendOrderIndexRef.current, + '(for next recording at end)' + ); + } + + // If starting recording without verse, disable adding new verses + if (!verse) { + allowAddVerseRef.current = false; + } + + setIsRecording(true); + + const selectedItem = isAtEnd + ? allItems[allItems.length - 1] + : allItems[currentInsertionIndex]; + const itemName = selectedItem + ? isPill(selectedItem) + ? `pill-${selectedItem.verse?.from ?? 'null'}` + : selectedItem.name + : 'none'; + + console.log( + `🎯 Recording ${isAtEnd ? 'at END' : 'in MIDDLE'} | index: ${currentInsertionIndex} | item: "${itemName}" | order_index: ${orderIndex} | verse: ${verse ? `${verse.from}-${verse.to}` : 'null'}` + ); + }, [isRecording, allItems, getInsertionContext]); + + const handleRecordingStop = React.useCallback(() => { + debugLog('πŸ›‘ Manual recording stop'); + setIsRecording(false); + }, []); + + const handleRecordingDiscarded = React.useCallback(() => { + debugLog('πŸ—‘οΈ Recording discarded'); + setIsRecording(false); + }, []); + + const handleRecordingComplete = React.useCallback( + async (uri: string, _duration: number, _waveformData: number[]) => { + // Recalculate order_index based on current list state + // This ensures each consecutive recording gets a unique incremented order_index + const currentContext = getInsertionContext(insertionIndexRef.current); + const targetOrder = currentContext.orderIndex; + const verseToUse = currentContext.verse; + + // Update refs for next recording in same session + currentRecordingOrderRef.current = targetOrder; + currentRecordingVerseRef.current = verseToUse; + + try { + debugLog('πŸ’Ύ Saving recording | order_index:', targetOrder); + + // Validate required data + if ( + !currentProjectId || + !currentQuestId || + !currentProject || + !currentUser + ) { + console.error('❌ Missing required data'); + return; + } + + // Generate name using persistent counter (independent of order_index and VAD mode) + // Counter is persisted per quest in AsyncStorage and increments continuously + const nextNumber = nameCounterRef.current; + nameCounterRef.current++; // Increment immediately to reserve this number + const assetName = String(nextNumber).padStart(3, '0'); + pendingAssetNamesRef.current.add(assetName); + console.log( + `🏷️ Reserved name: ${assetName} | counter: ${nextNumber} β†’ ${nameCounterRef.current} | order_index: ${targetOrder}` + ); + + // Native module flushes the file before sending onSegmentComplete event. + // File should be ready, but iOS Simulator may need a moment (handled by retry logic in saveAudioLocally). + + // Save audio file locally (with retry logic for timing issues) + const saveResult = await (async () => { + try { + const savedUri = await saveAudioLocally(uri); + return { success: true as const, uri: savedUri }; + } catch (error) { + // Release the reserved name on error + pendingAssetNamesRef.current.delete(assetName); + console.error('❌ Failed to save audio file locally:', error); + return { success: false as const, error }; + } + })(); + + if (!saveResult.success) { + // Re-throw to be caught by outer catch block + throw saveResult.error; + } + + const localUri = saveResult.uri; + + // Queue DB write (serialized to prevent race conditions) + dbWriteQueueRef.current = dbWriteQueueRef.current + .then(async () => { + if (!targetLanguoidId) { + throw new Error('Target languoid not found for project'); + } + // Use the verse that was captured when recording started + // This ensures we use the correct verse for middle-of-list recordings + const verseToUse = currentRecordingVerseRef.current; + + const newAssetId = await saveRecording({ + questId: currentQuestId, + projectId: currentProjectId, + targetLanguoidId: targetLanguoidId, + userId: currentUser.id, + orderIndex: targetOrder, + audioUri: localUri, + assetName: assetName, // Pass the reserved name + metadata: verseToUse ? { verse: verseToUse } : null // Pass verse metadata if provided + }); + + // Log the saved asset details + console.log( + `πŸ“Ό Asset saved | name: "${assetName}" | order_index: ${targetOrder} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'}` + ); + + // Add to session assets list (UI only - not loaded from DB) + addSessionAsset({ + id: newAssetId, + name: assetName, + order_index: targetOrder, + verse: verseToUse + }); + + // Track which verse was recorded (for order_index normalization on return) + // If no verse is assigned, use 999 (UNASSIGNED_VERSE_BASE) + const verseToTrack = verseToUse?.from ?? 999; + recordedVersesRef.current.add(verseToTrack); + debugLog( + `πŸ“‹ Tracked verse ${verseToTrack} for normalization (total: ${recordedVersesRef.current.size})` + ); + + // Save the updated name counter to AsyncStorage + await saveNameCounter(nameCounterRef.current); + + // Release the reserved name after successful save + pendingAssetNamesRef.current.delete(assetName); + debugLog( + `βœ… Released name: ${assetName} (pending: ${pendingAssetNamesRef.current.size})` + ); + }) + .catch((err) => { + console.error('❌ DB write failed:', err); + // Release the reserved name on error + pendingAssetNamesRef.current.delete(assetName); + throw err; + }); + + await dbWriteQueueRef.current; + + // Invalidate queries to sync order_index after insertions in the middle + // This is needed because recordingService shifts order_index values + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + + debugLog('🏁 Recording saved'); + setIsRecording(false); + } catch (error) { + console.error('❌ Failed to save recording:', error); + setIsRecording(false); + } + }, + [ + currentProjectId, + currentQuestId, + currentProject, + currentUser, + queryClient, + targetLanguoidId, + addSessionAsset, + saveNameCounter, + getInsertionContext + ] + ); + + // VAD segment handlers + const handleVADSegmentStart = React.useCallback(() => { + if (vadCounterRef.current === null) { + console.error('❌ VAD counter not initialized!'); + return; + } + + const targetOrder = vadCounterRef.current; + // Use the captured isAtEnd state from when VAD was activated + // This prevents issues where assets.length changes during recording + const isAtEnd = vadIsAtEndRef.current; + + // Track if we're recording in the middle (for auto-scroll behavior) + wasRecordingInMiddleRef.current = !isAtEnd; + + debugLog( + `🎬 VAD: Segment starting | order_index: ${targetOrder} | isAtEnd: ${isAtEnd}` + ); + + currentRecordingOrderRef.current = targetOrder; + + // Increment VAD counter for next segment ONLY if appending at end + // If inserting in middle, all recordings get the same order_index + if (isAtEnd) { + vadCounterRef.current = targetOrder + 1; + appendOrderIndexRef.current = vadCounterRef.current; // Keep append ref in sync + } + }, []); + + const handleVADSegmentComplete = React.useCallback( + (uri: string) => { + if (!uri || uri === '') { + debugLog('πŸ—‘οΈ VAD: Segment discarded'); + return; + } + + debugLog('πŸ“Ό VAD: Segment complete'); + void handleRecordingComplete(uri, 0, []); + }, + [handleRecordingComplete] + ); + + // Hook up native VAD recording + const { + currentEnergy, + isRecording: isVADRecording, + energyShared, + isRecordingShared + } = useVADRecording({ + threshold: vadThreshold, + silenceDuration: vadSilenceDuration, + isVADActive: isVADLocked, + onSegmentStart: handleVADSegmentStart, + onSegmentComplete: handleVADSegmentComplete, + isManualRecording: isRecording + }); + + // Invalidate queries when VAD mode ends + React.useEffect(() => { + if (!isVADLocked) { + void queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + } + }, [isVADLocked, currentQuestId, queryClient]); + + // ============================================================================ + // LAZY LOAD SEGMENT COUNTS + // ============================================================================ + + // Stable reference to raw assets for segment count loading + // Only extract what we need to avoid circular dependencies + const assetMetadata = React.useMemo( + () => + rawAssets + .map((a) => { + const obj = a as { id?: string } | null; + return obj?.id; + }) + .filter((id): id is string => !!id), + [rawAssets] + ); + + const assetIds = React.useMemo( + () => assetMetadata.join(','), + [assetMetadata] + ); + + // Track which asset IDs we've loaded counts for to prevent re-loading + const loadedAssetIdsRef = React.useRef(new Set()); + + // Clear loaded IDs when asset list changes significantly (e.g., after merge/delete) + // This ensures segment counts are re-loaded for modified assets + const previousAssetIdsRef = React.useRef(assetIds); + React.useEffect(() => { + if (previousAssetIdsRef.current !== assetIds) { + // Asset list changed - clear cache for assets that no longer exist + const currentAssetIdSet = new Set(assetMetadata); + const toRemove = Array.from(loadedAssetIdsRef.current).filter( + (id) => !currentAssetIdSet.has(id) + ); + + if (toRemove.length > 0) { + debugLog( + `🧹 Clearing ${toRemove.length} stale asset segment cache entries` + ); + toRemove.forEach((id) => loadedAssetIdsRef.current.delete(id)); + + // Also clear from state maps + setAssetSegmentCounts((prev) => { + const next = new Map(prev); + toRemove.forEach((id) => next.delete(id)); + return next; + }); + setAssetDurations((prev) => { + const next = new Map(prev); + toRemove.forEach((id) => next.delete(id)); + return next; + }); + } + + previousAssetIdsRef.current = assetIds; + } + }, [assetIds, assetMetadata]); + + // OPTIMIZED: Load segment counts and durations in batches after UI is idle + // This prevents blocking the UI thread during initial render and animations + React.useEffect(() => { + // Check both ref AND state to determine if we need to load + // This ensures we reload when re-entering the view (state is cleared on unmount) + const assetsToLoad = assetMetadata.filter((id) => { + // Load if not in ref (never attempted) OR missing from state (needs reload) + const notInRef = !loadedAssetIdsRef.current.has(id); + const missingFromState = + !assetSegmentCounts.has(id) || !assetDurations.has(id); + return notInRef || missingFromState; + }); + + if (assetsToLoad.length === 0) { + // Nothing new to load - don't even start the async work + return; + } + + // Defer until animations complete + const interactionHandle = InteractionManager.runAfterInteractions(() => { + const controller = new AbortController(); + batchLoadingControllerRef.current = controller; + + // Process assets in batches to prevent blocking + const processBatch = async (startIdx: number) => { + if (controller.signal.aborted) return; + + const BATCH_SIZE = 5; // Process 5 assets at a time + const batch = assetsToLoad.slice(startIdx, startIdx + BATCH_SIZE); + + if (batch.length === 0) { + // All done! + debugLog('βœ… Finished loading all asset metadata'); + return; + } + + debugLog( + `πŸ“Š Loading batch ${Math.floor(startIdx / BATCH_SIZE) + 1}: ${batch.length} assets (${startIdx + 1}-${startIdx + batch.length} of ${assetsToLoad.length})` + ); + + try { + const newCounts = new Map(); + const newDurations = new Map(); + + for (const assetId of batch) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (controller.signal.aborted) break; + + try { + // Query asset_content_link to get audio segments + // ARCHITECTURE EXPLANATION: + // - Each asset can have multiple segments (merged assets) + // - Each segment is one row in asset_content_link + // - Each segment can have one or more audio files in its audio[] array + // + // COUNTS: + // - Segment count = number of content_link rows + // - Audio file count = total audio files across all segments + // - Duration = sum of all audio files' durations + const contentLinks = + await system.db.query.asset_content_link.findMany({ + columns: { + id: true, + audio: true + }, + where: eq(asset_content_link.asset_id, assetId), + orderBy: asc(asset_content_link.created_at) + }); + + // DEBUG: Log raw query result + debugLog( + `πŸ”Ž Query result for asset ${assetId.slice(0, 8)}:`, + contentLinks.length, + 'rows found' + ); + if (contentLinks.length > 0) { + debugLog( + ` First row ID: ${contentLinks[0]?.id.slice(0, 8)}, audio count: ${contentLinks[0]?.audio?.length ?? 0}` + ); + if (contentLinks.length > 1) { + debugLog( + ` Second row ID: ${contentLinks[1]?.id.slice(0, 8)}, audio count: ${contentLinks[1]?.audio?.length ?? 0}` + ); + } + } else { + console.warn( + `⚠️ NO content_link rows found for asset ${assetId.slice(0, 8)}!` + ); + } + + // SEGMENT COUNT: Number of content_link rows (each row = one segment) + const segmentCount = contentLinks.length || 1; + newCounts.set(assetId, segmentCount); + + // DEBUG: Log segment count for this asset + debugLog( + `πŸ” Asset ${assetId.slice(0, 8)} segment count: ${segmentCount} ${segmentCount > 1 ? 'βœ… MULTI-SEGMENT' : '(single)'}` + ); + + // AUDIO FILES: Extract all audio file references from all segments + // This flattens the audio arrays from all content_link rows + const audioValues = contentLinks + .flatMap((link) => link.audio ?? []) + .filter((value): value is string => !!value); + + // DEBUG: Log audio values found + debugLog( + `🎡 Asset ${assetId.slice(0, 8)} has ${audioValues.length} audio file(s) across ${segmentCount} segment(s) - loading durations...` + ); + + // DURATION: Load and sum all audio file durations + let totalDuration = 0; + + for (const audioValue of audioValues) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (controller.signal.aborted) break; + + try { + // Get the full URI for this audio + let audioUri: string | null = null; + if (audioValue.startsWith('local/')) { + audioUri = await getLocalAttachmentUriWithOPFS(audioValue); + } else if (audioValue.startsWith('file://')) { + audioUri = audioValue; + } else if (system.permAttachmentQueue) { + // It's an attachment ID + const attachment = await system.powersync.getOptional<{ + id: string; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE id = ?`, + [audioValue] + ); + if (attachment?.local_uri) { + audioUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + } + } + + if (audioUri) { + // Load audio file to get duration + const { sound } = await Audio.Sound.createAsync({ + uri: audioUri + }); + const status = await sound.getStatusAsync(); + await sound.unloadAsync(); + + if (status.isLoaded && status.durationMillis) { + totalDuration += status.durationMillis; + } + } + } catch (err) { + // Skip this segment if we can't load it + console.warn(`Failed to load duration for segment:`, err); + } + } + + if (totalDuration > 0) { + newDurations.set(assetId, totalDuration); + debugLog( + `⏱️ Asset ${assetId.slice(0, 8)} total duration: ${Math.round(totalDuration / 1000)}s` + ); + } else { + // Set duration to 0 to mark as loaded (prevents infinite retries) + // AssetCard will only show duration if it's > 0, so 0 won't be displayed + newDurations.set(assetId, 0); + debugLog( + `⚠️ Asset ${assetId.slice(0, 8)} has no duration (${audioValues.length} audio files found) - marked as loaded` + ); + } + + loadedAssetIdsRef.current.add(assetId); + } catch (err) { + // If query fails for any asset, default to 1 segment and 0 duration + // This marks it as loaded (prevents infinite retries) + console.warn(`Failed to load data for asset ${assetId}:`, err); + newCounts.set(assetId, 1); + newDurations.set(assetId, 0); + loadedAssetIdsRef.current.add(assetId); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (controller.signal.aborted) { + return; + } else { + if (newCounts.size > 0) { + // Merge with existing counts + setAssetSegmentCounts((prev) => { + const merged = new Map(prev); + for (const [id, count] of newCounts) { + merged.set(id, count); + } + return merged; + }); + debugLog( + `βœ… Batch loaded segment counts for ${newCounts.size} asset${newCounts.size > 1 ? 's' : ''}` + ); + } + + if (newDurations.size > 0) { + // Merge with existing durations + setAssetDurations((prev) => { + const merged = new Map(prev); + for (const [id, duration] of newDurations) { + merged.set(id, duration); + } + return merged; + }); + debugLog( + `βœ… Batch loaded durations for ${newDurations.size} asset${newDurations.size > 1 ? 's' : ''}` + ); + } + + // Schedule next batch with a frame delay to keep UI responsive + const timeoutId = setTimeout(() => { + timeoutIdsRef.current.delete(timeoutId); + void processBatch(startIdx + BATCH_SIZE); + }, 16); // One frame delay (60fps) + timeoutIdsRef.current.add(timeoutId); + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (controller.signal.aborted) { + return; + } else { + console.error('Failed to load asset metadata batch:', error); + // Continue with next batch even if this one failed + setTimeout(() => { + void processBatch(startIdx + BATCH_SIZE); + }, 16); + } + } + }; + + // Start processing from first batch + void processBatch(0); + + return () => { + controller.abort(); + }; + }); + + return () => { + interactionHandle.cancel(); + // Abort controller if it exists + if (batchLoadingControllerRef.current) { + batchLoadingControllerRef.current.abort(); + batchLoadingControllerRef.current = null; + } + // Clear any pending timeouts + const timeoutIds = timeoutIdsRef.current; + timeoutIds.forEach((id) => clearTimeout(id)); + timeoutIds.clear(); + }; + // Only depend on assetIds and assetMetadata - NOT on the state Maps + // The Maps are checked inside the effect with .has(), so we don't need them as dependencies + // Including them causes the effect to re-run every time durations are updated, which + // triggers unnecessary re-checks even though loadedAssetIdsRef prevents actual re-loading + }, [assetIds, assetMetadata]); + + // ============================================================================ + // ASSET OPERATIONS (Delete, Merge) + // ============================================================================ + + const handleDeleteLocalAsset = React.useCallback( + async (assetId: string) => { + try { + await audioSegmentService.deleteAudioSegment(assetId); + + // Remove from session assets list + setSessionItems((prev) => prev.filter((a) => a.id !== assetId)); + + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + } catch (e) { + console.error('Failed to delete local asset', e); + } + }, + [queryClient, currentQuestId] + ); + + const handleMergeDownLocal = React.useCallback( + async (index: number) => { + try { + const first = assets[index]; + const second = assets[index + 1]; + if (!first || !second || !currentUser) return; + if (first.source === 'cloud' || second.source === 'cloud') return; + + const contentLocal = resolveTable('asset_content_link', { + localOverride: true + }); + const secondContent = await system.db + .select() + .from(contentLocal) + .where(eq(contentLocal.asset_id, second.id)); + + for (const c of secondContent) { + if (!c.audio) continue; + await system.db.insert(contentLocal).values({ + asset_id: first.id, + source_language_id: c.source_language_id, // Deprecated field, kept for backward compatibility + languoid_id: c.languoid_id ?? c.source_language_id ?? null, // Use languoid_id if available, fallback to source_language_id + text: c.text || '', + audio: c.audio, + download_profiles: [currentUser.id] + }); + } + + await audioSegmentService.deleteAudioSegment(second.id); + + // Remove merged asset from session list (second one gets deleted) + setSessionItems((prev) => prev.filter((a) => a.id !== second.id)); + + // Force re-load of segment count for the merged asset + debugLog( + `πŸ”„ Forcing segment count reload for merged asset: ${first.id}` + ); + loadedAssetIdsRef.current.delete(first.id); + setAssetSegmentCounts((prev) => { + const next = new Map(prev); + next.delete(first.id); + return next; + }); + setAssetDurations((prev) => { + const next = new Map(prev); + next.delete(first.id); + return next; + }); + + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + } catch (e) { + console.error('Failed to merge local assets', e); + } + }, + [assets, currentUser, queryClient, currentQuestId] + ); + + const handleBatchMergeSelected = React.useCallback(() => { + const selectedOrdered = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + if (selectedOrdered.length < 2) return; + + RNAlert.alert( + 'Merge Assets', + `Are you sure you want to merge ${selectedOrdered.length} assets? The audio segments will be combined into the first selected asset, and the others will be deleted.`, + [ + { + text: 'Cancel', + style: 'cancel' + }, + { + text: 'Merge', + style: 'destructive', + onPress: () => { + void (async () => { + try { + if (!currentUser) return; + + const target = selectedOrdered[0]!; + const rest = selectedOrdered.slice(1); + const contentLocal = resolveTable('asset_content_link', { + localOverride: true + }); + + for (const src of rest) { + const srcContent = await system.db + .select() + .from(contentLocal) + .where(eq(contentLocal.asset_id, src.id)); + + for (const c of srcContent) { + if (!c.audio) continue; + await system.db.insert(contentLocal).values({ + asset_id: target.id, + source_language_id: c.source_language_id, // Deprecated field, kept for backward compatibility + languoid_id: + c.languoid_id ?? c.source_language_id ?? null, // Use languoid_id if available, fallback to source_language_id + text: c.text || '', + audio: c.audio, + download_profiles: [currentUser.id] + }); + } + + await audioSegmentService.deleteAudioSegment(src.id); + } + + // Remove merged assets from session list (all except target get deleted) + const deletedIds = new Set(rest.map((a) => a.id)); + setSessionItems((prev) => + prev.filter((a) => !deletedIds.has(a.id)) + ); + + // Force re-load of segment count for the merged target asset + debugLog( + `πŸ”„ Forcing segment count reload for merged asset: ${target.id}` + ); + loadedAssetIdsRef.current.delete(target.id); + setAssetSegmentCounts((prev) => { + const next = new Map(prev); + next.delete(target.id); + return next; + }); + setAssetDurations((prev) => { + const next = new Map(prev); + next.delete(target.id); + return next; + }); + + cancelSelection(); + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + + debugLog('βœ… Batch merge completed'); + } catch (e) { + console.error('Failed to batch merge local assets', e); + RNAlert.alert( + 'Error', + 'Failed to merge assets. Please try again.' + ); + } + })(); + } + } + ] + ); + }, [ + assets, + selectedAssetIds, + currentUser, + cancelSelection, + queryClient, + currentQuestId + ]); + + const handleBatchDeleteSelected = React.useCallback(() => { + const selectedOrdered = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + if (selectedOrdered.length < 1) return; + + RNAlert.alert( + 'Delete Assets', + `Are you sure you want to delete ${selectedOrdered.length} asset${selectedOrdered.length > 1 ? 's' : ''}? This action cannot be undone.`, + [ + { + text: 'Cancel', + style: 'cancel' + }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + void (async () => { + try { + for (const asset of selectedOrdered) { + await audioSegmentService.deleteAudioSegment(asset.id); + } + + // Remove deleted assets from session list + const deletedIds = new Set(selectedOrdered.map((a) => a.id)); + setSessionItems((prev) => + prev.filter((a) => !deletedIds.has(a.id)) + ); + + cancelSelection(); + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + + debugLog( + `βœ… Batch delete completed: ${selectedOrdered.length} assets` + ); + } catch (e) { + console.error('Failed to batch delete local assets', e); + RNAlert.alert( + 'Error', + 'Failed to delete assets. Please try again.' + ); + } + })(); + } + } + ] + ); + }, [assets, selectedAssetIds, cancelSelection, queryClient, currentQuestId]); + + // ============================================================================ + // SELECT ALL / DESELECT ALL + // ============================================================================ + + // Calculate if all local assets are selected + const allSelected = React.useMemo(() => { + if (!isSelectionMode || assets.length === 0) return false; + const selectableAssets = assets.filter((a) => a.source !== 'cloud'); + if (selectableAssets.length === 0) return false; + return selectableAssets.every((a) => selectedAssetIds.has(a.id)); + }, [isSelectionMode, assets, selectedAssetIds]); + + // Handle select all / deselect all + const handleSelectAll = React.useCallback(() => { + if (allSelected) { + // Deselect all + selectMultiple([]); + } else { + // Select all local assets (exclude cloud assets) + const selectableIds = assets + .filter((a) => a.source !== 'cloud') + .map((a) => a.id); + selectMultiple(selectableIds); + } + }, [allSelected, assets, selectMultiple]); + + // ============================================================================ + // RENAME ASSET + // ============================================================================ + + const handleRenameAsset = React.useCallback( + (assetId: string, currentName: string | null) => { + setRenameAssetId(assetId); + setRenameAssetName(currentName ?? ''); + setShowRenameDrawer(true); + }, + [] + ); + + const handleSaveRename = React.useCallback( + async (newName: string) => { + if (!renameAssetId) return; + + try { + // renameAsset will validate that this is a local-only asset + // and throw if it's synced (immutable) + await renameAsset(renameAssetId, newName); + + // Update the name directly in sessionAssets to reflect in UI immediately + // This is safe because the database was already updated successfully + setSessionItems((prev) => + prev.map((asset) => + asset.id === renameAssetId ? { ...asset, name: newName } : asset + ) + ); + + // Invalidate queries to refresh the list in parent view + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + + debugLog('βœ… Asset renamed successfully'); + } catch (error) { + console.error('❌ Failed to rename asset:', error); + if (error instanceof Error) { + console.warn('⚠️ Rename blocked:', error.message); + RNAlert.alert('Error', error.message); + } + } + }, + [renameAssetId, queryClient, currentQuestId] + ); + + // ============================================================================ + // CLEANUP ON UNMOUNT + // ============================================================================ + + // Cleanup effect: Clear all refs and stop audio when component unmounts + // This prevents memory leaks when navigating away from the recording view + React.useEffect(() => { + // Capture refs in variables to avoid stale closure warnings + const assetUriMap = assetUriMapRef.current; + const segmentDurations = segmentDurationsRef.current; + const assetSegmentRanges = assetSegmentRangesRef.current; + const assetProgressSharedMap = assetProgressSharedMapRef.current; + const pendingAssetNames = pendingAssetNamesRef.current; + const loadedAssetIds = loadedAssetIdsRef.current; + const timeoutIds = timeoutIdsRef.current; + // Store reference to audioContext - access current value in cleanup + const audioContextRef = audioContext; + + return () => { + // Stop audio playback if playing (check current state, not captured state) + if (audioContextRef.isPlaying) { + void audioContextRef.stopCurrentSound(); + } + + // Clear all refs to free memory + assetUriMap.clear(); + segmentDurations.length = 0; + assetSegmentRanges.clear(); + assetProgressSharedMap.clear(); + lastScrolledAssetIdRef.current = null; + pendingAssetNames.clear(); + loadedAssetIds.clear(); + + // Abort any ongoing batch loading + if (batchLoadingControllerRef.current) { + batchLoadingControllerRef.current.abort(); + batchLoadingControllerRef.current = null; + } + + // Clear all pending timeouts + timeoutIds.forEach((id) => clearTimeout(id)); + timeoutIds.clear(); + + // Reset state maps (they'll be recreated on remount) + setAssetSegmentCounts(new Map()); + setAssetDurations(new Map()); + setCurrentlyPlayingAssetId(null); + + debugLog('🧹 Cleaned up RecordingViewSimplified on unmount'); + }; + // Empty dependency array - this effect should only run on mount/unmount + // We access audioContext directly in cleanup to get the latest state + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ============================================================================ + // RENDER HELPERS + // ============================================================================ + + // Stable callbacks for AssetCard (don't change unless handlers change) + const stableHandlePlayAsset = React.useCallback(handlePlayAsset, [ + handlePlayAsset + ]); + const stableToggleSelect = React.useCallback(toggleSelect, [toggleSelect]); + const stableEnterSelection = React.useCallback(enterSelection, [ + enterSelection + ]); + const stableHandleDeleteLocalAsset = React.useCallback( + handleDeleteLocalAsset, + [handleDeleteLocalAsset] + ); + const stableHandleMergeDownLocal = React.useCallback(handleMergeDownLocal, [ + handleMergeDownLocal + ]); + const stableHandleRenameAsset = React.useCallback(handleRenameAsset, [ + handleRenameAsset + ]); + + // ============================================================================ + // OPTIMIZED CALLBACKS MAP - Prevents creating new functions in wheelChildren + // ============================================================================ + + // Create a memoized factory for asset callbacks + // This prevents creating new inline functions in wheelChildren useMemo + const createAssetCallbacks = React.useCallback( + (assetId: string) => ({ + onPress: () => { + if (isSelectionMode) { + stableToggleSelect(assetId); + } else { + void stableHandlePlayAsset(assetId); + } + }, + onLongPress: () => { + stableEnterSelection(assetId); + }, + onPlay: () => { + void stableHandlePlayAsset(assetId); + } + }), + [ + isSelectionMode, + stableToggleSelect, + stableHandlePlayAsset, + stableEnterSelection + ] + ); + + // Create a Map of callbacks per asset (only recreates when dependencies change) + // This is much more efficient than creating new functions in the render loop + const assetCallbacksMap = React.useMemo(() => { + const map = new Map< + string, + { + onPress: () => void; + onLongPress: () => void; + onPlay: () => void; + } + >(); + + itemsForWheel.forEach((item) => { + if (isAsset(item)) { + map.set(item.id, createAssetCallbacks(item.id)); + } + }); + + return map; + }, [itemsForWheel, createAssetCallbacks]); + + + // Lazy renderItem for ArrayInsertionWheel + // OPTIMIZED: Only renders items when they become visible (virtualizaΓ§Γ£o) + // No audioContext.position/duration dependencies - progress now uses SharedValues! + // This is much more efficient than pre-creating all children + const renderWheelItem = React.useCallback( + (item: ListItem, index: number) => { + // Render verse pill items differently from asset items + if (isPill(item)) { + const pillText = item.verse + ? (formatVerseRange(item.verse) ?? 'No Label') + : 'No Label'; + return ( + + + + ); + } + + // Asset item rendering + // Check if this asset is playing individually OR if it's the currently playing asset during play-all + const isThisAssetPlayingIndividually = + audioContext.isPlaying && audioContext.currentAudioId === item.id; + const isThisAssetPlayingInPlayAll = + audioContext.isPlaying && + audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && + currentlyPlayingAssetId === item.id; + const isThisAssetPlaying = + isThisAssetPlayingIndividually || isThisAssetPlayingInPlayAll; + const isSelected = selectedAssetIds.has(item.id); + + // Check if next item is an asset (not a pill) and not from cloud + const nextItem = itemsForWheel[index + 1]; + const canMergeDown = + index < itemsForWheel.length - 1 && + nextItem && + isAsset(nextItem) && + nextItem.source !== 'cloud'; + + // Duration from lazy-loaded metadata + const duration = item.duration; + + // Get custom progress for play-all mode + const customProgress = + audioContext.isPlaying && + audioContext.currentAudioId === PLAY_ALL_AUDIO_ID + ? assetProgressSharedMapRef.current.get(item.id) + : undefined; + + // Get stable callbacks from Map (avoids creating new functions) + const callbacks = assetCallbacksMap.get(item.id); + + // Fallback if callbacks not found (shouldn't happen, but defensive) + if (!callbacks) { + console.warn(`Missing callbacks for asset ${item.id}`); + return ; + } + + return ( + + ); + }, + [ + formatVerseRange, + audioContext.isPlaying, + audioContext.currentAudioId, + currentlyPlayingAssetId, + selectedAssetIds, + isSelectionMode, + itemsForWheel, + assetCallbacksMap, + stableHandleDeleteLocalAsset, + stableHandleMergeDownLocal, + stableHandleRenameAsset + ] + ); + + // SESSION-ONLY MODE: No loading/error states needed + // The list starts empty and only shows assets recorded in this session + + // Show full-screen overlay when VAD is locked and display mode is fullscreen + const showFullScreenOverlay = isVADLocked && vadDisplayMode === 'fullscreen'; + + const addButtonComponent = useMemo(() => { + // Apply same conditions as floating button (line 3050) + const shouldShow = + !isSelectionMode && + showAddVerseButton && + verseToAdd !== null && + !isVADRecording && + allowAddVerseRef.current; + + if (!shouldShow) return null; + + return ( + + + + + + + ); + }, [ + isSelectionMode, + showAddVerseButton, + verseToAdd, + isVADRecording, + handleAddNextVerse + ]); + + const boundaryComponent = useMemo( + () => ( + + + + {/* Language-agnostic visual: mic + circle-plus = "add recording here" */} + + + + + + {addButtonComponent} + + ), [addButtonComponent]); + + return ( + + {/* Full-screen VAD overlay - takes over entire screen */} + {showFullScreenOverlay && ( + { + // Cancel VAD mode + setIsVADLocked(false); + }} + /> + )} + + {/* Header */} + + + + + {bookChapterLabelFull || bookChapterLabel} + + {/* + {t('doRecord')} + */} + + + + {assets.length} {t('assets').toLowerCase()} + + {assets.length > 0 && enablePlayAll && ( + + )} + + + + {/* {(isRecording || isVADRecording)? ( */} + {(isVADLocked)? ( + + {highlightedItemVerse + ? `${t('recording')}: ${formatVerseRange(highlightedItemVerse)}` + : t('recording')} + + ) : ( + + {highlightedItemVerse + ? `${t('recordTo')}: ${formatVerseRange(highlightedItemVerse)}` + : `${t('noLabelSelected')}`} + + )} + + {/* Scrollable list area - full height with padding for controls */} + + { } + {/* {USE_INSERTION_WHEEL ? ( */} + // ArrayInsertionWheel mode - always show wheel (starts with initial verse pill) + + + ref={wheelRef} + value={insertionIndex} + onChange={(newIndex) => { + const item = itemsForWheel[newIndex]; + const itemDesc = item + ? isPill(item) + ? `pill-${item.verse?.from ?? 'null'}` + : item.name + : 'end'; + console.log( + `🎑 Wheel onChange: ${insertionIndex} β†’ ${newIndex} | ${itemDesc} ${item?.order_index}` + ); + setInsertionIndex(newIndex); + }} + rowHeight={ROW_HEIGHT} + className="h-full flex-1" + bottomInset={footerHeight} + boundaryComponent={boundaryComponent} + data={itemsForWheel} + renderItem={renderWheelItem} + /> + + {/* ) : ( + // LegendList mode (legacy) + assetsForLegendList.length > 0 && ( + + ) + )} */} + + + {/* Add verse button - floats above recording controls */} + {/* {!isSelectionMode && + showAddVerseButton && + verseToAdd !== null && + !isVADRecording && + allowAddVerseRef.current && ( + + + + + + + )} */} + {/* {!isSelectionMode && + showAddVerseButton && + verseToAdd !== null && + !isVADRecording && + allowAddVerseRef.current && ( + + + + + + + )} */} + + {/* Bottom controls - absolutely positioned */} + + {isSelectionMode ? ( + + + + ) : ( + setShowVADSettings(true)} + onAutoCalibratePress={() => { + setAutoCalibrateOnOpen(true); + setShowVADSettings(true); + }} + currentEnergy={currentEnergy} + vadThreshold={vadThreshold} + energyShared={energyShared} + isRecordingShared={isRecordingShared} + displayMode={vadDisplayMode} + /> + )} + + + {/* Rename drawer */} + { + setShowRenameDrawer(open); + if (!open) { + setRenameAssetId(null); + } + }} + onSave={handleSaveRename} + /> + + {/* VAD Settings Drawer */} + { + setShowVADSettings(open); + // Reset auto-calibrate flag when drawer closes + if (!open) { + setAutoCalibrateOnOpen(false); + } + }} + threshold={vadThreshold} + onThresholdChange={setVadThreshold} + silenceDuration={vadSilenceDuration} + onSilenceDurationChange={setVadSilenceDuration} + isVADLocked={isVADLocked} + displayMode={vadDisplayMode} + onDisplayModeChange={setVadDisplayMode} + autoCalibrateOnOpen={autoCalibrateOnOpen} + energyShared={energyShared} + /> + + ); +}; + +export default BibleRecordingView; diff --git a/views/new/recording/components/BibleSelectionControls.tsx b/views/new/recording/components/BibleSelectionControls.tsx new file mode 100644 index 000000000..f94b909bc --- /dev/null +++ b/views/new/recording/components/BibleSelectionControls.tsx @@ -0,0 +1,71 @@ +/** + * SelectionControls - Batch operation controls when in selection mode + * + * Shows: + * - Selected count + * - Cancel button + * - Merge button (requires 2+ selections) + * - Delete button (requires 1+ selections) + */ + +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { useLocalization } from '@/hooks/useLocalization'; +import { Bookmark, Merge, Trash2, X } from 'lucide-react-native'; +import React from 'react'; +import { View } from 'react-native'; + +interface BibleSelectionControlsProps { + selectedCount: number; + onCancel: () => void; + onMerge: () => void; + onDelete: () => void; + onAssignVerse?: () => void; +} + +export const BibleSelectionControls = React.memo(function SelectionControls({ + selectedCount, + onCancel, + onMerge, + onDelete, + onAssignVerse +}: BibleSelectionControlsProps) { + const { t } = useLocalization(); + return ( + + ({selectedCount}) + + + + + + + + + + ); +}); diff --git a/views/new/recording/components/LabeledAssetCard.tsx b/views/new/recording/components/LabeledAssetCard.tsx new file mode 100644 index 000000000..0caf5efb2 --- /dev/null +++ b/views/new/recording/components/LabeledAssetCard.tsx @@ -0,0 +1,438 @@ +/** + * AssetCard - Individual asset display with actions + * + * Features: + * - Tap card to play/pause audio (except when tapping label to rename) + * - Visual progress bar during playback + * - Duration display (monospace, muted) next to label + * - Delete and merge actions + * - Selection mode (WhatsApp-style long-press) + * + * Interaction: + * - Tap card β†’ play/pause audio (or toggle selection if in selection mode) + * - Tap label β†’ rename asset (when renameable) + * - Long press β†’ enter selection mode + * + * Performance: + * - Uses Reanimated for animations on native thread + * - Memoized to prevent unnecessary re-renders + */ + +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { useAudio } from '@/contexts/AudioContext'; +import type { Asset } from '@/hooks/db/useAssets'; +import { useLocalization } from '@/hooks/useLocalization'; +import { cn } from '@/utils/styleUtils'; +import { CheckCircleIcon, CircleIcon } from 'lucide-react-native'; +import React from 'react'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; +import Animated, { + Easing, + Extrapolation, + interpolate, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming +} from 'react-native-reanimated'; +import type { HybridDataSource } from '../../useHybridData'; + +interface AssetCardProps { + asset: Pick & { + source: HybridDataSource | 'optimistic'; + created_at?: string; + order_index?: number | null; + metadata?: string | { verse?: { from: number; to: number } } | null; + }; + index: number; + isSelected: boolean; + isSelectionMode: boolean; + isPlaying: boolean; + // progress removed - now calculated from SharedValues for 0 re-renders! + duration?: number; // Duration in milliseconds + segmentCount?: number; // Number of audio segments in this asset + // Custom progress for play-all mode (0-100 percentage) + // If provided, this overrides the default global progress calculation + customProgress?: SharedValue; + onPress: () => void; + onLongPress: () => void; + onPlay: (assetId: string) => void; + onRename?: (assetId: string, currentName: string | null) => void; + // Note: These callbacks are still passed but no longer used (batch operations only) + onDelete?: (assetId: string) => void; + onMerge?: (index: number) => void; + onEdit?: (assetId: string, assetName: string) => void; + canMergeDown?: boolean; + showVerseLabel?: boolean; // Whether to show the verse label on the card + bookChapterLabel?: string; // Book name and chapter (e.g., "Gen 1") for Bible verse format +} + +// Format duration in milliseconds to MM:SS +function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +/** + * Calculate age of asset in milliseconds + * Used by Reanimated worklet to compute highlight intensity + */ +function calculateAssetAge(createdAt?: string | Date): number { + if (!createdAt) return Infinity; // Very old, no highlight + + const now = Date.now(); + const created = + typeof createdAt === 'string' + ? new Date(createdAt).getTime() + : createdAt.getTime(); + const age = now - created; + + return age < 0 ? Infinity : age; +} + +function AssetCardInternal({ + asset, + index, + isSelected, + isSelectionMode, + isPlaying, + duration, + segmentCount, + customProgress, + onPress, + onLongPress, + onPlay, + onRename, + showVerseLabel = true, + bookChapterLabel +}: AssetCardProps) { + const audioContext = useAudio(); + + // CRITICAL: Only local-only assets can be renamed/edited/deleted (synced assets are immutable) + const isLocal = asset.source === 'local'; + // Renameable = local and not currently saving + const isRenameable = isLocal; + + // DEBUG: Log segment count and duration for this asset + React.useEffect(() => { + console.log( + `πŸƒ AssetCard render: ${asset.name} | segments: ${segmentCount ?? 'loading'} | duration: ${duration ? `${Math.round(duration / 1000)}s` : 'loading'}` + ); + }, [segmentCount, duration, asset.name]); + + // ============================================================================ + // REANIMATED ANIMATIONS (Run on native thread for better performance) + // ============================================================================ + + // NEW: Calculate progress from SharedValues (no re-renders!) + // This runs entirely on the UI thread at 60fps + // If customProgress is provided (for play-all mode), use that instead + const animatedProgress = useDerivedValue(() => { + 'worklet'; + if (!isPlaying) return 0; + + // Use custom progress if provided (for play-all mode with asset-specific progress) + if (customProgress) { + return customProgress.value; + } + + // Otherwise, use global progress calculation + const pos = audioContext.positionShared.value; + const dur = audioContext.durationShared.value; + + if (dur <= 0) return 0; + + // Calculate progress percentage (0-100) + const progressPercent = (pos / dur) * 100; + return Math.min(100, Math.max(0, progressPercent)); + }, [isPlaying, customProgress]); + + // Progress bar style (interpolate to slightly lead at the end) + const progressBarStyle = useAnimatedStyle(() => { + 'worklet'; + const progress = animatedProgress.value; + const width = interpolate( + progress, + [0, 95, 100], + [0, 97, 100], + Extrapolation.CLAMP + ); + return { + width: `${width}%` + }; + }); + + // Highlight animation for newly created assets + // Calculate initial age once to avoid recalculation + const initialAge = React.useMemo( + () => calculateAssetAge(asset.created_at), + [asset.created_at] + ); + + // Animate highlight intensity on native thread + const highlightProgress = useSharedValue(0); + const HIGHLIGHT_DURATION_MS = 12000; // Total highlight duration (12 seconds) + + React.useEffect(() => { + if (initialAge > HIGHLIGHT_DURATION_MS) { + // Too old, no animation needed + highlightProgress.value = 1; // 1 = fully decayed + return; + } + + // Animate from current age to fully decayed + const startProgress = initialAge / HIGHLIGHT_DURATION_MS; + highlightProgress.value = startProgress; + highlightProgress.value = withTiming(1, { + duration: HIGHLIGHT_DURATION_MS - initialAge, + easing: Easing.out(Easing.ease) + }); + }, [initialAge, highlightProgress]); + + // Derive highlight intensity using worklet (runs on native thread) + const highlightIntensity = useDerivedValue(() => { + 'worklet'; + // Power law decay: intensity = 1 / (1 + (progress * 4)^2) + // At progress=0: intensity = 1.0 (full highlight) + // At progress=0.25: intensity = 0.5 (half) + // At progress=0.5: intensity = 0.2 + // At progress=1: intensity = 0.06 (barely visible) + const normalized = highlightProgress.value * 4; + return 1 / (1 + Math.pow(normalized, 2)); + }); + + const highlightStyle = useAnimatedStyle(() => { + 'worklet'; + const intensity = highlightIntensity.value; + return { + opacity: intensity, + backgroundColor: `hsl(var(--chart-5) / ${intensity * 0.3})` + }; + }); + + // Handle card press: play/pause in normal mode, toggle selection in selection mode + const handleCardPress = React.useCallback(() => { + if (isSelectionMode) { + onPress(); // Toggle selection + } else { + onPlay(asset.id); // Play/pause audio + } + }, [isSelectionMode, onPress, onPlay, asset.id]); + + const { t } = useLocalization(); + + // Extract verse range from metadata if available + const verseRange = React.useMemo(() => { + console.log('πŸ” LabeledAssetCard - Checking metadata:', { + assetId: asset.id, + assetName: asset.name, + metadata: asset.metadata, + metadataType: typeof asset.metadata + }); + + if (!asset.metadata) { + return null; + } + + try { + const metadata: unknown = + typeof asset.metadata === 'string' + ? JSON.parse(asset.metadata) + : asset.metadata; + + if (metadata && typeof metadata === 'object' && 'verse' in metadata) { + const verseObj = (metadata as { verse?: unknown }).verse; + console.log('πŸ“– Verse object:', verseObj); + if ( + verseObj && + typeof verseObj === 'object' && + 'from' in verseObj && + 'to' in verseObj + ) { + const verse = verseObj as { from: unknown; to: unknown }; + if (typeof verse.from === 'number' && typeof verse.to === 'number') { + return { + from: verse.from, + to: verse.to + }; + } + } + } + } catch (e) { + console.error('❌ Error parsing metadata:', e); + } + + return null; + }, [asset.metadata, asset.id, asset.name]); + + // Format verse label in Bible format (e.g., "Gen 1:5" or "Gen 1:5-10") + const formattedVerseLabel = React.useMemo(() => { + if (!verseRange || !bookChapterLabel) { + return null; + } + + const { from, to } = verseRange; + if (from === to) { + return `${bookChapterLabel}:${from}`; + } + return `${bookChapterLabel}:${from}-${to}`; + }, [verseRange, bookChapterLabel]); + + return ( + + {/* Verse label - positioned above the top edge, center-right, outside the card */} + {formattedVerseLabel && showVerseLabel && ( + + + {formattedVerseLabel} + + + )} + + + {/* New asset highlight - decaying gradient overlay (Reanimated on native thread) */} + {initialAge < 12000 && ( + + )} + + {/* Progress bar overlay - positioned absolutely behind content (Reanimated on native thread) */} + {isPlaying && ( + + + + )} + + {/* Content - z-index ensures it appears above progress bar */} + + + + {index + 1} + + + + + {/* Label with rename functionality - prevents card play when tapped */} + { + if (!isSelectionMode && isRenameable && onRename) { + onRename(asset.id, asset.name); + } + }} + disabled={isSelectionMode || !isRenameable || !onRename} + activeOpacity={0.7} + > + + {asset.name || t('unnamedAsset')} + + + {segmentCount && segmentCount > 1 && ( + + + {segmentCount} + + + )} + + + + {asset.created_at && + new Date(asset.created_at).toLocaleString()} + + + + {duration !== undefined && duration > 0 && ( + + {formatDuration(duration)} + + )} + + {/* Selection checkbox - only show for local assets in selection mode */} + {isSelectionMode && isLocal && ( + + + + )} + + + + ); +} + +/** + * Memoized AssetCard to prevent unnecessary re-renders + * Only re-renders when props actually change + * + * OPTIMIZATION: progress removed from comparison - now uses SharedValues + * This eliminates 10 re-renders/second during audio playback! + */ +export const LabeledAssetCard = React.memo(AssetCardInternal, (prev, next) => { + // Custom equality check - only re-render if these props change + return ( + prev.asset.id === next.asset.id && + prev.asset.name === next.asset.name && + prev.asset.source === next.asset.source && + prev.asset.metadata === next.asset.metadata && + prev.index === next.index && + prev.isSelected === next.isSelected && + prev.isSelectionMode === next.isSelectionMode && + prev.isPlaying === next.isPlaying && + // prev.progress removed - uses SharedValues now! + prev.duration === next.duration && + prev.segmentCount === next.segmentCount && + prev.canMergeDown === next.canMergeDown && + // Compare customProgress SharedValue reference (needed when it changes from undefined to SharedValue) + prev.customProgress === next.customProgress && + prev.showVerseLabel === next.showVerseLabel && + prev.bookChapterLabel === next.bookChapterLabel && + // Callbacks are stable (wrapped in useCallback in parent), so we can skip checking them + prev.onPress === next.onPress && + prev.onLongPress === next.onLongPress && + prev.onPlay === next.onPlay && + prev.onRename === next.onRename && + prev.onDelete === next.onDelete && + prev.onMerge === next.onMerge && + prev.onEdit === next.onEdit + ); +}); diff --git a/views/new/recording/components/SelectionControls.tsx b/views/new/recording/components/SelectionControls.tsx index adfbd97b8..3bcdd2a66 100644 --- a/views/new/recording/components/SelectionControls.tsx +++ b/views/new/recording/components/SelectionControls.tsx @@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { useLocalization } from '@/hooks/useLocalization'; -import { Merge, Trash2, X } from 'lucide-react-native'; +import { ListChecks, ListX, Merge, Trash2, X } from 'lucide-react-native'; import React from 'react'; import { View } from 'react-native'; @@ -21,13 +21,19 @@ interface SelectionControlsProps { onCancel: () => void; onMerge: () => void; onDelete: () => void; + allowSelectAll?: boolean; + allSelected?: boolean; + onSelectAll?: () => void; } export const SelectionControls = React.memo(function SelectionControls({ selectedCount, onCancel, onMerge, - onDelete + onDelete, + allowSelectAll = false, + allSelected = false, + onSelectAll }: SelectionControlsProps) { const { t } = useLocalization(); return ( @@ -35,6 +41,11 @@ export const SelectionControls = React.memo(function SelectionControls({ ({selectedCount}) + {allowSelectAll && onSelectAll && ( + + )}