From de096a30df7eb4c02ceb47ed6508711e6689c666 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Mon, 24 Nov 2025 18:46:45 -0800 Subject: [PATCH 01/39] Creating verse label --- database_services/tagService.ts | 41 ++- hooks/db/useSearchTags.ts | 32 +++ views/new/AssetListItem.tsx | 56 +++- views/new/recording/components/TagModal.md | 80 ++++++ views/new/recording/components/TagModal.tsx | 267 ++++++++++++++++++++ 5 files changed, 472 insertions(+), 4 deletions(-) create mode 100644 hooks/db/useSearchTags.ts create mode 100644 views/new/recording/components/TagModal.md create mode 100644 views/new/recording/components/TagModal.tsx diff --git a/database_services/tagService.ts b/database_services/tagService.ts index 8d4f54bc9..f50f478a9 100644 --- a/database_services/tagService.ts +++ b/database_services/tagService.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm'; +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'; @@ -48,6 +48,45 @@ 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); + + console.log('Searching tags with condition:', searchTerm); + 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; + } } export const tagService = new TagService(); 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/views/new/AssetListItem.tsx b/views/new/AssetListItem.tsx index 59c334400..e390695f0 100644 --- a/views/new/AssetListItem.tsx +++ b/views/new/AssetListItem.tsx @@ -1,4 +1,5 @@ import { DownloadIndicator } from '@/components/DownloadIndicator'; +import { Badge } from '@/components/ui/badge'; import { Card, CardDescription, @@ -9,13 +10,20 @@ import { Icon } from '@/components/ui/icon'; import { useAuth } from '@/contexts/AuthContext'; import { LayerType, useStatusContext } from '@/contexts/StatusContext'; import type { asset as asset_type } from '@/db/drizzleSchema'; +import type { Tag } from '@/hooks/db/useSearchTags'; import { useAppNavigation } from '@/hooks/useAppNavigation'; import { useLocalization } from '@/hooks/useLocalization'; 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, + TagIcon +} from 'lucide-react-native'; import React from 'react'; import { Pressable, View } from 'react-native'; +import { TagModal } from './recording/components/TagModal'; import { useItemDownload, useItemDownloadStatus } from './useHybridData'; // Define props locally to avoid require cycle @@ -50,6 +58,19 @@ export const AssetListItem: React.FC = ({ asset.id ); + // Tag modal state + const [isTagModalVisible, setIsTagModalVisible] = React.useState(false); + + const handleOpenTagModal = () => { + setIsTagModalVisible(true); + }; + + const handleAssignTags = (tags: Tag[]) => { + console.log('Tags assigned to asset:', asset.id, tags); + // TODO: Implement tag assignment logic + setIsTagModalVisible(false); + }; + const layerStatus = useStatusContext(); const { allowEditing, invisible } = layerStatus.getStatusParams( LayerType.ASSET, @@ -102,8 +123,8 @@ export const AssetListItem: React.FC = ({ > - - + + {(!allowEditing || invisible) && ( {invisible && ( @@ -127,6 +148,27 @@ export const AssetListItem: React.FC = ({ + + {/* + {'Verse:1'} */} + + + + {1 == 0 && ( + + + + {`Verse:1`} + + + )} + = ({ */} + + setIsTagModalVisible(false)} + onAssignTags={handleAssignTags} + /> ); }; diff --git a/views/new/recording/components/TagModal.md b/views/new/recording/components/TagModal.md new file mode 100644 index 000000000..087732188 --- /dev/null +++ b/views/new/recording/components/TagModal.md @@ -0,0 +1,80 @@ +# TagModal Component + +## Descrição +Componente modal para atribuição de tags. Permite buscar e selecionar múltiplas tags da tabela `tag` do Drizzle usando o `tagService`. + +## Funcionalidades do TagService + +O componente utiliza os seguintes métodos do `tagService`: + +- `searchTags(searchTerm?, limit)`: Busca tags por padrão na chave (`key LIKE %termo%`) +- `getAllActiveTags(limit)`: Retorna todas as tags ativas +- Ordenação automática por `key` +- Filtro de tags ativas (`active = true`) + +## Propriedades + +```typescript +interface TagModalProps { + isVisible: boolean; // Controla a visibilidade do modal + selectedTag?: Tag; // Tag pré-selecionada (opcional) + searchTerm?: string; // Termo de busca inicial (opcional) + limit?: number; // Número máximo de tags retornadas (padrão: 20) + onClose: () => void; // Função chamada ao fechar o modal + onAssignTags: (tags: Tag[]) => void; // Função chamada ao atribuir tags +} +``` + +## Comportamento + +- **Se `searchTerm` for fornecido**: Lista todas as tags onde a `key` contenha o termo de busca +- **Se `searchTerm` estiver vazio**: Mostra um input de busca para o usuário pesquisar +- **Limitador**: O parâmetro `limit` controla quantas tags são retornadas (padrão: 20) +- **Seleção múltipla**: Permite selecionar/desselecionar múltiplas tags +- **Tag pré-selecionada**: Se `selectedTag` for fornecida, inicia com essa tag selecionada + +## Exemplo de Uso + +```typescript +import { TagModal } from './TagModal'; +import type { Tag } from '@/hooks/db/useSearchTags'; + +function MyComponent() { + const [isTagModalVisible, setIsTagModalVisible] = useState(false); + const [selectedTag, setSelectedTag] = useState(); + + const handleAssignTags = (tags: Tag[]) => { + console.log('Tags selecionadas:', tags); + // Implementar lógica de atribuição das tags + }; + + return ( + <> + + + setIsTagModalVisible(false)} + onAssignTags={handleAssignTags} + /> + + ); +} +``` + +## Busca com Termo Pré-definido + +```typescript + setIsTagModalVisible(false)} + onAssignTags={handleAssignTags} +/> +``` \ No newline at end of file diff --git a/views/new/recording/components/TagModal.tsx b/views/new/recording/components/TagModal.tsx new file mode 100644 index 000000000..3a318d2f3 --- /dev/null +++ b/views/new/recording/components/TagModal.tsx @@ -0,0 +1,267 @@ +/** + * 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 '@/hooks/db/useSearchTags'; +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; + searchTerm?: string; + limit?: number; + onClose: () => void; + onAssignTags: (tags: Tag[]) => void; +} + +export function TagModal({ + isVisible, + selectedTag, + searchTerm = '', + limit = 20, + onClose, + onAssignTags +}: TagModalProps) { + const [localSearchTerm, setLocalSearchTerm] = React.useState(searchTerm); + const [selectedTags, setSelectedTags] = React.useState( + selectedTag ? [selectedTag] : [] + ); + 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); + setSelectedTags(selectedTag ? [selectedTag] : []); + } + }, [isVisible, searchTerm, selectedTag]); + + // 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); + setSelectedTags(selectedTag ? [selectedTag] : []); + 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) => ( + handleTagToggle(tag)} + className="mr-2 rounded-full bg-primary px-3 py-1" + > + + {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 + ); + return ( + handleTagToggle(tag)} + className={`mb-2 mr-2 rounded-full px-3 py-1 ${ + isSelected + ? 'bg-primary' + : 'border border-border bg-background' + }`} + > + + {formatTagText(tag)} + {isSelected && ' ✓'} + + + ); + })} + + + )} + + + + + + + + + + + + ); +} From 71b2f9e1a293ff163389c725f4da5737cdbb565f Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Mon, 24 Nov 2025 19:00:17 -0800 Subject: [PATCH 02/39] Updating TagModal model --- views/new/recording/components/TagModal.tsx | 50 ++++++++++++++------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/views/new/recording/components/TagModal.tsx b/views/new/recording/components/TagModal.tsx index 3a318d2f3..40370a70b 100644 --- a/views/new/recording/components/TagModal.tsx +++ b/views/new/recording/components/TagModal.tsx @@ -178,17 +178,28 @@ export function TagModal({ Selected Tags ({selectedTags.length}): - {selectedTags.map((tag) => ( - handleTagToggle(tag)} - className="mr-2 rounded-full bg-primary px-3 py-1" - > - - {formatTagText(tag)} ✕ - - - ))} + {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)} ✕ + + + ); + })} )} @@ -214,21 +225,28 @@ export function TagModal({ 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 ${ - isSelected + isFirstSelected ? 'bg-primary' - : 'border border-border bg-background' + : isSelected + ? 'bg-primary/90' + : 'border border-border bg-background' }`} > {formatTagText(tag)} From 264975d7837f986ff0b51c612824bfdc6fa26eae Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Wed, 3 Dec 2025 07:30:42 -0800 Subject: [PATCH 03/39] Adapting tags to assets --- app/_layout.tsx | 6 ++ database_services/tagCache.ts | 8 +++ database_services/tagService.ts | 63 ++++++++++++++++- hooks/db/useAssets.ts | 65 ++++++++++++++---- hooks/useTagStore.ts | 24 +++++++ views/new/AssetListItem.tsx | 76 ++++++++++++++------- views/new/NextGenAssetsView.tsx | 15 +++- views/new/recording/components/TagModal.tsx | 25 +++++-- 8 files changed, 234 insertions(+), 48 deletions(-) create mode 100644 database_services/tagCache.ts create mode 100644 hooks/useTagStore.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index e70c38d81..fba5c00f7 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -33,6 +33,7 @@ import { toNavTheme } from '@/utils/styleUtils'; import { DarkTheme, DefaultTheme } from '@react-navigation/native'; import { StatusBar } from 'expo-status-bar'; +import { tagService } from '@/database_services/tagService'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import { KeyboardProvider } from 'react-native-keyboard-controller'; import { @@ -77,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/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 f50f478a9..792ddbd03 100644 --- a/database_services/tagService.ts +++ b/database_services/tagService.ts @@ -1,6 +1,8 @@ +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; @@ -65,8 +67,6 @@ export class TagService { const whereCondition = searchTerm ? and(eq(tag.active, true), like(tag.key, `${searchTerm}%`)) : eq(tag.active, true); - - console.log('Searching tags with condition:', searchTerm); const tags = await db .select() .from(tag) @@ -87,6 +87,65 @@ export class TagService { 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 + })); + + await tx.insert(contentLocal).values(newAssignments); + } + + 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/hooks/db/useAssets.ts b/hooks/db/useAssets.ts index 663301fc1..cfe253a2e 100644 --- a/hooks/db/useAssets.ts +++ b/hooks/db/useAssets.ts @@ -9,7 +9,6 @@ import { tag } from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; -import type { WithSource } from '@/utils/dbUtils'; import { blockedContentQuery, blockedUsersQuery } from '@/utils/dbUtils'; import { getOptionShowHiddenContent } from '@/utils/settingsUtils'; import { @@ -29,7 +28,8 @@ import { isNull, like, notInArray, - or + or, + sql } from 'drizzle-orm'; import { useMemo } from 'react'; import { @@ -1032,6 +1032,7 @@ export function useAssetsQuestLinkById( type AssetQuestLink = Asset & { quest_active: boolean; quest_visible: boolean; + tag_ids?: string[]; }; export function useAssetsByQuest( @@ -1081,7 +1082,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 +1100,30 @@ 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[]) : []; + } + } 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 +1147,8 @@ export function useAssetsByQuest( visible, active, asset:asset_id ( - * + *, + asset_tag_link(tag_id) ) ` ) @@ -1161,16 +1191,21 @@ 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; - return { - ...item.asset, - quest_visible: item.visible, - quest_active: item.active - } as AssetQuestLink; - }) - .filter((item): item is AssetQuestLink => item !== 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; }, diff --git a/hooks/useTagStore.ts b/hooks/useTagStore.ts new file mode 100644 index 000000000..3edf94c1c --- /dev/null +++ b/hooks/useTagStore.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand'; +import { tagCache } from '../database_services/tagCache'; + +interface Tag { + id: string; + key: string; + value?: string; +} + +interface TagStore { + getTag: (id: string) => Tag | undefined; + getManyTags: (ids: string[]) => Tag[]; +} + +export const useTagStore = create(() => ({ + getTag: (id) => tagCache.get(id), + + 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); + } +})); diff --git a/views/new/AssetListItem.tsx b/views/new/AssetListItem.tsx index e390695f0..52b3041ab 100644 --- a/views/new/AssetListItem.tsx +++ b/views/new/AssetListItem.tsx @@ -9,16 +9,19 @@ 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 type { Tag } from '@/hooks/db/useSearchTags'; 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, + Plus, TagIcon } from 'lucide-react-native'; import React from 'react'; @@ -33,16 +36,19 @@ type Asset = typeof asset_type.$inferSelect; type AssetQuestLink = Asset & { quest_active: boolean; quest_visible: boolean; + tag_ids?: string[] | undefined; }; export interface AssetListItemProps { asset: AssetQuestLink; questId: string; + onUpdate?: () => void; attachmentState?: AttachmentRecord; } export const AssetListItem: React.FC = ({ asset, questId, + onUpdate, attachmentState }) => { const { goToAsset, currentProjectData, currentQuestData } = @@ -52,6 +58,10 @@ export const AssetListItem: React.FC = ({ // Check if asset is downloaded const isDownloaded = useItemDownloadStatus(asset, currentUser?.id); + const getManyTags = useTagStore((s) => s.getManyTags); + const tags = getManyTags(asset.tag_ids || []); + console.log('[Fetched tags from store]:', tags); + // Download mutation const { mutate: downloadAsset, isPending: isDownloading } = useItemDownload( 'asset', @@ -62,13 +72,29 @@ export const AssetListItem: React.FC = ({ const [isTagModalVisible, setIsTagModalVisible] = React.useState(false); const handleOpenTagModal = () => { + console.log('Opening tag modal for asset:', asset.id); setIsTagModalVisible(true); }; - const handleAssignTags = (tags: Tag[]) => { - console.log('Tags assigned to asset:', asset.id, tags); - // TODO: Implement tag assignment logic - setIsTagModalVisible(false); + 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(); @@ -116,6 +142,8 @@ export const AssetListItem: React.FC = ({ downloadAsset({ userId: currentUser.id, download: !isDownloaded }); }; + const tag = tags.length > 0 ? tags[0] : null; + return ( = ({ - {/* - {'Verse:1'} */} - - + + {tags.length === 0 ? ( + + + + + ) : ( + + + + + {tag && `${tag.key}${tag.value && `: ${tag.value}`}`} + + + + )} - {1 == 0 && ( - - - - {`Verse:1`} - - - )} = ({ isVisible={isTagModalVisible} searchTerm="" limit={200} + initialSelectedTags={tags} onClose={() => setIsTagModalVisible(false)} onAssignTags={handleAssignTags} /> diff --git a/views/new/NextGenAssetsView.tsx b/views/new/NextGenAssetsView.tsx index b1b9ffd03..7c6ccbbd8 100644 --- a/views/new/NextGenAssetsView.tsx +++ b/views/new/NextGenAssetsView.tsx @@ -79,6 +79,7 @@ type Asset = typeof asset.$inferSelect; type AssetQuestLink = Asset & { quest_active: boolean; quest_visible: boolean; + tag_ids?: string[] | undefined; }; export default function NextGenAssetsView() { @@ -291,6 +292,17 @@ 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 } }) => ( ), // Use stable memo key instead of Map reference to prevent hook dependency issues // Always has exactly 2 dependencies (string, string) - never changes size - [currentQuestId, safeAttachmentStates] + [currentQuestId, safeAttachmentStates, handleAssetUpdate] ); const onEndReached = React.useCallback(() => { diff --git a/views/new/recording/components/TagModal.tsx b/views/new/recording/components/TagModal.tsx index 40370a70b..8edd0646b 100644 --- a/views/new/recording/components/TagModal.tsx +++ b/views/new/recording/components/TagModal.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; -import type { Tag } from '@/hooks/db/useSearchTags'; +import type { Tag } from '@/database_services/tagCache'; import { useSearchTags } from '@/hooks/db/useSearchTags'; import React from 'react'; import type { TextInput } from 'react-native'; @@ -20,6 +20,7 @@ import Animated, { interface TagModalProps { isVisible: boolean; selectedTag?: Tag; + initialSelectedTags?: Tag[]; searchTerm?: string; limit?: number; onClose: () => void; @@ -29,15 +30,17 @@ interface TagModalProps { export function TagModal({ isVisible, selectedTag, + initialSelectedTags = [], searchTerm = '', limit = 20, onClose, onAssignTags }: TagModalProps) { const [localSearchTerm, setLocalSearchTerm] = React.useState(searchTerm); - const [selectedTags, setSelectedTags] = React.useState( - selectedTag ? [selectedTag] : [] - ); + const [selectedTags, setSelectedTags] = React.useState(() => { + if (selectedTag) return [selectedTag]; + return initialSelectedTags; + }); const [modalVisible, setModalVisible] = React.useState(false); const inputRef = React.useRef(null); @@ -53,9 +56,13 @@ export function TagModal({ React.useEffect(() => { if (isVisible) { setLocalSearchTerm(searchTerm); - setSelectedTags(selectedTag ? [selectedTag] : []); + if (selectedTag) { + setSelectedTags([selectedTag]); + } else { + setSelectedTags(initialSelectedTags); + } } - }, [isVisible, searchTerm, selectedTag]); + }, [isVisible, searchTerm, selectedTag, initialSelectedTags]); // Handle modal visibility with exit animation React.useEffect(() => { @@ -126,7 +133,11 @@ export function TagModal({ const handleCancel = () => { setLocalSearchTerm(searchTerm); - setSelectedTags(selectedTag ? [selectedTag] : []); + if (selectedTag) { + setSelectedTags([selectedTag]); + } else { + setSelectedTags(initialSelectedTags); + } onClose(); }; From e2d4a05dd92ff909e0d16f60e2664b57a78d7487 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Sat, 6 Dec 2025 11:23:22 -0800 Subject: [PATCH 04/39] Fixing UI Onboard View Button alignment --- views/new/SimpleOnboardingFlow.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/views/new/SimpleOnboardingFlow.tsx b/views/new/SimpleOnboardingFlow.tsx index b411a322e..bdab6c59a 100644 --- a/views/new/SimpleOnboardingFlow.tsx +++ b/views/new/SimpleOnboardingFlow.tsx @@ -328,9 +328,9 @@ export function SimpleOnboardingFlow({ variant="default" size="lg" onPress={handleAction} - className="w-full" + className="w-72" > - + {t('onboardingContinue')} @@ -395,7 +395,7 @@ export function SimpleOnboardingFlow({ variant="default" size="lg" onPress={handleAction} - className="w-full" + className="w-72" > {t('onboardingContinue')} @@ -452,7 +452,7 @@ export function SimpleOnboardingFlow({ variant="default" size="lg" onPress={handleAction} - className="w-full" + className="w-72" > {t('onboardingContinue')} @@ -519,7 +519,7 @@ export function SimpleOnboardingFlow({ variant="default" size="lg" onPress={handleAction} - className="w-full" + className="w-72" > {t('onboardingContinue')} From 3a81d90df0346cfbb1dd3e6aef876cee05f3a48e Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Wed, 10 Dec 2025 06:26:28 -0800 Subject: [PATCH 05/39] Add Assets Separators --- components/SectionSeparator.tsx | 42 +++++++++ components/SortListCombo.tsx | 158 ++++++++++++++++++++++++++++++++ components/ui/select.tsx | 32 ++++--- database_services/tagService.ts | 9 +- views/new/AssetListItem.tsx | 2 +- views/new/NextGenAssetsView.tsx | 18 ++-- 6 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 components/SectionSeparator.tsx create mode 100644 components/SortListCombo.tsx 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/ui/select.tsx b/components/ui/select.tsx index 29a1bb341..7369ac80a 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 && ( + + + + + + )} = ({ diff --git a/views/new/NextGenAssetsView.tsx b/views/new/NextGenAssetsView.tsx index 8658af8c4..5abdb060a 100644 --- a/views/new/NextGenAssetsView.tsx +++ b/views/new/NextGenAssetsView.tsx @@ -332,14 +332,16 @@ export default function NextGenAssetsView() { } return ( - + <> + + ); }, // Use stable memo key instead of Map reference to prevent hook dependency issues From d5cc99d40908287700435ae54d450f35c0147ed6 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Wed, 10 Dec 2025 07:42:41 -0800 Subject: [PATCH 06/39] Adapt to load only used tags --- app/_layout.tsx | 9 ++--- hooks/useTagStore.ts | 72 ++++++++++++++++++++++++++++++--- views/new/AssetListItem.tsx | 33 +++++++++++---- views/new/NextGenAssetsView.tsx | 11 ++++- 4 files changed, 105 insertions(+), 20 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index fba5c00f7..e6c5bde2f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -33,7 +33,6 @@ import { toNavTheme } from '@/utils/styleUtils'; import { DarkTheme, DefaultTheme } from '@react-navigation/native'; import { StatusBar } from 'expo-status-bar'; -import { tagService } from '@/database_services/tagService'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import { KeyboardProvider } from 'react-native-keyboard-controller'; import { @@ -78,10 +77,10 @@ export default function RootLayout() { }); useEffect(() => { - async function init() { - await tagService.preloadTagsIntoCache(); - } - void init(); + // async function init() { + // await tagService.preloadTagsIntoCache(); + // } + // void init(); if (Platform.OS === 'web') return; console.log('[_layout] Setting up deep link handler'); diff --git a/hooks/useTagStore.ts b/hooks/useTagStore.ts index 3edf94c1c..0eb6393d2 100644 --- a/hooks/useTagStore.ts +++ b/hooks/useTagStore.ts @@ -1,24 +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'; -interface Tag { - id: string; - key: string; - value?: string; -} +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/views/new/AssetListItem.tsx b/views/new/AssetListItem.tsx index fdde6ff6f..5f4f337e1 100644 --- a/views/new/AssetListItem.tsx +++ b/views/new/AssetListItem.tsx @@ -40,6 +40,7 @@ type AssetQuestLink = Asset & { }; export interface AssetListItemProps { asset: AssetQuestLink; + isPublished: boolean; questId: string; onUpdate?: () => void; attachmentState?: AttachmentRecord; @@ -50,6 +51,7 @@ export const AssetListItem: React.FC = ({ asset, questId, isCurrentlyPlaying = false, + isPublished, onUpdate, attachmentState }) => { @@ -60,9 +62,20 @@ export const AssetListItem: React.FC = ({ // Check if asset is downloaded const isDownloaded = useItemDownloadStatus(asset, currentUser?.id); - const getManyTags = useTagStore((s) => s.getManyTags); - const tags = getManyTags(asset.tag_ids || []); - console.log('[Fetched tags from store]:', tags); + 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( @@ -179,12 +192,16 @@ export const AssetListItem: React.FC = ({ - + {tags.length === 0 ? ( - - - - + !isPublished && ( + + + + + ) ) : ( { + ({ + item, + isPublished + }: { + item: AssetQuestLink & { source?: HybridDataSource }; + isPublished: boolean; + }) => { const isPlaying = audioContext.isPlaying && audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && @@ -340,6 +346,7 @@ export default function NextGenAssetsView() { questId={currentQuestId || ''} isCurrentlyPlaying={isPlaying} onUpdate={handleAssetUpdate} + isPublished={isPublished} /> ); @@ -1248,7 +1255,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} From 48505d53b16fa0b06629d84d0fbc49c7f38bc78b Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Thu, 11 Dec 2025 16:04:53 -0800 Subject: [PATCH 07/39] Labelling assets --- components/AddVerseLabelButton.tsx | 33 + components/VerseSeparator.tsx | 66 ++ hooks/useAppNavigation.ts | 18 +- package-lock.json | 39 +- package.json | 1 + store/localStore.ts | 1 + views/AppView.tsx | 3 + views/new/BibleAssetListItem.tsx | 253 +++++ views/new/BibleAssetsView.tsx | 1531 ++++++++++++++++++++++++++++ 9 files changed, 1936 insertions(+), 9 deletions(-) create mode 100644 components/AddVerseLabelButton.tsx create mode 100644 components/VerseSeparator.tsx create mode 100644 views/new/BibleAssetListItem.tsx create mode 100644 views/new/BibleAssetsView.tsx diff --git a/components/AddVerseLabelButton.tsx b/components/AddVerseLabelButton.tsx new file mode 100644 index 000000000..6e07505bc --- /dev/null +++ b/components/AddVerseLabelButton.tsx @@ -0,0 +1,33 @@ +import { BookmarkIcon } from 'lucide-react-native'; +import React from 'react'; +import { View } from 'react-native'; +import { Button } from './ui/button'; +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 ( + + + + ); +} diff --git a/components/VerseSeparator.tsx b/components/VerseSeparator.tsx new file mode 100644 index 000000000..5f2709d54 --- /dev/null +++ b/components/VerseSeparator.tsx @@ -0,0 +1,66 @@ +import { CircleDashedIcon } from 'lucide-react-native'; +import React from 'react'; +import { View } from 'react-native'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +interface VerseSeparatorProps { + from?: number; + to?: number; + label: string; + className?: string; +} + +export function VerseSeparator({ + from, + to, + label, + className = '' +}: 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 - ghost pill with dashed icon + return ( + + + + + {getText()} + + + + ); + } + + // Has numbers - pill style + return ( + + + + {getText()} + + + + ); +} diff --git a/hooks/useAppNavigation.ts b/hooks/useAppNavigation.ts index d6b980c79..18882da82 100644 --- a/hooks/useAppNavigation.ts +++ b/hooks/useAppNavigation.ts @@ -169,6 +169,12 @@ export function useAppNavigation() { questData?: Record; projectData?: Record; }) => { + const assetView = + questData.projectData?.template === 'bible' ? 'bible-assets' : 'assets'; + console.log('============================================='); + console.log('assetView', assetView); + console.log('============================================='); + // Track recently visited addRecentQuest({ id: questData.id, @@ -180,7 +186,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 +197,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, @@ -323,13 +329,17 @@ export function useAppNavigation() { template: state.projectTemplate }) }); + console.log('============================================='); + console.log('state.questName', 'Going to AssetsView', state.questName); + console.log('============================================='); crumbs.push({ label: state.questName, onPress: () => 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/package-lock.json b/package-lock.json index f487e25a4..d85a78bc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "langquest", - "version": "2.0.5", + "version": "2.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "langquest", - "version": "2.0.5", + "version": "2.0.6", "hasInstallScript": true, "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", @@ -95,6 +95,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", @@ -21493,6 +21494,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", @@ -21538,9 +21552,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", @@ -21590,6 +21604,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 c5eb2a177..7c8f83bb2 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,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/store/localStore.ts b/store/localStore.ts index 324f989fe..61fe92c1e 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' diff --git a/views/AppView.tsx b/views/AppView.tsx index fae03eb51..d4d9cac06 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') ); @@ -210,6 +211,8 @@ function AppViewContent() { return ; case 'assets': return ; + case 'bible-assets': + return ; case 'asset-detail': return ; case 'profile': diff --git a/views/new/BibleAssetListItem.tsx b/views/new/BibleAssetListItem.tsx new file mode 100644 index 000000000..8fa1b0e2e --- /dev/null +++ b/views/new/BibleAssetListItem.tsx @@ -0,0 +1,253 @@ +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 { + EyeOffIcon, + HardDriveIcon, + PauseIcon, + Plus, + TagIcon +} from 'lucide-react-native'; +import React from 'react'; +import { Pressable, View } from 'react-native'; +import { TagModal } from './recording/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; + attachmentState?: AttachmentRecord; + isCurrentlyPlaying?: boolean; + dragHandle?: React.ReactNode; +} + +export const BibleAssetListItem: React.FC = ({ + asset, + questId, + isCurrentlyPlaying = false, + isPublished, + onUpdate, + attachmentState: _attachmentState, + dragHandle +}) => { + const { goToAsset, currentProjectData, currentQuestData } = + useAppNavigation(); + const { currentUser } = useAuth(); + const { t } = useLocalization(); + // Check if asset is downloaded + const isDownloaded = useItemDownloadStatus(asset, currentUser?.id); + + 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 + 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 = () => { + 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 handleDownloadToggle = () => { + if (!currentUser?.id) return; + + // Toggle download status + downloadAsset({ userId: currentUser.id, download: !isDownloaded }); + }; + + const tag = tags.length > 0 ? tags[0] : null; + + return ( + + + + + + + {(!allowEditing || invisible) && ( + + {invisible && ( + + )} + {!allowEditing && ( + + )} + + )} + {dragHandle} + + {asset.source === 'local' && } + + {asset.name || t('unnamedAsset')} + + + + + + {tags.length === 0 ? ( + !isPublished && ( + + + + + ) + ) : ( + + + + + {tag && `${tag.key}${tag.value && `: ${tag.value}`}`} + + + + )} + + + + + {SHOW_DEV_ELEMENTS && ( + + {`ID: ${asset.id.substring(0, 8)}...`} + + )} + + + {/* + + + */} + + + setIsTagModalVisible(false)} + onAssignTags={handleAssignTags} + /> + + ); +}; diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx new file mode 100644 index 000000000..610df5fcd --- /dev/null +++ b/views/new/BibleAssetsView.tsx @@ -0,0 +1,1531 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +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 { 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 { + CheckCheck, + CloudUpload, + FlagIcon, + GripVerticalIcon, + InfoIcon, + LockIcon, + MicIcon, + PauseIcon, + PencilIcon, + PlayIcon, + RefreshCwIcon, + SearchIcon, + SettingsIcon, + UserPlusIcon +} from 'lucide-react-native'; +import React from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import Animated, { + cancelAnimation, + Easing, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import type { HybridDataSource } from './useHybridData'; +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 { VerseSeparator } from '@/components/VerseSeparator'; +import { BIBLE_BOOKS } from '@/constants/bibleStructure'; +import { AppConfig } from '@/db/supabase/AppConfig'; +import { useAssetsByQuest } 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 { eq } from 'drizzle-orm'; +import Sortable from 'react-native-sortables'; +import { BibleAssetListItem } from './BibleAssetListItem'; +import RecordingViewSimplified from './recording/components/RecordingViewSimplified'; + +type Asset = typeof asset.$inferSelect; + +interface AssetMetadata { + verses?: { + 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 + +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(); + 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 [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 [verseLabelLists, setVerseLabelLists] = React.useState( + () => { + if (!currentQuestData || !currentBookId) return []; + const chapterNum = (currentQuestData as { chapterNumber?: number }) + ?.chapterNumber; + if (typeof chapterNum !== 'number') return []; + const book = BIBLE_BOOKS.find((b) => b.id === currentBookId); + const verseCount = book?.verses[chapterNum - 1] ?? 0; + return Array.from({ length: verseCount }).fill(false); + } + ); + + 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]); + + // 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); + + 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); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isOnline, + isFetching, + refetch + } = useAssetsByQuest( + currentQuestId || '', + debouncedSearchQuery, + showInvisibleContent + ); + + // 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]); + + const listItems = React.useMemo((): ListItem[] => {}, [assets]); + + 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]); + + const renderItem = React.useCallback( + ({ + item, + isPublished + }: { + item: AssetQuestLink & { source?: HybridDataSource }; + isPublished: boolean; + }) => { + const isPlaying = + audioContext.isPlaying && + audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && + currentlyPlayingAssetId === item.id; + + const dragHandle = !isPublished ? ( + + + + ) : null; + + return ( + <> + + + + ); + }, + // Use stable memo key instead of Map reference to prevent hook dependency issues + // Always has exactly 2 dependencies (string, string) - never changes size + [ + currentQuestId, + safeAttachmentStates, + audioContext.isPlaying, + audioContext.currentAudioId, + currentlyPlayingAssetId, + handleAssetUpdate + ] + ); + + const _onEndReached = React.useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + 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 []; + } + }, + [] + ); + + // Track currently playing asset based on audio position + React.useEffect(() => { + if ( + !audioContext.isPlaying || + audioContext.currentAudioId !== PLAY_ALL_AUDIO_ID + ) { + setCurrentlyPlayingAssetId(null); + return; + } + + // Calculate which asset is playing based on cumulative position + const checkCurrentAsset = () => { + const uris = uriOrderRef.current; + const durations = segmentDurationsRef.current; + + if (uris.length === 0) return; + + const position = audioContext.position; // Position in milliseconds + + // 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) { + console.log( + `⏸️ No duration available yet (position: ${position}ms, duration: ${duration}ms)` + ); + 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) { + if (assetId !== currentlyPlayingAssetId) { + console.log( + `🎵 [Fallback] Highlighting asset ${assetId.slice(0, 8)} (segment ${uriIndex + 1}/${uris.length}, ${Math.round(positionPercent * 100)}%)` + ); + setCurrentlyPlayingAssetId(assetId); + } + } else { + console.warn(`⚠️ No asset ID found for URI at index ${uriIndex}`); + } + } + return; + } + + // 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 + // Use <= for the last segment to catch it even if position is slightly off + 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) { + if (assetId !== currentlyPlayingAssetId) { + console.log( + `🎵 Highlighting asset ${assetId.slice(0, 8)} (segment ${i + 1}/${uris.length}, position: ${Math.round(position)}ms in range [${Math.round(segmentStart)}-${Math.round(cumulativeDuration)}]ms)` + ); + setCurrentlyPlayingAssetId(assetId); + } + } else { + console.warn(`⚠️ No asset ID found for URI at index ${i}`); + } + } + break; + } + } + }; + + // Check immediately and then periodically while playing + checkCurrentAsset(); + const interval = setInterval(checkCurrentAsset, 200); // Check every 200ms + return () => clearInterval(interval); + }, [ + audioContext.isPlaying, + audioContext.currentAudioId, + audioContext.position, + audioContext.duration, + currentlyPlayingAssetId + ]); + + // 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 = []; + } 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 = []; + + for (const asset of assets) { + const uris = await getAssetAudioUris(asset.id); + if (uris.length > 0) { + assetOrderRef.current.push(asset.id); + for (const uri of uris) { + allUris.push(uri); + uriOrderRef.current.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; + } + + console.log( + `▶️ Playing ${allUris.length} audio segments from ${assets.length} assets` + ); + + // Set the first asset as currently playing + // Note: Duration preloading is handled by AudioContext.playSoundSequence + if (assetOrderRef.current.length > 0) { + setCurrentlyPlayingAssetId(assetOrderRef.current[0] || null); + } + + 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 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); + } + }; + + if (!currentQuestId) { + return ( + + {t('noQuestSelected')} + + ); + } + + // Recording mode UI + if (showRecording) { + // Pass existing assets as initial data for instant rendering + return ( + { + setShowRecording(false); + // Refetch to show newly recorded assets + void refetch(); + }} + initialAssets={assets} + /> + ); + } + + // 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 || ''; + + interface SortableGridDragEndParams { + key: string; + fromIndex: number; + toIndex: number; + indexToKey: string[]; + keyToIndex: Record; + data: AssetQuestLink[]; + } + + function handleSorting(params: SortableGridDragEndParams) { + console.log('🔄 Sorting complete!'); + console.log('🔄 Dragged item key:', params.key); + console.log('🔄 From index:', params.fromIndex); + console.log('🔄 To index:', params.toIndex); + console.log('🔄 New order (keys):', params.indexToKey); + console.log('🔄 Key to index map:', params.keyToIndex); + // console.log('🔄 Reordered data:', params.data); + + // Example: Get the new order of asset IDs + const newAssetOrder = params.indexToKey; + console.log('🔄 New asset ID order:', newAssetOrder); + + // You can now save this order to the database or state + } + + return ( + + + + + {t('assets')} + {` -- REMOVE -- `} + + + {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 && ( + + + + {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 })} + rowGap={10} + scrollableRef={scrollableRef} // required for auto scroll + overDrag="vertical" + // onDragEnd={(params) => handleSorting(params)} + customHandle + // autoScrollActivationOffset={75} + // autoScrollSpeed={1} + // autoScrollEnabled={true} + /> + + // item.id} + // extraData={currentlyPlayingAssetId} + // renderItem={({ item }) => renderItem({ item, isPublished })} + // onEndReached={onEndReached} + // onEndReachedThreshold={0.5} + // estimatedItemSize={120} + // recycleItems + // contentContainerStyle={{ + // gap: 8, + // paddingBottom: !isPublished ? 100 : 24 + // }} + // maintainVisibleContentPosition + // ListFooterComponent={() => ( + // + // {isFetchingNextPage && ( + // + // + // + // )} + // {blockedCount > 0 && ( + // + // + // + // {blockedCount}{' '} + // {blockedCount === 1 ? 'blocked item' : 'blocked items'} + // + // + // )} + // + // )} + // ListEmptyComponent={() => ( + // + // + // + // {isPublished ? t('noAssetsFound') : t('nothingHereYet')} + // + // {!isPublished && ( + // + // )} + // + // + // )} + // /> + )} + + {/* Sticky Record Button Footer - only show for authenticated users */} + {!isPublished && currentUser && ( + + + + )} + + + + + {/* For anonymous users, only show info button */} + {currentUser ? ( + <> + {allowSettings && isOwner ? ( + setShowSettingsModal(true)} + /> + ) : !hasReported ? ( + setShowReportModal(true)} + /> + ) : null} + + ) : null} + {/* 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(); + } + }} + /> + + + + + + {allowSettings && isOwner && ( + setShowSettingsModal(false)} + questId={currentQuestId} + projectId={currentProjectId || ''} + /> + )} + {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 */} + { + if (!open && !isOffloading) { + setShowOffloadDrawer(false); + verificationState.cancel(); + } + }} + onContinue={handleOffloadConfirm} + verificationState={verificationState} + isOffloading={isOffloading} + /> + + {/* Private Access Gate Modal for Membership Requests */} + {isPrivateProject && ( + setShowPrivateAccessModal(false)} + /> + )} + + ); +} From e6f6504372d383adc4bd900f76f6881867f3ffb4 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Mon, 15 Dec 2025 16:27:57 -0800 Subject: [PATCH 08/39] Add new fields and functionalities related to labeling assets --- components/AddVerseLabelButton.tsx | 26 +- components/VerseRangeSelector.tsx | 207 +++++++++ components/VerseSeparator.tsx | 37 +- database_services/assetService.ts | 114 ++++- db/drizzleSchemaColumns.ts | 1 + hooks/db/useAssets.ts | 4 + ...0251214120000_add_asset_metadata_field.sql | 11 + views/new/BibleAssetListItem.tsx | 39 +- views/new/BibleAssetsView.tsx | 428 +++++++++++++++--- 9 files changed, 763 insertions(+), 104 deletions(-) create mode 100644 components/VerseRangeSelector.tsx create mode 100644 supabase/migrations/20251214120000_add_asset_metadata_field.sql diff --git a/components/AddVerseLabelButton.tsx b/components/AddVerseLabelButton.tsx index 6e07505bc..d9c5e466a 100644 --- a/components/AddVerseLabelButton.tsx +++ b/components/AddVerseLabelButton.tsx @@ -1,7 +1,6 @@ -import { BookmarkIcon } from 'lucide-react-native'; +import { PlusCircleIcon } from 'lucide-react-native'; import React from 'react'; -import { View } from 'react-native'; -import { Button } from './ui/button'; +import { Pressable, View } from 'react-native'; import { Icon } from './ui/icon'; import { Text } from './ui/text'; @@ -17,17 +16,22 @@ export function AddVerseLabelButton({ className = '' }: AddVerseLabelButtonProps) { return ( - - + + + Add verse + + + {/* */} ); } diff --git a/components/VerseRangeSelector.tsx b/components/VerseRangeSelector.tsx new file mode 100644 index 000000000..2bae63f1b --- /dev/null +++ b/components/VerseRangeSelector.tsx @@ -0,0 +1,207 @@ +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 { + 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; +} + +export function VerseRangeSelector({ + from, + to, + selectedFrom: initialFrom, + selectedTo: initialTo, + onApply, + onCancel, + className = '', + ScrollViewComponent = ScrollView +}: VerseRangeSelectorProps) { + const [selectedFrom, setSelectedFrom] = React.useState( + initialFrom + ); + const [selectedTo, setSelectedTo] = React.useState( + initialTo + ); + + // Generate array of numbers from `from` to `to` + const allNumbers = React.useMemo(() => { + const numbers: number[] = []; + for (let i = from; i <= to; i++) { + numbers.push(i); + } + return numbers; + }, [from, to]); + + // Check if a number is selectable based on current selection state + const isNumberSelectable = React.useCallback( + (num: number) => { + // If nothing selected, all numbers are selectable + if (selectedFrom === undefined) { + return true; + } + // If only "from" selected, only numbers >= selectedFrom are selectable + if (selectedTo === undefined) { + return num >= selectedFrom; + } + // If both selected, nothing is selectable (user must clear first) + return false; + }, + [selectedFrom, selectedTo] + ); + + const handleNumberPress = (num: number) => { + if (!isNumberSelectable(num)) return; + + if (selectedFrom === undefined) { + // First selection - set "from" + setSelectedFrom(num); + } else if (selectedTo === undefined) { + // Second selection - set "to" + setSelectedTo(num); + } + }; + + 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 index 5f2709d54..49fc7d471 100644 --- a/components/VerseSeparator.tsx +++ b/components/VerseSeparator.tsx @@ -1,4 +1,4 @@ -import { CircleDashedIcon } from 'lucide-react-native'; +import { AlertCircleIcon } from 'lucide-react-native'; import React from 'react'; import { View } from 'react-native'; import { Icon } from './ui/icon'; @@ -9,13 +9,17 @@ interface VerseSeparatorProps { to?: number; label: string; className?: string; + editable?: boolean; + dragHandle?: React.ReactNode; } export function VerseSeparator({ from, to, label, - className = '' + className = '', + editable = false, + dragHandle = null }: VerseSeparatorProps) { const hasNumbers = from !== undefined || to !== undefined; @@ -36,29 +40,30 @@ export function VerseSeparator({ }; if (!hasNumbers) { - // No assigned - ghost pill with dashed icon + // No assigned - warning style with amber/orange tones return ( - - - - - {getText()} + + + + + + {getText()} + - + ); } // Has numbers - pill style return ( - + - - {getText()} + + {dragHandle} + + {getText()} + diff --git a/database_services/assetService.ts b/database_services/assetService.ts index 70dd2930d..a7a845cac 100644 --- a/database_services/assetService.ts +++ b/database_services/assetService.ts @@ -4,7 +4,17 @@ import { system } from '@/db/powersync/system'; import { resolveTable } from '@/utils/dbUtils'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; + +/** + * Asset metadata structure for verse ranges + */ +export interface AssetMetadata { + verse?: { + from: number; + to: number; + }; +} /** * Update an asset's name - ONLY for local-only assets @@ -132,3 +142,105 @@ export async function updateAssetContentText( throw error; } } + +/** + * Update asset metadata (verse range) - ONLY for local-only assets + * @param assetId - The ID of the asset to update + * @param metadata - The metadata object to store (will be JSON stringified) + * @throws Error if asset is synced (immutable) + */ +export async function updateAssetMetadata( + assetId: string, + metadata: AssetMetadata | null +): Promise { + 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; + } +} + +/** + * Batch update asset metadata for multiple assets + * @param updates - Array of { assetId, metadata } objects + */ +export async function batchUpdateAssetMetadata( + updates: { assetId: string; metadata: AssetMetadata | null }[] +): 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 { assetId, metadata } of localUpdates) { + const metadataStr = metadata ? JSON.stringify(metadata) : null; + console.log(metadataStr); + await system.db + .update(assetLocalTable) + .set({ metadata: metadataStr }) + .where(eq(assetLocalTable.id, assetId)); + } + + console.log(`✅ Updated metadata for ${localUpdates.length} assets`); + } catch (error) { + console.error('Failed to batch update asset metadata:', error); + throw error; + } +} diff --git a/db/drizzleSchemaColumns.ts b/db/drizzleSchemaColumns.ts index 0ab5ac282..9cf61bcd2 100644 --- a/db/drizzleSchemaColumns.ts +++ b/db/drizzleSchemaColumns.ts @@ -352,6 +352,7 @@ export function createAssetTable< source_asset_id: text().references((): AnySQLiteColumn => table.id), 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 cfe253a2e..cb6704c80 100644 --- a/hooks/db/useAssets.ts +++ b/hooks/db/useAssets.ts @@ -1108,6 +1108,10 @@ export function useAssetsByQuest( 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:', 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/new/BibleAssetListItem.tsx b/views/new/BibleAssetListItem.tsx index 8fa1b0e2e..edbca5d77 100644 --- a/views/new/BibleAssetListItem.tsx +++ b/views/new/BibleAssetListItem.tsx @@ -164,43 +164,54 @@ export const BibleAssetListItem: React.FC = ({ return ( - + - - + + {(!allowEditing || invisible) && ( - + {invisible && ( )} {!allowEditing && ( )} )} {dragHandle} - - {asset.source === 'local' && } - + + {asset.source === 'local' && ( + + )} + {asset.name || t('unnamedAsset')} - + {tags.length === 0 ? ( !isPublished && ( - + @@ -209,10 +220,10 @@ export const BibleAssetListItem: React.FC = ({ - - + + {tag && `${tag.key}${tag.value && `: ${tag.value}`}`} @@ -224,7 +235,7 @@ export const BibleAssetListItem: React.FC = ({ isFlaggedForDownload={isDownloaded} isLoading={isDownloading} onPress={handleDownloadToggle} - size={20} + size={16} /> {SHOW_DEV_ELEMENTS && ( diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 610df5fcd..c053ff224 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -56,17 +56,25 @@ import Animated, { withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import type { HybridDataSource } from './useHybridData'; import { useHybridData } from './useHybridData'; +import { AddVerseLabelButton } from '@/components/AddVerseLabelButton'; 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, + DrawerHeader, + DrawerTitle +} from '@/components/ui/drawer'; +import { VerseRangeSelector } from '@/components/VerseRangeSelector'; import { VerseSeparator } from '@/components/VerseSeparator'; import { BIBLE_BOOKS } from '@/constants/bibleStructure'; +import { batchUpdateAssetMetadata } from '@/database_services/assetService'; import { AppConfig } from '@/db/supabase/AppConfig'; import { useAssetsByQuest } from '@/hooks/db/useAssets'; import { useBlockedAssetsCount } from '@/hooks/useBlockedCount'; @@ -80,6 +88,7 @@ import { getThemeColor } from '@/utils/styleUtils'; import { toCompilableQuery } from '@powersync/drizzle-driver'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { eq } from 'drizzle-orm'; +import { ScrollView as GHScrollView } from 'react-native-gesture-handler'; import Sortable from 'react-native-sortables'; import { BibleAssetListItem } from './BibleAssetListItem'; import RecordingViewSimplified from './recording/components/RecordingViewSimplified'; @@ -87,7 +96,7 @@ import RecordingViewSimplified from './recording/components/RecordingViewSimplif type Asset = typeof asset.$inferSelect; interface AssetMetadata { - verses?: { + verse?: { from: number; to: number; }; @@ -101,6 +110,20 @@ type AssetQuestLink = Asset & { }; // 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; export default function BibleAssetsView() { const { @@ -124,6 +147,27 @@ export default function BibleAssetsView() { const [showSettingsModal, setShowSettingsModal] = React.useState(false); const [showReportModal, setShowReportModal] = React.useState(false); const [showOffloadDrawer, setShowOffloadDrawer] = React.useState(false); + const [verseSelectorState, setVerseSelectorState] = React.useState<{ + isOpen: boolean; + key: string | null; + from?: number; + to?: number; + }>({ isOpen: false, key: null }); + + // Manual verse separators created by the user + const [manualSeparators, setManualSeparators] = React.useState< + { from: number; to: number; key: string }[] + >([]); + + // Function to add a new verse separator + const addVerseSeparator = React.useCallback((from: number, to: number) => { + const newSeparator = { + from, + to, + key: `manual-sep-${from}-${to}-${Date.now()}` + }; + setManualSeparators((prev) => [...prev, newSeparator]); + }, []); const [showPrivateAccessModal, setShowPrivateAccessModal] = React.useState(false); const [isOffloading, setIsOffloading] = React.useState(false); @@ -136,18 +180,17 @@ export default function BibleAssetsView() { 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 [verseLabelLists, setVerseLabelLists] = React.useState( - () => { - if (!currentQuestData || !currentBookId) return []; - const chapterNum = (currentQuestData as { chapterNumber?: number }) - ?.chapterNumber; - if (typeof chapterNum !== 'number') return []; - const book = BIBLE_BOOKS.find((b) => b.id === currentBookId); - const verseCount = book?.verses[chapterNum - 1] ?? 0; - return Array.from({ length: verseCount }).fill(false); - } - ); + const fixedItemsIndexesRef = React.useRef([0]); + + // Get verse count for current chapter + const verseCount = React.useMemo(() => { + if (!currentQuestData || !currentBookId) return 0; + const chapterNum = (currentQuestData as { chapterNumber?: number }) + ?.chapterNumber; + if (typeof chapterNum !== 'number') return 0; + const book = BIBLE_BOOKS.find((b) => b.id === currentBookId); + return book?.verses[chapterNum - 1] ?? 0; + }, [currentQuestData, currentBookId]); const scrollableRef = useAnimatedRef(); @@ -308,7 +351,162 @@ export default function BibleAssetsView() { return Array.from(assetMap.values()); }, [data.pages]); - const listItems = React.useMemo((): ListItem[] => {}, [assets]); + const listItems = React.useMemo((): ListItem[] => { + // Separate assets with and without metadata + const assetsWithMeta = assets.filter( + (a) => a.metadata?.verse?.from != null + ); + const assetsWithoutMeta = assets.filter( + (a) => a.metadata?.verse?.from == null + ); + + // Sort assets with metadata by verse.from + assetsWithMeta.sort((a, b) => { + const aFrom = a.metadata?.verse?.from ?? 0; + const bFrom = b.metadata?.verse?.from ?? 0; + return aFrom - bFrom; + }); + + // First build the list with auto separators + assets + const result: ListItem[] = []; + let currentFrom: number | undefined; + let currentTo: number | undefined; + + // Process assets with metadata + for (const asset of assetsWithMeta) { + const from = asset.metadata?.verse?.from; + const to = asset.metadata?.verse?.to; + + // If different from current group, add separator + 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 + }); + } + + // Prepare unassigned block (append later) + 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 manual separators in-order before the unassigned block + const sortedManualSeps = [...manualSeparators].sort( + (a, b) => a.from - b.from + ); + + for (const sep of sortedManualSeps) { + const sepItem: ListItemSeparator = { + type: 'separator', + from: sep.from, + to: sep.to, + key: sep.key + }; + + // Find the first separator with 'from' greater than this sep.from + 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); + } + + // Final assembly: result (with manual seps inserted) + unassigned block + const combined: ListItem[] = [...result, ...unassignedBlock]; + + // Deduplicate separators with the same range to avoid duplicates after drag/drop + const seenSeparators = new Set(); + const deduped: ListItem[] = []; + for (const item of combined) { + if (item.type === 'separator') { + const sepKey = `${item.from ?? 'none'}-${item.to ?? 'none'}`; + if (seenSeparators.has(sepKey)) { + continue; + } + seenSeparators.add(sepKey); + } + deduped.push(item); + } + + return deduped; + }, [assets, manualSeparators]); + + // 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") + 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)); + + console.log( + `computeAllowedRange for ${separatorKey}: prevTo=${prevTo}, currentFrom=${currentFrom} => from=${finalFrom}, to=${finalTo}` + ); + + return { from: finalFrom, to: finalTo }; + }, + [listItems, verseCount] + ); const assetIds = React.useMemo(() => { return assets.map((asset) => asset.id).filter((id): id is string => !!id); @@ -352,18 +550,22 @@ export default function BibleAssetsView() { const renderItem = React.useCallback( ({ item, - isPublished + isPublished, + index }: { - item: AssetQuestLink & { source?: HybridDataSource }; + item: ListItem; isPublished: boolean; + index: number; }) => { - const isPlaying = - audioContext.isPlaying && - audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && - currentlyPlayingAssetId === item.id; - + // Handle separator items const dragHandle = !isPublished ? ( - + ) : null; + if (item.type === 'separator') { + return ( + + { + // Calculate the allowed range based on neighboring separators + const allowedRange = computeAllowedRange(item.key); + setVerseSelectorState({ + isOpen: true, + key: item.key, + from: allowedRange.from, + to: allowedRange.to + }); + }} + /> + + + ); + } + + // Handle asset items + const asset = item.content; + const isPlaying = + audioContext.isPlaying && + audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && + currentlyPlayingAssetId === asset.id; + return ( - <> - - - + ); }, - // Use stable memo key instead of Map reference to prevent hook dependency issues - // Always has exactly 2 dependencies (string, string) - never changes size [ currentQuestId, safeAttachmentStates, audioContext.isPlaying, audioContext.currentAudioId, currentlyPlayingAssetId, - handleAssetUpdate + handleAssetUpdate, + computeAllowedRange ] ); @@ -1077,29 +1308,65 @@ export default function BibleAssetsView() { // Note: queriedProjectData doesn't include name, so we only use currentProjectData const projectName = currentProjectData?.name || ''; - interface SortableGridDragEndParams { - key: string; - fromIndex: number; - toIndex: number; + async function _handleSorting(params: { indexToKey: string[]; - keyToIndex: Record; - data: AssetQuestLink[]; - } - - function handleSorting(params: SortableGridDragEndParams) { + data: ListItem[]; + }) { console.log('🔄 Sorting complete!'); - console.log('🔄 Dragged item key:', params.key); - console.log('🔄 From index:', params.fromIndex); - console.log('🔄 To index:', params.toIndex); console.log('🔄 New order (keys):', params.indexToKey); - console.log('🔄 Key to index map:', params.keyToIndex); - // console.log('🔄 Reordered data:', params.data); - // Example: Get the new order of asset IDs - const newAssetOrder = params.indexToKey; - console.log('🔄 New asset ID order:', newAssetOrder); + // 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 + // based on the preceding separator + let currentSeparator: ListItemSeparator | null = null; + const updates: { assetId: string; metadata: AssetMetadata | null }[] = []; + + for (const key of params.indexToKey) { + const item = keyToItem.get(key); + if (!item) continue; + + if (item.type === 'separator') { + currentSeparator = item; + } else if (item.type === 'asset') { + // 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 has changed + const currentMetadata = item.content.metadata; + const hasChanged = + JSON.stringify(newMetadata) !== JSON.stringify(currentMetadata); + + if (hasChanged) { + updates.push({ + assetId: item.content.id, + metadata: newMetadata + }); + } + } + } - // You can now save this order to the database or state + // Batch update all changed assets + if (updates.length > 0) { + try { + await batchUpdateAssetMetadata(updates); + console.log(`✅ Updated ${updates.length} asset(s) metadata`); + + // 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 asset metadata:', err); + } + } } return ( @@ -1107,8 +1374,8 @@ export default function BibleAssetsView() { + {/* Title */} {t('assets')} - {` -- REMOVE -- `} + {!isPublished && ( + + )} + {selectedFrom !== undefined ? ( + // Show Apply when verse is selected + + ) : ( + // Show Remove when no selection but assets have labels + onRemove && + hasSelectedAssetsWithLabels && ( + + ) + )} + + + ); +} diff --git a/components/VerseRangeSelector.tsx b/components/VerseRangeSelector.tsx index 90ff3be47..cbb6a6922 100644 --- a/components/VerseRangeSelector.tsx +++ b/components/VerseRangeSelector.tsx @@ -57,15 +57,17 @@ export function VerseRangeSelector({ }, [availableVerses, from, to]); // Calculate max "to" value when "from" is selected - const maxTo = React.useMemo(() => { + const maxTo: number = React.useMemo(() => { if (selectedFrom === undefined) { - return allNumbers.length > 0 ? allNumbers[allNumbers.length - 1] : (to || 1); + 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); + 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 @@ -75,7 +77,7 @@ export function VerseRangeSelector({ if (!allNumbers.includes(num)) { return false; } - + // If nothing selected, all available numbers are selectable if (selectedFrom === undefined) { return true; diff --git a/components/VerseSeparator.tsx b/components/VerseSeparator.tsx index 73870c989..cda10a951 100644 --- a/components/VerseSeparator.tsx +++ b/components/VerseSeparator.tsx @@ -1,6 +1,6 @@ import { AlertCircleIcon, MoveVerticalIcon } from 'lucide-react-native'; import React from 'react'; -import { View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Icon } from './ui/icon'; import { Text } from './ui/text'; @@ -10,6 +10,8 @@ interface VerseSeparatorProps { label: string; className?: string; editable?: boolean; + largeText?: boolean; + onPress?: () => void; dragHandleComponent?: React.ComponentType<{ mode?: 'fixed-order' | 'draggable'; children?: React.ReactNode; @@ -25,6 +27,8 @@ export function VerseSeparator({ label, className = '', editable = false, + largeText = false, + onPress, dragHandleComponent: DragHandleComponent, dragHandleProps }: VerseSeparatorProps) { @@ -33,17 +37,17 @@ export function VerseSeparator({ const getText = () => { // No numbers provided if (!hasNumbers) { - return `No ${label} assigned`; + 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}`; + return `${label}:${value}`; } // Range of numbers - return `${label} ${from}-${to}`; + return `${label}:${from}-${to}`; }; if (!hasNumbers) { @@ -53,7 +57,12 @@ export function VerseSeparator({ - + {getText()} @@ -63,21 +72,39 @@ export function VerseSeparator({ } // Has numbers - pill style + const pillContent = ( + + {DragHandleComponent && editable && ( + + + + )} + + {getText()} + + + ); + return ( - - {DragHandleComponent && editable && ( - - - - - - )} - - {getText()} - - + {DragHandleComponent && editable ? ( + + {onPress ? ( + {pillContent} + ) : ( + pillContent + )} + + ) : onPress && editable ? ( + {pillContent} + ) : ( + pillContent + )} ); diff --git a/constants/bibleStructure.ts b/constants/bibleStructure.ts index 886e74017..bb66462d8 100644 --- a/constants/bibleStructure.ts +++ b/constants/bibleStructure.ts @@ -4,6 +4,7 @@ import type { Quest, Segment, TemplatedProject } from './templates'; export interface BibleBook { id: string; name: string; + shortName: string; chapters: number; verses: number[]; // verses per chapter } @@ -19,6 +20,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'gen', name: 'Genesis', + shortName: 'Gen', chapters: 50, verses: [ 31, 25, 24, 26, 32, 22, 24, 22, 29, 32, 32, 20, 18, 24, 21, 16, 27, 33, @@ -29,6 +31,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'exo', name: 'Exodus', + shortName: 'Exod', chapters: 40, verses: [ 22, 25, 22, 31, 23, 30, 25, 32, 35, 29, 10, 51, 22, 31, 27, 36, 16, 27, @@ -39,6 +42,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'lev', name: 'Leviticus', + shortName: 'Lev', chapters: 27, verses: [ 17, 16, 17, 35, 19, 30, 38, 36, 24, 20, 47, 8, 59, 57, 33, 34, 16, 30, 37, @@ -48,6 +52,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'num', name: 'Numbers', + shortName: 'Num', chapters: 36, verses: [ 54, 34, 51, 49, 31, 27, 89, 26, 23, 36, 35, 16, 33, 45, 41, 50, 13, 32, @@ -57,6 +62,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'deu', name: 'Deuteronomy', + shortName: 'Deut', chapters: 34, verses: [ 46, 37, 29, 49, 33, 25, 26, 20, 29, 22, 32, 32, 18, 29, 23, 22, 20, 22, @@ -66,6 +72,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'jos', name: 'Joshua', + shortName: 'Josh', chapters: 24, verses: [ 18, 24, 17, 24, 15, 27, 26, 35, 27, 43, 23, 24, 33, 15, 63, 10, 18, 28, @@ -75,16 +82,24 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'jdg', name: 'Judges', + shortName: 'Judg', chapters: 21, verses: [ 36, 23, 31, 24, 31, 40, 25, 35, 57, 18, 40, 15, 25, 20, 20, 31, 13, 31, 30, 48, 25 ] }, - { id: 'rut', name: 'Ruth', chapters: 4, verses: [22, 23, 18, 22] }, + { + id: 'rut', + name: 'Ruth', + shortName: 'Ruth', + chapters: 4, + verses: [22, 23, 18, 22] + }, { id: '1sa', name: '1 Samuel', + shortName: '1 Sam', chapters: 31, verses: [ 28, 36, 21, 22, 12, 21, 17, 22, 27, 27, 15, 25, 23, 52, 35, 23, 58, 30, @@ -94,6 +109,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: '2sa', name: '2 Samuel', + shortName: '2 Sam', chapters: 24, verses: [ 27, 32, 39, 12, 25, 23, 29, 18, 13, 19, 27, 31, 39, 33, 37, 23, 29, 33, @@ -103,6 +119,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: '1ki', name: '1 Kings', + shortName: '1 Kgs', chapters: 22, verses: [ 53, 46, 28, 34, 18, 38, 51, 66, 28, 29, 43, 33, 34, 31, 34, 34, 24, 46, @@ -112,6 +129,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: '2ki', name: '2 Kings', + shortName: '2 Kgs', chapters: 25, verses: [ 18, 25, 27, 44, 27, 33, 20, 29, 37, 36, 21, 21, 25, 29, 38, 20, 41, 37, @@ -121,6 +139,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: '1ch', name: '1 Chronicles', + shortName: '1 Chr', chapters: 29, verses: [ 54, 55, 24, 43, 26, 81, 40, 40, 44, 14, 47, 40, 14, 17, 29, 43, 27, 17, @@ -130,6 +149,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: '2ch', name: '2 Chronicles', + shortName: '2 Chr', chapters: 36, verses: [ 17, 18, 17, 22, 14, 42, 22, 18, 31, 19, 23, 16, 22, 15, 19, 14, 19, 34, @@ -139,24 +159,28 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'ezr', name: 'Ezra', + shortName: 'Ezra', chapters: 10, verses: [11, 70, 13, 24, 17, 22, 28, 36, 15, 44] }, { id: 'neh', name: 'Nehemiah', + shortName: 'Neh', chapters: 13, verses: [11, 20, 32, 23, 19, 19, 73, 18, 38, 39, 36, 47, 31] }, { id: 'est', name: 'Esther', + shortName: 'Esth', chapters: 10, verses: [22, 23, 15, 17, 14, 14, 10, 17, 32, 3] }, { id: 'job', name: 'Job', + shortName: 'Job', chapters: 42, verses: [ 22, 13, 26, 21, 27, 30, 21, 22, 35, 22, 20, 25, 28, 22, 35, 22, 16, 21, @@ -167,6 +191,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'psa', name: 'Psalms', + shortName: 'Ps', chapters: 150, verses: [ 6, 12, 8, 8, 12, 10, 17, 9, 20, 18, 7, 8, 6, 7, 5, 11, 15, 50, 14, 9, 13, @@ -182,6 +207,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'pro', name: 'Proverbs', + shortName: 'Prov', chapters: 31, verses: [ 33, 22, 35, 27, 23, 35, 27, 36, 18, 32, 31, 28, 25, 35, 33, 33, 28, 24, @@ -191,18 +217,21 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'ecc', name: 'Ecclesiastes', + shortName: 'Eccl', chapters: 12, verses: [18, 26, 22, 16, 20, 12, 29, 17, 18, 20, 10, 14] }, { id: 'sng', name: 'Song of Solomon', + shortName: 'Song', chapters: 8, verses: [17, 17, 11, 16, 16, 13, 13, 14] }, { id: 'isa', name: 'Isaiah', + shortName: 'Isa', chapters: 66, verses: [ 31, 22, 26, 6, 30, 13, 25, 22, 21, 34, 16, 6, 22, 32, 9, 14, 14, 7, 25, 6, @@ -214,6 +243,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'jer', name: 'Jeremiah', + shortName: 'Jer', chapters: 52, verses: [ 19, 37, 25, 31, 31, 30, 34, 22, 26, 25, 23, 17, 27, 22, 21, 21, 27, 23, @@ -224,12 +254,14 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'lam', name: 'Lamentations', + shortName: 'Lam', chapters: 5, verses: [22, 22, 66, 22, 22] }, { id: 'ezk', name: 'Ezekiel', + shortName: 'Ezek', chapters: 48, verses: [ 28, 10, 27, 17, 17, 14, 27, 18, 11, 22, 25, 28, 23, 23, 8, 63, 24, 32, 14, @@ -240,46 +272,94 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'dan', name: 'Daniel', + shortName: 'Dan', chapters: 12, verses: [21, 49, 30, 37, 31, 28, 28, 27, 27, 21, 45, 13] }, { id: 'hos', name: 'Hosea', + shortName: 'Hos', chapters: 14, verses: [11, 23, 5, 19, 15, 11, 16, 14, 17, 15, 12, 14, 16, 9] }, - { id: 'joe', name: 'Joel', chapters: 3, verses: [20, 32, 21] }, + { + id: 'joe', + name: 'Joel', + shortName: 'Joel', + chapters: 3, + verses: [20, 32, 21] + }, { id: 'amo', name: 'Amos', + shortName: 'Amos', chapters: 9, verses: [15, 16, 15, 13, 27, 14, 17, 14, 15] }, - { id: 'oba', name: 'Obadiah', chapters: 1, verses: [21] }, - { id: 'jon', name: 'Jonah', chapters: 4, verses: [17, 10, 10, 11] }, + { id: 'oba', name: 'Obadiah', shortName: 'Obad', chapters: 1, verses: [21] }, + { + id: 'jon', + name: 'Jonah', + shortName: 'Jonah', + chapters: 4, + verses: [17, 10, 10, 11] + }, { id: 'mic', name: 'Micah', + shortName: 'Mic', chapters: 7, verses: [16, 13, 12, 13, 15, 16, 20] }, - { id: 'nah', name: 'Nahum', chapters: 3, verses: [15, 13, 19] }, - { id: 'hab', name: 'Habakkuk', chapters: 3, verses: [17, 20, 19] }, - { id: 'zep', name: 'Zephaniah', chapters: 3, verses: [18, 15, 20] }, - { id: 'hag', name: 'Haggai', chapters: 2, verses: [15, 23] }, + { + id: 'nah', + name: 'Nahum', + shortName: 'Nah', + chapters: 3, + verses: [15, 13, 19] + }, + { + id: 'hab', + name: 'Habakkuk', + shortName: 'Hab', + chapters: 3, + verses: [17, 20, 19] + }, + { + id: 'zep', + name: 'Zephaniah', + shortName: 'Zeph', + chapters: 3, + verses: [18, 15, 20] + }, + { + id: 'hag', + name: 'Haggai', + shortName: 'Hag', + chapters: 2, + verses: [15, 23] + }, { id: 'zec', name: 'Zechariah', + shortName: 'Zech', chapters: 14, verses: [21, 13, 10, 14, 11, 15, 14, 23, 17, 12, 17, 14, 9, 21] }, - { id: 'mal', name: 'Malachi', chapters: 4, verses: [14, 17, 18, 6] }, + { + id: 'mal', + name: 'Malachi', + shortName: 'Mal', + chapters: 4, + verses: [14, 17, 18, 6] + }, // New Testament { id: 'mat', name: 'Matthew', + shortName: 'Matt', chapters: 28, verses: [ 25, 23, 17, 25, 48, 34, 29, 34, 38, 42, 30, 50, 58, 36, 39, 28, 27, 35, @@ -289,12 +369,14 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'mar', name: 'Mark', + shortName: 'Mark', chapters: 16, verses: [45, 28, 35, 41, 43, 56, 37, 38, 50, 52, 33, 44, 37, 72, 47, 20] }, { id: 'luk', name: 'Luke', + shortName: 'Luke', chapters: 24, verses: [ 80, 52, 38, 44, 39, 49, 50, 56, 62, 42, 54, 59, 35, 35, 32, 31, 37, 43, @@ -304,6 +386,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'jhn', name: 'John', + shortName: 'John', chapters: 21, verses: [ 51, 25, 36, 54, 47, 71, 53, 59, 41, 42, 57, 50, 38, 31, 27, 33, 26, 40, @@ -313,6 +396,7 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'act', name: 'Acts', + shortName: 'Acts', chapters: 28, verses: [ 26, 47, 26, 37, 42, 15, 60, 40, 43, 48, 30, 25, 52, 28, 41, 40, 34, 28, @@ -322,67 +406,130 @@ export const BIBLE_BOOKS: BibleBook[] = [ { id: 'rom', name: 'Romans', + shortName: 'Rom', chapters: 16, verses: [32, 29, 31, 25, 21, 23, 25, 39, 33, 21, 36, 21, 14, 23, 33, 27] }, { id: '1co', name: '1 Corinthians', + shortName: '1 Cor', chapters: 16, verses: [31, 16, 23, 21, 13, 20, 40, 13, 27, 33, 34, 31, 13, 40, 58, 24] }, { id: '2co', name: '2 Corinthians', + shortName: '2 Cor', chapters: 13, verses: [24, 17, 18, 18, 21, 18, 16, 24, 15, 18, 33, 21, 14] }, { id: 'gal', name: 'Galatians', + shortName: 'Gal', chapters: 6, verses: [24, 21, 29, 31, 26, 18] }, { id: 'eph', name: 'Ephesians', + shortName: 'Eph', chapters: 6, verses: [23, 22, 21, 32, 33, 24] }, - { id: 'phi', name: 'Philippians', chapters: 4, verses: [30, 30, 21, 23] }, - { id: 'col', name: 'Colossians', chapters: 4, verses: [29, 23, 25, 18] }, + { + id: 'phi', + name: 'Philippians', + shortName: 'Phil', + chapters: 4, + verses: [30, 30, 21, 23] + }, + { + id: 'col', + name: 'Colossians', + shortName: 'Col', + chapters: 4, + verses: [29, 23, 25, 18] + }, { id: '1th', name: '1 Thessalonians', + shortName: '1 Thess', chapters: 5, verses: [10, 20, 13, 18, 28] }, - { id: '2th', name: '2 Thessalonians', chapters: 3, verses: [12, 17, 18] }, + { + id: '2th', + name: '2 Thessalonians', + shortName: '2 Thess', + chapters: 3, + verses: [12, 17, 18] + }, { id: '1ti', name: '1 Timothy', + shortName: '1 Tim', chapters: 6, verses: [20, 15, 16, 16, 25, 21] }, - { id: '2ti', name: '2 Timothy', chapters: 4, verses: [18, 26, 17, 22] }, - { id: 'tit', name: 'Titus', chapters: 3, verses: [16, 15, 15] }, - { id: 'phm', name: 'Philemon', chapters: 1, verses: [25] }, + { + id: '2ti', + name: '2 Timothy', + shortName: '2 Tim', + chapters: 4, + verses: [18, 26, 17, 22] + }, + { + id: 'tit', + name: 'Titus', + shortName: 'Titus', + chapters: 3, + verses: [16, 15, 15] + }, + { id: 'phm', name: 'Philemon', shortName: 'Phlm', chapters: 1, verses: [25] }, { id: 'heb', name: 'Hebrews', + shortName: 'Heb', chapters: 13, verses: [14, 18, 19, 16, 14, 20, 28, 13, 28, 39, 40, 29, 25] }, - { id: 'jas', name: 'James', chapters: 5, verses: [27, 26, 18, 17, 20] }, - { id: '1pe', name: '1 Peter', chapters: 5, verses: [25, 25, 22, 19, 14] }, - { id: '2pe', name: '2 Peter', chapters: 3, verses: [21, 22, 18] }, - { id: '1jn', name: '1 John', chapters: 5, verses: [10, 29, 24, 21, 21] }, - { id: '2jn', name: '2 John', chapters: 1, verses: [13] }, - { id: '3jn', name: '3 John', chapters: 1, verses: [14] }, - { id: 'jud', name: 'Jude', chapters: 1, verses: [25] }, + { + id: 'jas', + name: 'James', + shortName: 'Jas', + chapters: 5, + verses: [27, 26, 18, 17, 20] + }, + { + id: '1pe', + name: '1 Peter', + shortName: '1 Pet', + chapters: 5, + verses: [25, 25, 22, 19, 14] + }, + { + id: '2pe', + name: '2 Peter', + shortName: '2 Pet', + chapters: 3, + verses: [21, 22, 18] + }, + { + id: '1jn', + name: '1 John', + shortName: '1 John', + chapters: 5, + verses: [10, 29, 24, 21, 21] + }, + { id: '2jn', name: '2 John', shortName: '2 John', chapters: 1, verses: [13] }, + { id: '3jn', name: '3 John', shortName: '3 John', chapters: 1, verses: [14] }, + { id: 'jud', name: 'Jude', shortName: 'Jude', chapters: 1, verses: [25] }, { id: 'rev', name: 'Revelation', + shortName: 'Rev', chapters: 22, verses: [ 20, 29, 22, 11, 14, 17, 17, 13, 21, 11, 19, 17, 18, 20, 8, 21, 18, 24, 21, diff --git a/views/new/AssetListItem.tsx b/views/new/AssetListItem.tsx index 5f4f337e1..26c71f59d 100644 --- a/views/new/AssetListItem.tsx +++ b/views/new/AssetListItem.tsx @@ -26,7 +26,7 @@ import { } from 'lucide-react-native'; import React from 'react'; import { Pressable, View } from 'react-native'; -import { TagModal } from './recording/components/TagModal'; +import { TagModal } from '../../components/TagModal'; import { useItemDownload, useItemDownloadStatus } from './useHybridData'; // Define props locally to avoid require cycle diff --git a/views/new/BibleAssetListItem.tsx b/views/new/BibleAssetListItem.tsx index edbca5d77..66e790032 100644 --- a/views/new/BibleAssetListItem.tsx +++ b/views/new/BibleAssetListItem.tsx @@ -26,7 +26,7 @@ import { } from 'lucide-react-native'; import React from 'react'; import { Pressable, View } from 'react-native'; -import { TagModal } from './recording/components/TagModal'; +import { TagModal } from '../../components/TagModal'; import { useItemDownload, useItemDownloadStatus } from './useHybridData'; // Define props locally to avoid require cycle diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index c5d3ebf84..8a43b6764 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -91,7 +91,7 @@ import { eq } from 'drizzle-orm'; import { ScrollView as GHScrollView } from 'react-native-gesture-handler'; import Sortable from 'react-native-sortables'; import { BibleAssetListItem } from './BibleAssetListItem'; -import RecordingViewSimplified from './recording/components/RecordingViewSimplified'; +import RecordingViewSimplified from './recording/components/NewRecordingViewSimplified'; type Asset = typeof asset.$inferSelect; @@ -169,6 +169,14 @@ export default function BibleAssetsView() { 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 }); + // Manual verse separators created by the user const [manualSeparators, setManualSeparators] = React.useState< { from: number; to: number; key: string; assetId?: string }[] @@ -266,6 +274,56 @@ export default function BibleAssetsView() { return questData?.[0]; }, [currentQuestData, queriedQuestData]); + // Store book name and chapter number for VerseSeparator label + const bookChapterLabelRef = React.useRef('Verse'); + + // Calculate book chapter label + 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(() => { @@ -533,16 +591,42 @@ export default function BibleAssetsView() { // Final assembly: result (with manual seps inserted) + unassigned block const combined: ListItem[] = [...result, ...unassignedBlock]; - // Deduplicate separators with the same range to avoid duplicates after drag/drop - const seenSeparators = new Set(); + // Build a set of manual separator ranges to check against + const manualSeparatorRanges = new Set(); + for (const sep of manualSeparators) { + const range = `${sep.from ?? 'none'}-${sep.to ?? 'none'}`; + manualSeparatorRanges.add(range); + } + + // Deduplicate separators with the same range to avoid duplicates + // Prefer manual separators over auto-generated ones + const seenSeparatorRanges = new Set(); const deduped: ListItem[] = []; + const manualSeparatorKeys = new Set(manualSeparators.map((sep) => sep.key)); + for (const item of combined) { if (item.type === 'separator') { - const sepKey = `${item.from ?? 'none'}-${item.to ?? 'none'}`; - if (seenSeparators.has(sepKey)) { + const sepRange = `${item.from ?? 'none'}-${item.to ?? 'none'}`; + const isManualSeparator = manualSeparatorKeys.has(item.key); + const hasManualSeparatorForRange = manualSeparatorRanges.has(sepRange); + + // If we've seen this range before, skip duplicates + if (seenSeparatorRanges.has(sepRange)) { + // Always skip auto-generated separators if we've seen the range + // (either from a manual separator or another auto one) + if (!isManualSeparator) { + continue; + } + // If this is a manual separator and we already added one, skip continue; } - seenSeparators.add(sepKey); + + // Skip auto-generated separators if there's a manual separator for this range + if (!isManualSeparator && hasManualSeparatorForRange) { + continue; + } + + seenSeparatorRanges.add(sepRange); } deduped.push(item); } @@ -696,6 +780,83 @@ export default function BibleAssetsView() { void processNewSeparators(); }, [manualSeparators, listItems, assets, queryClient, refetch]); + // 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: { assetId: string; metadata: AssetMetadata }[] = []; + + 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 + if (item.type === 'asset') { + assetsToUpdate.push({ + assetId: item.content.id, + metadata: { + verse: { + from: newFrom, + to: newTo + } + } + }); + } + } + + // Batch update all affected assets + if (assetsToUpdate.length > 0) { + try { + await batchUpdateAssetMetadata(assetsToUpdate); + console.log( + `✅ Updated ${assetsToUpdate.length} asset(s) below separator with new verse range ${newFrom}-${newTo}` + ); + + // 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) @@ -982,6 +1143,118 @@ export default function BibleAssetsView() { [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] + ); + const renderItem = React.useCallback( ({ item, @@ -998,7 +1271,19 @@ export default function BibleAssetsView() { editable={!isPublished} from={item.from} to={item.to} - label="Verse" + label={bookChapterLabelRef.current} + onPress={ + !isPublished + ? () => { + setEditSeparatorState({ + isOpen: true, + separatorKey: item.key, + from: item.from, + to: item.to + }); + } + : undefined + } dragHandleComponent={!isPublished ? Sortable.Handle : undefined} dragHandleProps={ !isPublished @@ -1059,7 +1344,7 @@ export default function BibleAssetsView() { > @@ -1085,6 +1370,7 @@ export default function BibleAssetsView() { currentlyPlayingAssetId, handleAssetUpdate, getRangeForAsset + // fixedItemsIndexesRef.current.length //isPublished ] ); @@ -1770,6 +2056,8 @@ export default function BibleAssetsView() { indexToKey: string[]; data: ListItem[]; }) { + fixedItemsIndexesRef.current = [0]; + // Build a map of key -> item for quick lookup const keyToItem = new Map(params.data.map((item) => [item.key, item])); @@ -2062,6 +2350,7 @@ export default function BibleAssetsView() { ref={scrollableRef} > { - // console.log('🔄 Drag start:', params); - // }} onDragEnd={(params) => void _handleSorting(params)} customHandle // autoScrollActivationOffset={75} @@ -2319,6 +2605,58 @@ export default function BibleAssetsView() { + + {/* Verse Range Selector Drawer for editing separator */} + { + 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 + ); + } + setEditSeparatorState({ isOpen: false, separatorKey: null }); + }} + onCancel={() => + setEditSeparatorState({ isOpen: false, separatorKey: null }) + } + /> + )} + + + ); } 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/NewRecordingViewSimplified.tsx b/views/new/recording/components/NewRecordingViewSimplified.tsx new file mode 100644 index 000000000..c450b28f2 --- /dev/null +++ b/views/new/recording/components/NewRecordingViewSimplified.tsx @@ -0,0 +1,2996 @@ +import type { ArrayInsertionWheelHandle } from '@/components/ArrayInsertionWheel'; +import ArrayInsertionWheel from '@/components/ArrayInsertionWheel'; +import { VerseAssigner } from '@/components/VerseAssigner'; +import { VerseSeparator } from '@/components/VerseSeparator'; +import { Button } from '@/components/ui/button'; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle +} from '@/components/ui/drawer'; +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { BIBLE_BOOKS } from '@/constants/bibleStructure'; +import { useAudio } from '@/contexts/AudioContext'; +import { useAuth } from '@/contexts/AuthContext'; +import type { AssetMetadata } from '@/database_services/assetService'; +import { + batchUpdateAssetMetadata, + renameAsset, + updateAssetMetadata +} from '@/database_services/assetService'; +import { audioSegmentService } from '@/database_services/audioSegmentService'; +import { + asset, + asset_content_link, + project_language_link, + quest_asset_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 type { LegendListRef } from '@legendapp/list'; +import { LegendList } from '@legendapp/list'; +import { toCompilableQuery } from '@powersync/drizzle-driver'; +import { useQueryClient } from '@tanstack/react-query'; +import { and, asc, eq, getTableColumns } from 'drizzle-orm'; +import { Audio } from 'expo-av'; +import { + ArrowLeft, + ArrowUpDown, + PauseIcon, + PlayIcon +} from 'lucide-react-native'; +import React from 'react'; +import { InteractionManager, View } from 'react-native'; +import { ScrollView as GHScrollView } from 'react-native-gesture-handler'; +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 { getNextOrderIndex, saveRecording } from '../services/recordingService'; +import { FullScreenVADOverlay } from './FullScreenVADOverlay'; +import { LabeledAssetCard } from './LabeledAssetCard'; +import { RecordingControls } from './RecordingControls'; +import { RenameAssetModal } from './RenameAssetModal'; +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 { + id: string; + name: string; + created_at: string; + order_index: number; + source: 'local' | 'synced' | 'cloud'; + segmentCount: number; + duration?: number; // Total duration in milliseconds + metadata?: string | { verse?: { from: number; to: number } } | null; +} + +interface RecordingViewSimplifiedProps { + onBack: () => void; + // Pass existing assets as initial data to avoid redundant query + initialAssets?: unknown[]; +} + +const RecordingViewSimplified = ({ + onBack, + initialAssets +}: RecordingViewSimplifiedProps) => { + const queryClient = useQueryClient(); + const { t } = useLocalization(); + const navigation = useCurrentNavigation(); + const { currentQuestId, currentProjectId, currentBookId, currentQuestData } = + 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 [showVADSettings, setShowVADSettings] = React.useState(false); + const [autoCalibrateOnOpen, setAutoCalibrateOnOpen] = React.useState(false); + + // Track current recording order index + const currentRecordingOrderRef = React.useRef(0); + 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()); + + // 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); + + // 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); + + // Sort order state: 'original' = by recording order, 'verse' = by verse metadata + const [sortOrder, setSortOrder] = React.useState<'original' | 'verse'>( + 'verse' + ); + + // Track footer height for proper scrolling + const [footerHeight, setFooterHeight] = React.useState(0); + const ROW_HEIGHT = 80; + + // Selection mode for batch operations (merge, delete) + const { + isSelectionMode, + selectedAssetIds, + enterSelection, + toggleSelect, + cancelSelection + } = useSelectionMode(); + + // Rename modal state + const [showRenameModal, setShowRenameModal] = React.useState(false); + const [renameAssetId, setRenameAssetId] = React.useState(null); + const [renameAssetName, setRenameAssetName] = React.useState(''); + + // Verse assigner modal state + const [showVerseAssignerModal, setShowVerseAssignerModal] = + React.useState(false); + + // 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()); + + // Load quest data to get verse count + const questTable = resolveTable('quest', { localOverride: true }); + type Quest = typeof questTable.$inferSelect; + const { data: queriedQuestData } = 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) + const selectedQuest = React.useMemo(() => { + if (queriedQuestData.length > 0) { + return queriedQuestData[0]; + } + if (currentQuestData) { + return currentQuestData as Quest; + } + return undefined; + }, [currentQuestData, queriedQuestData]); + + // Store book name and chapter number for VerseSeparator label + const bookChapterLabelRef = React.useRef('Verse'); + + // Calculate book chapter label + 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 + 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]); + + // Load assets from database + // Use initialAssets if provided to avoid redundant query and instant render + const { + data: rawAssets = [], + isOfflineLoading, + isError, + offlineError + } = useHybridData({ + dataType: 'assets', + queryKeyParams: [currentQuestId], + offlineQuery: toCompilableQuery( + system.db + .select({ + ...getTableColumns(asset), + quest_id: quest_asset_link.quest_id + }) + .from(asset) + .innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id)) + .where(eq(quest_asset_link.quest_id, currentQuestId!)) + .orderBy(asc(asset.order_index), asc(asset.created_at), asc(asset.name)) + ), + cloudQueryFn: async () => { + const { data, error } = await system.supabaseConnector.client + .from('quest_asset_link') + .select('asset:asset_id(*)') + .eq('quest_id', currentQuestId) + .order('order_index', { ascending: true }) + .order('created_at', { ascending: true }) + .order('name', { ascending: true }); + if (error) throw error; + + return data.map((d: { asset: unknown }) => d.asset).filter(Boolean); + }, + enableOfflineQuery: true, + enableCloudQuery: true, + lazyLoadCloud: true, // Show local data immediately + getItemId: (item) => { + const typedItem = item as unknown as { id: string }; + return typedItem.id; + }, + // Use initial data if provided - renders instantly with cached data + offlineQueryOptions: initialAssets + ? { + initialData: initialAssets, + staleTime: 0 // Still refetch to ensure fresh data + } + : undefined + }); + + // 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'; + metadata?: string | { 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 metadata + if (obj.metadata) { + debugLog( + `📋 Asset "${obj.name}" (${obj.id.slice(0, 8)}) has metadata:`, + obj.metadata + ); + } + + // DEBUG: Log assets with multiple segments + if (segmentCount > 1) { + debugLog( + `📊 Asset "${obj.name}" (${obj.id.slice(0, 8)}) has ${segmentCount} segments` + ); + } + + return { + 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, + metadata: obj.metadata + }; + }); + + // 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]); + + // 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 asset list that only updates when content actually changes + // Sorted by verse range (assets without metadata go to the bottom) or by original order + const assetsForLegendList = React.useMemo(() => { + if (sortOrder === 'original') { + // Return assets in their original order (as they come from the database) + return assets; + } + + // Sort by verse metadata (verse.from) + // Create a copy to avoid mutating the original array + const sorted = [...assets].sort((a, b) => { + // If one doesn't have metadata, it goes to the bottom + if (!a.metadata && !b.metadata) return 0; // Both without metadata: maintain order + if (!a.metadata) return 1; // a goes to bottom + if (!b.metadata) return -1; // b goes to bottom + + // Parse JSON metadata if it's a string + let aMetadata: unknown; + let bMetadata: unknown; + + try { + aMetadata = + typeof a.metadata === 'string' ? JSON.parse(a.metadata) : a.metadata; + bMetadata = + typeof b.metadata === 'string' ? JSON.parse(b.metadata) : b.metadata; + } catch { + // If parsing fails, treat as no metadata (goes to bottom) + if (!aMetadata) return 1; + if (!bMetadata) return -1; + return 0; + } + + // Extract verse range + const aVerse = + aMetadata && typeof aMetadata === 'object' && 'verse' in aMetadata + ? (aMetadata as { verse?: { from?: number; to?: number } }).verse + ?.from + : undefined; + const bVerse = + bMetadata && typeof bMetadata === 'object' && 'verse' in bMetadata + ? (bMetadata as { verse?: { from?: number; to?: number } }).verse + ?.from + : undefined; + + // If verse is undefined, treat as no metadata (goes to bottom) + if (aVerse === undefined && bVerse === undefined) return 0; + if (aVerse === undefined) return 1; // a goes to bottom + if (bVerse === undefined) return -1; // b goes to bottom + + // Both have verse ranges, compare them + return aVerse - bVerse; + }); + + return sorted; + }, [assets, sortOrder]); + + // Helper function to extract verse from metadata + const getVerseFromMetadata = React.useCallback( + ( + metadata: + | string + | { verse?: { from: number; to: number } } + | null + | undefined + ): { + from?: number; + to?: number; + } | null => { + if (!metadata) return null; + + try { + const parsed: unknown = + typeof metadata === 'string' ? JSON.parse(metadata) : metadata; + + if ( + parsed && + typeof parsed === 'object' && + 'verse' in parsed && + parsed.verse && + typeof parsed.verse === 'object' && + 'from' in parsed.verse + ) { + const verse = parsed.verse as { from: unknown; to?: unknown }; + const from = typeof verse.from === 'number' ? verse.from : undefined; + const to = + typeof verse.to === 'number' + ? verse.to + : typeof verse.from === 'number' + ? verse.from + : undefined; + + if (from !== undefined) { + return { from, to }; + } + } + } catch { + // Ignore parsing errors + } + + return null; + }, + [] + ); + + // Calculate total number of elements in the wheel (including separators) + // This needs to match the logic in wheelChildren to ensure correct clamping + const totalWheelItems = React.useMemo(() => { + let separatorCount = 0; + + if (sortOrder === 'verse') { + assetsForLegendList.forEach((item, index) => { + const currentVerse = getVerseFromMetadata(item.metadata); + const prevItem = index > 0 ? assetsForLegendList[index - 1] : null; + const prevVerse = prevItem + ? getVerseFromMetadata(prevItem.metadata) + : null; + + // Check if this is the start of a new verse group + if (index === 0) { + separatorCount++; + } else if (!currentVerse && prevVerse) { + separatorCount++; + } else if (currentVerse && !prevVerse) { + separatorCount++; + } else if (currentVerse && prevVerse) { + if ( + currentVerse.from !== prevVerse.from || + (currentVerse.to ?? currentVerse.from) !== + (prevVerse.to ?? prevVerse.from) + ) { + separatorCount++; + } + } + }); + } + + // Total = assets + separators + return assetsForLegendList.length + separatorCount; + }, [assetsForLegendList, sortOrder, getVerseFromMetadata]); + + // Clamp insertion index when wheel items count changes + // Note: insertionIndex represents insertion boundaries, so maxIndex = totalWheelItems + // (can insert at 0..N boundaries, where N is the number of items) + React.useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (USE_INSERTION_WHEEL) { + const maxIndex = totalWheelItems; // Can insert at 0..N (after last item) + if (insertionIndex > maxIndex) { + debugLog( + `📍 Clamping insertion index from ${insertionIndex} to ${maxIndex} (total wheel items: ${totalWheelItems})` + ); + setInsertionIndex(maxIndex); + } + } + }, [totalWheelItems, insertionIndex]); + + // Ref for LegendList to enable scrolling + const listRef = React.useRef(null); + + // Track asset count to detect new insertions + const previousAssetCountRef = React.useRef(assets.length); + + // Auto-scroll behavior differs between list and wheel + React.useEffect(() => { + const currentCount = assets.length; + const previousCount = previousAssetCountRef.current; + + // Only scroll if a new asset was added (count increased) + if (currentCount > previousCount && currentCount > 0) { + debugLog('📜 Auto-scrolling to new asset'); + + // Small delay to ensure the new item is rendered before scrolling + setTimeout(() => { + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (USE_INSERTION_WHEEL) { + // For wheel: scroll to the newly inserted item's position + // After insertion at index N, the new item is at position N + const newItemIndex = Math.min(insertionIndex, currentCount - 1); + wheelRef.current?.scrollToInsertionIndex(newItemIndex + 1, true); + } else { + // For list: scroll to end + listRef.current?.scrollToEnd({ animated: true }); + } + } catch (error) { + console.error('Failed to scroll:', error); + } + }, 100); + } + + previousAssetCountRef.current = currentCount; + }, [assets.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. + }, [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 + // ============================================================================ + + // Store insertion index in ref to prevent stale closure issues + const insertionIndexRef = React.useRef(insertionIndex); + React.useEffect(() => { + insertionIndexRef.current = insertionIndex; + }, [insertionIndex]); + + // Initialize VAD counter when VAD mode activates + React.useEffect(() => { + if (isVADLocked && vadCounterRef.current === null) { + // CRITICAL: Use ref to get the LATEST insertionIndex value + // This prevents issues when fullscreen overlay blocks the wheel and causes + // insertionIndex state updates to be delayed or missed + const currentInsertionIndex = insertionIndexRef.current; + const currentAssets = assets; + + debugLog( + `🎯 VAD initializing | insertionIndex (ref): ${currentInsertionIndex} | insertionIndex (state): ${insertionIndex} | assets.length: ${currentAssets.length}` + ); + + void (async () => { + let targetOrder: number; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (USE_INSERTION_WHEEL) { + // Respect insertion wheel position (same logic as manual recordings) + // insertionIndex is the boundary BEFORE an item + // When at bottom (insertionIndex === assets.length), append to end + // When in middle, insert after the currently viewed item + + if (currentInsertionIndex >= currentAssets.length) { + // At or past the end - append + targetOrder = + currentAssets.length > 0 + ? (currentAssets[currentAssets.length - 1]?.order_index ?? + currentAssets.length - 1) + 1 + : 0; + debugLog( + `🎯 VAD: At bottom, appending with order_index: ${targetOrder}` + ); + } else { + // In the middle - insert after current item + const actualInsertionIndex = currentInsertionIndex + 1; + if (actualInsertionIndex < currentAssets.length) { + targetOrder = + currentAssets[actualInsertionIndex]?.order_index ?? + actualInsertionIndex; + } else { + targetOrder = + currentAssets.length > 0 + ? (currentAssets[currentAssets.length - 1]?.order_index ?? + currentAssets.length - 1) + 1 + : 0; + } + debugLog( + `🎯 VAD: In middle at visual index ${currentInsertionIndex}, inserting at order_index: ${targetOrder}` + ); + } + } else { + // Legacy: append to end + targetOrder = await getNextOrderIndex(currentQuestId!); + debugLog(`🎯 VAD counter initialized to end: ${targetOrder}`); + } + + vadCounterRef.current = targetOrder; + })(); + } else if (!isVADLocked) { + vadCounterRef.current = null; + } + // IMPORTANT: Only depend on isVADLocked and currentQuestId + // insertionIndex is read from ref to avoid stale closure issues + // assets is captured from closure (intentional - we want the state at activation time) + }, [isVADLocked, currentQuestId, assets, insertionIndex]); + + // Manual recording handlers + const handleRecordingStart = React.useCallback(() => { + if (isRecording) return; + debugLog('🎬 Manual recording start'); + setIsRecording(true); + + // Set order index for manual recording + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (USE_INSERTION_WHEEL) { + // IMPORTANT: insertionIndex is the boundary BEFORE an item + // When user sees item 0 centered, insertionIndex = 0 (before item 0) + // But they want to insert AFTER the item they're viewing + // So we use insertionIndex + 1 for the actual insertion position + const actualInsertionIndex = insertionIndex + 1; + + const targetOrder = + actualInsertionIndex < assets.length + ? (assets[actualInsertionIndex]?.order_index ?? actualInsertionIndex) + : (assets[assets.length - 1]?.order_index ?? assets.length - 1) + 1; + currentRecordingOrderRef.current = targetOrder; + debugLog( + `🎯 Recording will insert AFTER item at visual index ${insertionIndex} (boundary ${actualInsertionIndex}) with order_index ${targetOrder}` + ); + } else { + // Legacy: append to end + const targetOrder = + assets.length > 0 + ? (assets[assets.length - 1]?.order_index ?? 0) + 1 + : 0; + currentRecordingOrderRef.current = targetOrder; + } + }, [isRecording, assets, insertionIndex]); + + const handleRecordingStop = React.useCallback(() => { + debugLog('🛑 Manual recording stop'); + setIsRecording(false); + }, []); + + const handleRecordingDiscarded = React.useCallback(() => { + debugLog('🗑️ Recording discarded'); + setIsRecording(false); + }, []); + + // Helper function to determine verse at insertion position when sorting by verse + const getVerseAtInsertionIndex = + React.useCallback((): AssetMetadata | null => { + if (sortOrder !== 'verse' || assetsForLegendList.length === 0) { + return null; + } + + // insertionIndex represents the insertion point in the wheelChildren array + // We need to find which verse group this insertion point belongs to + let wheelPosition = 0; + + for (let i = 0; i < assetsForLegendList.length; i++) { + const item = assetsForLegendList[i]; + if (!item) continue; + + // Check if we need a separator before this item + const currentVerse = getVerseFromMetadata(item.metadata); + const prevItem = i > 0 ? assetsForLegendList[i - 1] : null; + const prevVerse = prevItem + ? getVerseFromMetadata(prevItem.metadata) + : null; + + let shouldShowVerseSeparator = false; + if (i === 0) { + shouldShowVerseSeparator = true; + } else if (!currentVerse && prevVerse) { + shouldShowVerseSeparator = true; + } else if (currentVerse && !prevVerse) { + shouldShowVerseSeparator = true; + } else if (currentVerse && prevVerse) { + shouldShowVerseSeparator = + currentVerse.from !== prevVerse.from || + (currentVerse.to ?? currentVerse.from) !== + (prevVerse.to ?? prevVerse.from); + } + + // If insertionIndex is at or before this separator, return the verse + if (shouldShowVerseSeparator) { + if (insertionIndex <= wheelPosition) { + if (currentVerse?.from !== undefined) { + return { + verse: { + from: currentVerse.from, + to: currentVerse.to ?? currentVerse.from + } + }; + } + return null; + } + wheelPosition++; // Separator takes one position + } + + // Check if insertionIndex is at or before this asset + if (insertionIndex <= wheelPosition) { + const verse = getVerseFromMetadata(item.metadata); + if (verse?.from !== undefined) { + return { + verse: { + from: verse.from, + to: verse.to ?? verse.from + } + }; + } + return null; + } + wheelPosition++; // Asset takes one position + } + + // If insertionIndex is after all items, use the verse of the last item + const lastItem = assetsForLegendList[assetsForLegendList.length - 1]; + const verse = lastItem ? getVerseFromMetadata(lastItem.metadata) : null; + if (verse?.from !== undefined) { + return { + verse: { + from: verse.from, + to: verse.to ?? verse.from + } + }; + } + + return null; + }, [sortOrder, assetsForLegendList, insertionIndex, getVerseFromMetadata]); + + const handleRecordingComplete = React.useCallback( + async (uri: string, _duration: number, _waveformData: number[]) => { + const targetOrder = currentRecordingOrderRef.current; + + try { + debugLog('💾 Saving recording | order_index:', targetOrder); + + // Validate required data + if ( + !currentProjectId || + !currentQuestId || + !currentProject || + !currentUser + ) { + console.error('❌ Missing required data'); + return; + } + + // Generate name immediately and reserve it to prevent duplicates + // In VAD mode: Use the VAD counter which is already incremented per segment + // In manual mode: Use total count (existing + pending) for simple sequential naming + const nextNumber = isVADLocked + ? targetOrder + 1 // VAD: use order_index + 1 for naming (order is 0-based, names are 1-based) + : assets.length + pendingAssetNamesRef.current.size + 1; + const assetName = String(nextNumber).padStart(3, '0'); + pendingAssetNamesRef.current.add(assetName); + debugLog( + `🏷️ Reserved name: ${assetName} (${isVADLocked ? 'VAD mode' : 'manual mode'}) | order_index: ${targetOrder}, asset count: ${assets.length}, pending: ${pendingAssetNamesRef.current.size}` + ); + + // 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) + let newAssetId: string | undefined; + dbWriteQueueRef.current = dbWriteQueueRef.current + .then(async () => { + if (!targetLanguoidId) { + throw new Error('Target languoid not found for project'); + } + const assetId = await saveRecording({ + questId: currentQuestId, + projectId: currentProjectId, + targetLanguoidId: targetLanguoidId, + userId: currentUser.id, + orderIndex: targetOrder, + audioUri: localUri, + assetName: assetName // Pass the reserved name + }); + newAssetId = assetId; + // 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; + + // If sorting by verse, automatically apply verse metadata to the new asset + if (sortOrder === 'verse' && newAssetId) { + try { + const verseMetadata = getVerseAtInsertionIndex(); + if (verseMetadata) { + await updateAssetMetadata(newAssetId, verseMetadata); + debugLog( + `✅ Applied verse metadata to new asset: ${JSON.stringify(verseMetadata)}` + ); + } + } catch (error) { + console.error('❌ Failed to apply verse metadata:', error); + // Don't throw - asset was created successfully, metadata is optional + } + } + + // Invalidate queries to refresh asset list + if (!isVADLocked) { + 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, + isVADLocked, + assets, + targetLanguoidId, + sortOrder, + getVerseAtInsertionIndex + ] + ); + + // VAD segment handlers + const handleVADSegmentStart = React.useCallback(() => { + if (vadCounterRef.current === null) { + console.error('❌ VAD counter not initialized!'); + return; + } + + const targetOrder = vadCounterRef.current; + debugLog('🎬 VAD: Segment starting | order_index:', targetOrder); + + currentRecordingOrderRef.current = targetOrder; + vadCounterRef.current = targetOrder + 1; // Increment for next segment + }, []); + + 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(); + + // 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 + setTimeout(() => { + void processBatch(startIdx + BATCH_SIZE); + }, 16); // One frame delay (60fps) + } + } 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(); + }; + // Depend on assetIds, assetMetadata, and state maps + // State maps are included so we detect when durations are missing (e.g., after remount) + // The effect safely handles updates by only loading missing assets + }, [assetIds, assetMetadata, assetSegmentCounts, assetDurations]); + + // ============================================================================ + // ASSET OPERATIONS (Delete, Merge) + // ============================================================================ + + const handleDeleteLocalAsset = React.useCallback( + async (assetId: string) => { + try { + await audioSegmentService.deleteAudioSegment(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); + + // 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); + } + + // 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); + } + + 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]); + + // Collect existing verse labels from all assets + const existingLabels = React.useMemo(() => { + const labelsMap = new Map(); + + for (const asset of assets) { + if (!asset.metadata) continue; + + 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; + 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' + ) { + const key = `${verse.from}-${verse.to}`; + if (!labelsMap.has(key)) { + labelsMap.set(key, { from: verse.from, to: verse.to }); + } + } + } + } + } catch { + // Skip invalid metadata + } + } + + return Array.from(labelsMap.values()).sort((a, b) => { + if (a.from !== b.from) return a.from - b.from; + return a.to - b.to; + }); + }, [assets]); + + // Calculate available verses (excluding occupied ones) + const availableVerses = React.useMemo(() => { + if (verseCount === 0) return []; + + // Create a set of occupied verses + const occupiedVerses = new Set(); + for (const label of existingLabels) { + for (let verse = label.from; verse <= label.to; verse++) { + occupiedVerses.add(verse); + } + } + + // Return array of available verses (1 to verseCount, excluding occupied) + const available: number[] = []; + for (let verse = 1; verse <= verseCount; verse++) { + if (!occupiedVerses.has(verse)) { + available.push(verse); + } + } + + return available; + }, [existingLabels, 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) => { + // 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 first existing label that starts after selectedFrom + const sortedLabels = [...existingLabels].sort((a, b) => a.from - b.from); + const nextLabel = sortedLabels.find((label) => label.from > selectedFrom); + + if (nextLabel) { + // Return the verse just before the next label starts + return nextLabel.from - 1; + } + + // No label after selectedFrom, can go to the end + return verseCount || 1; + }, + [existingLabels, verseCount, availableVerses] + ); + + // Check if selected assets have verse labels + const hasSelectedAssetsWithLabels = React.useMemo(() => { + const selectedAssets = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + return selectedAssets.some((asset) => { + if (!asset.metadata) return false; + try { + const metadata: unknown = + typeof asset.metadata === 'string' + ? JSON.parse(asset.metadata) + : asset.metadata; + if ( + metadata && + typeof metadata === 'object' && + 'verse' in metadata && + metadata.verse && + typeof metadata.verse === 'object' && + 'from' in metadata.verse && + 'to' in metadata.verse + ) { + return true; + } + } catch { + // Skip invalid metadata + } + return false; + }); + }, [assets, selectedAssetIds]); + + // Handle verse assignment to selected assets + const handleAssignVerse = React.useCallback( + (from: number, to: number) => { + const selectedOrdered = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + if (selectedOrdered.length < 1) return; + + void (async () => { + try { + const updates = selectedOrdered.map((asset) => ({ + assetId: asset.id, + metadata: { verse: { from, to } } as AssetMetadata + })); + + await batchUpdateAssetMetadata(updates); + + cancelSelection(); + setShowVerseAssignerModal(false); + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + + debugLog( + `✅ Verse assignment completed: ${selectedOrdered.length} assets assigned verse ${from}-${to}` + ); + } catch (e) { + console.error('Failed to assign verse to assets', e); + RNAlert.alert( + 'Error', + 'Failed to assign verse to assets. Please try again.' + ); + } + })(); + }, + [assets, selectedAssetIds, cancelSelection, queryClient, currentQuestId] + ); + + // Handle verse label removal from selected assets + const handleRemoveVerse = React.useCallback(() => { + const selectedOrdered = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + if (selectedOrdered.length < 1) return; + + void (async () => { + try { + // Remove verse metadata while preserving other metadata properties + const updates = selectedOrdered.map((asset) => { + let newMetadata: AssetMetadata | null = null; + + // Parse existing metadata if it exists + if (asset.metadata) { + try { + const existingMetadata: unknown = + typeof asset.metadata === 'string' + ? JSON.parse(asset.metadata) + : asset.metadata; + + if (existingMetadata && typeof existingMetadata === 'object') { + // Create new metadata object without the verse property + const { verse, ...rest } = existingMetadata as { + verse?: unknown; + [key: string]: unknown; + }; + // Only keep metadata if there are other properties, otherwise set to null + newMetadata = + Object.keys(rest).length > 0 ? (rest as AssetMetadata) : null; + } + } catch { + // If parsing fails, set to null + newMetadata = null; + } + } + + return { + assetId: asset.id, + metadata: newMetadata + }; + }); + + await batchUpdateAssetMetadata(updates); + + cancelSelection(); + setShowVerseAssignerModal(false); + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + + debugLog( + `✅ Verse removal completed: ${selectedOrdered.length} assets had verse labels removed` + ); + } catch (e) { + console.error('Failed to remove verse from assets', e); + RNAlert.alert( + 'Error', + 'Failed to remove verse labels from assets. Please try again.' + ); + } + })(); + }, [assets, selectedAssetIds, cancelSelection, queryClient, currentQuestId]); + + // ============================================================================ + // RENAME ASSET + // ============================================================================ + + const handleRenameAsset = React.useCallback( + (assetId: string, currentName: string | null) => { + setRenameAssetId(assetId); + setRenameAssetName(currentName ?? ''); + setShowRenameModal(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 + 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] + ); + + // ============================================================================ + // 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 + ]); + + // Memoized render function for LegendList + // OPTIMIZED: No audioContext.position dependency - progress now uses SharedValues! + // This eliminates 10 re-renders/second during audio playback + const renderAssetItem = React.useCallback( + ({ item, index }: { item: UIAsset; index: number }) => { + // 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); + const canMergeDown = + index < assets.length - 1 && assets[index + 1]?.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; + + return ( + { + if (isSelectionMode) { + stableToggleSelect(item.id); + } else { + void stableHandlePlayAsset(item.id); + } + }} + onLongPress={() => { + stableEnterSelection(item.id); + }} + onPlay={() => { + void stableHandlePlayAsset(item.id); + }} + onDelete={stableHandleDeleteLocalAsset} + onMerge={stableHandleMergeDownLocal} + onRename={stableHandleRenameAsset} + /> + ); + }, + [ + audioContext.isPlaying, + audioContext.currentAudioId, + currentlyPlayingAssetId, + // audioContext.position REMOVED - uses SharedValues now! + // audioContext.duration REMOVED - not needed for render + selectedAssetIds, + isSelectionMode, + assets, + sortOrder, + stableHandlePlayAsset, + stableToggleSelect, + stableEnterSelection, + stableHandleDeleteLocalAsset, + stableHandleMergeDownLocal, + stableHandleRenameAsset + ] + ); + + // Memoized children for ArrayInsertionWheel + // OPTIMIZED: No audioContext.position/duration dependencies - progress now uses SharedValues! + // This eliminates re-creating all children 10+ times per second during audio playback + const wheelChildren = React.useMemo(() => { + // Map assets to wheel items + return assetsForLegendList + .map((item, index) => { + // 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); + const canMergeDown = + index < assetsForLegendList.length - 1 && + assetsForLegendList[index + 1]?.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; + + // Check if we need to show VerseSeparator (when sorting by verse) + let shouldShowVerseSeparator = false; + let currentVerse: { from?: number; to?: number } | null = null; + + if (sortOrder === 'verse') { + currentVerse = getVerseFromMetadata(item.metadata); + const prevItem = index > 0 ? assetsForLegendList[index - 1] : null; + const prevVerse = prevItem + ? getVerseFromMetadata(prevItem.metadata) + : null; + + // Check if this is the start of a new verse group + if (index === 0) { + // First item - always show separator + shouldShowVerseSeparator = true; + } else if (!currentVerse && prevVerse) { + // Transition from verse to no verse + shouldShowVerseSeparator = true; + } else if (currentVerse && !prevVerse) { + // Transition from no verse to verse + shouldShowVerseSeparator = true; + } else if (currentVerse && prevVerse) { + // Both have verses - check if they're different + shouldShowVerseSeparator = + currentVerse.from !== prevVerse.from || + (currentVerse.to ?? currentVerse.from) !== + (prevVerse.to ?? prevVerse.from); + } + } + + // Return array with separator (if needed) and card as separate items + const items: React.ReactNode[] = []; + + // Add separator as a separate list item if needed + if (shouldShowVerseSeparator) { + items.push( + + ); + } + + // Add asset card as a separate list item + items.push( + { + if (isSelectionMode) { + stableToggleSelect(item.id); + } else { + void stableHandlePlayAsset(item.id); + } + }} + onLongPress={() => { + stableEnterSelection(item.id); + }} + onPlay={() => { + void stableHandlePlayAsset(item.id); + }} + onDelete={stableHandleDeleteLocalAsset} + onMerge={stableHandleMergeDownLocal} + onRename={stableHandleRenameAsset} + /> + ); + + return items; + }) + .flat(); + }, [ + assetsForLegendList, + sortOrder, + getVerseFromMetadata, + audioContext.isPlaying, + audioContext.currentAudioId, + currentlyPlayingAssetId, + // assetProgressSharedMap REMOVED - it's a ref, accessed directly in render + // audioContext.position REMOVED - uses SharedValues now! + // audioContext.duration REMOVED - not needed for render + selectedAssetIds, + isSelectionMode, + stableHandlePlayAsset, + stableToggleSelect, + stableEnterSelection, + stableHandleDeleteLocalAsset, + stableHandleMergeDownLocal, + stableHandleRenameAsset + ]); + + // Render loading state + if (isOfflineLoading) { + return ( + + + {t('loading') || 'Loading assets...'} + + + ); + } + + // Render error state + if (isError && offlineError) { + return ( + + Error loading assets + + {offlineError.message} + + + ); + } + + // Show full-screen overlay when VAD is locked and display mode is fullscreen + const showFullScreenOverlay = isVADLocked && vadDisplayMode === 'fullscreen'; + + return ( + + {/* Full-screen VAD overlay - takes over entire screen */} + {showFullScreenOverlay && ( + { + // Cancel VAD mode + setIsVADLocked(false); + }} + /> + )} + + {/* Header */} + + + + + {t('doRecord')} + + + {t('assets')} ({assets.length}) + + + {assets.length > 0 && ( + + )} + + + {/* Scrollable list area - full height with padding for controls */} + + {/* Sort button - positioned absolutely at the top */} + + + + + {assets.length === 0 && ( + + + No assets yet. Start recording to create your first asset. + + + )} + + {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */} + {USE_INSERTION_WHEEL ? ( + // ArrayInsertionWheel mode - always show wheel, even when empty + + {wheelChildren} + + ) : ( + // LegendList mode (legacy) + assetsForLegendList.length > 0 && ( + + ) + )} + + + {/* Bottom controls - absolutely positioned */} + + {isSelectionMode ? ( + + setShowVerseAssignerModal(true)} + /> + + ) : ( + setShowVADSettings(true)} + onAutoCalibratePress={() => { + setAutoCalibrateOnOpen(true); + setShowVADSettings(true); + }} + currentEnergy={currentEnergy} + vadThreshold={vadThreshold} + energyShared={energyShared} + isRecordingShared={isRecordingShared} + displayMode={vadDisplayMode} + /> + )} + + + {/* Rename modal */} + setShowRenameModal(false)} + onSave={handleSaveRename} + /> + + {/* Verse Assigner Drawer */} + { + if (!open) { + setShowVerseAssignerModal(false); + } + }} + snapPoints={['50%']} + enableDynamicSizing={false} + > + + + Assign Verse Label + + + setShowVerseAssignerModal(false)} + /> + + + + + {/* 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} + /> + + ); +}; + +export default RecordingViewSimplified; diff --git a/views/new/recording/components/SelectionControls.tsx b/views/new/recording/components/SelectionControls.tsx index adfbd97b8..15e72b63c 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 { Bookmark, Merge, Trash2, X } from 'lucide-react-native'; import React from 'react'; import { View } from 'react-native'; @@ -21,13 +21,15 @@ interface SelectionControlsProps { onCancel: () => void; onMerge: () => void; onDelete: () => void; + onAssignVerse?: () => void; } export const SelectionControls = React.memo(function SelectionControls({ selectedCount, onCancel, onMerge, - onDelete + onDelete, + onAssignVerse }: SelectionControlsProps) { const { t } = useLocalization(); return ( @@ -35,6 +37,13 @@ export const SelectionControls = React.memo(function SelectionControls({ ({selectedCount}) + + + + + + + + + ); +} diff --git a/store/localStore.ts b/store/localStore.ts index 5e688c126..ddf46dfe2 100644 --- a/store/localStore.ts +++ b/store/localStore.ts @@ -97,6 +97,8 @@ export interface LocalState { setEnableQuestExport: (enabled: boolean) => void; enableVerseMarkers: boolean; setEnableVerseMarkers: (enabled: boolean) => void; + verseMarkersFeaturePrompted: boolean; + setVerseMarkersFeaturePrompted: (prompted: boolean) => void; // VAD (Voice Activity Detection) settings // vadThreshold: 0.005-0.1 (lower = more sensitive, picks up quiet speech) @@ -226,6 +228,7 @@ export const useLocalStore = create()( enablePlayAll: false, enableQuestExport: false, enableVerseMarkers: false, + verseMarkersFeaturePrompted: false, // VAD settings (defaults) vadThreshold: 0.085, // 8.5% sensitivity @@ -328,6 +331,8 @@ export const useLocalStore = create()( setEnablePlayAll: (enabled) => set({ enablePlayAll: enabled }), setEnableQuestExport: (enabled) => set({ enableQuestExport: enabled }), setEnableVerseMarkers: (enabled) => set({ enableVerseMarkers: enabled }), + setVerseMarkersFeaturePrompted: (prompted) => + set({ verseMarkersFeaturePrompted: prompted }), // VAD settings setters setVadThreshold: (threshold) => set({ vadThreshold: threshold }), diff --git a/views/new/BibleBookList.tsx b/views/new/BibleBookList.tsx index d9d973898..f58b37e63 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)} From d8c0fa0bf896859ec911d7eb4090542793b537dc Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Sat, 3 Jan 2026 10:32:50 -0800 Subject: [PATCH 15/39] Change Question Modal to Drawer --- components/QuestionModal.tsx | 97 +++++++++++++++++++----------------- services/localizations.ts | 20 ++++---- views/SettingsView.tsx | 4 +- views/new/BibleBookList.tsx | 4 +- 4 files changed, 64 insertions(+), 61 deletions(-) diff --git a/components/QuestionModal.tsx b/components/QuestionModal.tsx index 86997220b..1ce0e1f60 100644 --- a/components/QuestionModal.tsx +++ b/components/QuestionModal.tsx @@ -1,7 +1,15 @@ 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 { Modal, Pressable, TouchableWithoutFeedback, View } from 'react-native'; +import { View } from 'react-native'; interface QuestionModalProps { visible: boolean; @@ -20,56 +28,51 @@ export function QuestionModal({ onNo, onClose }: QuestionModalProps) { - const handleClose = () => { + 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 ( - - - - e.stopPropagation()}> - - - - {title} - - - {description} - - + + + + {title} + {description} + - - - - - - - - - + + + + + + + + ); } diff --git a/services/localizations.ts b/services/localizations.ts index c38752702..54f66b2a7 100644 --- a/services/localizations.ts +++ b/services/localizations.ts @@ -5893,21 +5893,21 @@ export const localizations = { indonesian: 'Tautan disalin ke clipboard!' }, verseMarkers: { - english: 'Verse Markers', - spanish: 'Marcadores de Versículos', - brazilian_portuguese: 'Marcadores de Versículos', - tok_pisin: 'Verse Markers', - indonesian: 'Marker Versi' + 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 markers to help organize Bible resources', + english: 'Enable verse labels to help organize Bible resources', spanish: - 'Habilitar marcadores de versículos para ayudar a organizar recursos de la Biblia', + 'Habilitar etiquetas de versículos para ayudar a organizar recursos de la Biblia', brazilian_portuguese: - 'Habilitar marcadores de versículos para ajudar a organizar recursos da Bíblia', - tok_pisin: 'Enable verse markers to help organize Bible resources', + '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 marker versi untuk membantu mengorganisir sumber daya Alkitab' + 'Aktifkan label versi untuk membantu mengorganisir sumber daya Alkitab' } } as const; diff --git a/views/SettingsView.tsx b/views/SettingsView.tsx index a4f7cc37c..fd48c1ee8 100644 --- a/views/SettingsView.tsx +++ b/views/SettingsView.tsx @@ -273,10 +273,10 @@ export default function SettingsView() { }, { id: 'verseMarkers', - title: t('verseMarkers') || 'Verse Markers', + title: t('verseMarkers') || 'Verse Labels', description: t('verseMarkersDescription') || - 'Enable verse markers to help organize Bible resources', + 'Enable verse labels to help organize Bible resources', type: 'toggle', value: enableVerseMarkers, onPress: () => handleVerseMarkersToggle(!enableVerseMarkers) diff --git a/views/new/BibleBookList.tsx b/views/new/BibleBookList.tsx index f58b37e63..1c0ee238e 100644 --- a/views/new/BibleBookList.tsx +++ b/views/new/BibleBookList.tsx @@ -138,8 +138,8 @@ export function BibleBookList({ From 3326a1af92c12962dbb9137129f3b08e829af65c Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Thu, 8 Jan 2026 06:33:42 -0800 Subject: [PATCH 16/39] Add infinite scroll --- views/new/BibleAssetsView.tsx | 70 ++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 7455b7c71..8353f2811 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -50,7 +50,9 @@ import { ActivityIndicator, Pressable, View } from 'react-native'; import Animated, { cancelAnimation, Easing, + runOnJS, useAnimatedRef, + useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withRepeat, @@ -280,6 +282,9 @@ export default function BibleAssetsView() { 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'); @@ -428,6 +433,19 @@ export default function BibleAssetsView() { 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, @@ -437,11 +455,8 @@ export default function BibleAssetsView() { isOnline, isFetching, refetch - } = useAssetsByQuest( - currentQuestId || '', - debouncedSearchQuery, - showInvisibleContent - ); + } = 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 @@ -465,6 +480,29 @@ export default function BibleAssetsView() { 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)(); + } + } + }); + const listItems = React.useMemo((): ListItem[] => { // Separate assets with and without metadata const assetsWithMeta = assets.filter( @@ -2146,7 +2184,7 @@ export default function BibleAssetsView() { } // Check if quest is published (source is 'synced') - const isPublished = selectedQuest?.source === 'synced'; + // const isPublished = selectedQuest?.source === 'synced'; // Get project name for PrivateAccessGate // Note: queriedProjectData doesn't include name, so we only use currentProjectData @@ -2447,6 +2485,8 @@ export default function BibleAssetsView() { + {/* Loading indicator for infinite scroll */} + {isFetchingNextPage && ( + + + + {t('loading')}... + + + )} + {/* End of list indicator */} + {!hasNextPage && assets.length > 0 && ( + + ••• + + )} )} From 158a8b1afb04e3fdc9c51705431a32404594c57e Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Fri, 9 Jan 2026 07:30:19 -0800 Subject: [PATCH 17/39] Optimize functions --- views/new/BibleAssetListItem.tsx | 151 ++++++--- views/new/BibleAssetsView.tsx | 564 +++++++++++++++++-------------- 2 files changed, 415 insertions(+), 300 deletions(-) diff --git a/views/new/BibleAssetListItem.tsx b/views/new/BibleAssetListItem.tsx index 8d9bddd76..145d9592d 100644 --- a/views/new/BibleAssetListItem.tsx +++ b/views/new/BibleAssetListItem.tsx @@ -1,5 +1,5 @@ import { DownloadIndicator } from '@/components/DownloadIndicator'; -import { Badge } from '@/components/ui/badge'; +// import { Badge } from '@/components/ui/badge'; import { Card, CardDescription, @@ -9,25 +9,27 @@ 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 { 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 { useTagStore } from '@/hooks/useTagStore'; import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; import type { AttachmentRecord } from '@powersync/attachments'; import { + CheckSquareIcon, EyeOffIcon, HardDriveIcon, PauseIcon, PlayIcon, - Plus, - TagIcon + // Plus, + SquareIcon + // TagIcon } from 'lucide-react-native'; import React from 'react'; import { Pressable, View } from 'react-native'; -import { TagModal } from '../../components/TagModal'; +// import { TagModal } from '../../components/TagModal'; import { useItemDownload, useItemDownloadStatus } from './useHybridData'; // Define props locally to avoid require cycle @@ -48,6 +50,11 @@ export interface BibleAssetListItemProps { attachmentState?: AttachmentRecord; isCurrentlyPlaying?: boolean; dragHandle?: React.ReactNode; + // Selection mode props + isSelectionMode?: boolean; + isSelected?: boolean; + onToggleSelect?: (assetId: string) => void; + onEnterSelection?: (assetId: string) => void; } export const BibleAssetListItem: React.FC = ({ @@ -55,10 +62,14 @@ export const BibleAssetListItem: React.FC = ({ questId, isCurrentlyPlaying = false, isPublished, - onUpdate, + onUpdate: _onUpdate, onPlay, attachmentState: _attachmentState, - dragHandle + dragHandle, + isSelectionMode = false, + isSelected = false, + onToggleSelect, + onEnterSelection }) => { const { goToAsset, currentProjectData, currentQuestData } = useAppNavigation(); @@ -67,20 +78,21 @@ export const BibleAssetListItem: React.FC = ({ // Check if asset is downloaded const isDownloaded = useItemDownloadStatus(asset, currentUser?.id); - const fetchManyTags = useTagStore((s) => s.fetchManyTags); - const [tags, setTags] = React.useState< - { id: string; key: string; value?: string }[] - >([]); + // 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]); + // 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( @@ -88,34 +100,34 @@ export const BibleAssetListItem: React.FC = ({ asset.id ); - // Tag modal state - const [isTagModalVisible, setIsTagModalVisible] = React.useState(false); + // 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 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); + // 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); + // // Use the tagService to assign tags to the asset + // await tagService.assignTagsToAssetLocal(asset.id, tagIds); - onUpdate?.(); + // 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); - } - }; + // 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( @@ -130,6 +142,12 @@ export const BibleAssetListItem: React.FC = ({ ); const handlePress = () => { + // If in selection mode, toggle selection instead of navigating + if (isSelectionMode) { + onToggleSelect?.(asset.id); + return; + } + if (!isPublished) return; layerStatus.setLayerStatus( LayerType.ASSET, @@ -156,6 +174,13 @@ export const BibleAssetListItem: React.FC = ({ }); }; + const handleLongPress = () => { + // Enter selection mode on long press + if (!isSelectionMode && onEnterSelection) { + onEnterSelection(asset.id); + } + }; + const handleDownloadToggle = () => { if (!currentUser?.id) return; @@ -163,16 +188,34 @@ export const BibleAssetListItem: React.FC = ({ downloadAsset({ userId: currentUser.id, download: !isDownloaded }); }; - const tag = tags.length > 0 ? tags[0] : null; + // Tags display - commented out + // const tag = tags.length > 0 ? tags[0] : null; + + // 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 ( - + @@ -196,7 +239,7 @@ export const BibleAssetListItem: React.FC = ({ )} )} - {dragHandle} + {selectionOrDragElement} {asset.source === 'local' && ( @@ -230,7 +273,8 @@ export const BibleAssetListItem: React.FC = ({ - + {/* Tags UI - commented out */} + {/* @@ -255,7 +299,7 @@ export const BibleAssetListItem: React.FC = ({ )} - + */} = ({ */} - setIsTagModalVisible(false)} onAssignTags={handleAssignTags} - /> + /> */} ); }; diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 8353f2811..76fc6c6d2 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -94,6 +94,9 @@ import { ScrollView as GHScrollView } from 'react-native-gesture-handler'; import Sortable from 'react-native-sortables'; import { BibleAssetListItem } from './BibleAssetListItem'; import RecordingViewSimplified from './recording/components/NewRecordingViewSimplified'; +import { SelectionControls } from './recording/components/SelectionControls'; +import { useSelectionMode } from './recording/hooks/useSelectionMode'; +// import RecordingViewSimplified from './recording/components/RecordingViewSimplified'; type Asset = typeof asset.$inferSelect; @@ -127,6 +130,157 @@ interface ListItemSeparator { type ListItem = ListItemAsset | ListItemSeparator; +// Manual separator type used for verse grouping +interface ManualSeparator { + from: number; + to: number; + key: string; + assetId?: string; +} + +// ============================================================================ +// 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, @@ -140,6 +294,15 @@ export default function BibleAssetsView() { 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 @@ -503,180 +666,53 @@ export default function BibleAssetsView() { } }); - const listItems = React.useMemo((): ListItem[] => { - // Separate assets with and without metadata - const assetsWithMeta = assets.filter( - (a) => a.metadata?.verse?.from != null - ); - const assetsWithoutMeta = assets.filter( - (a) => a.metadata?.verse?.from == null - ); + // ============================================================================ + // OPTIMIZED LIST BUILDING - Split into smaller memoized steps + // ============================================================================ - // Sort assets with metadata by verse.from - assetsWithMeta.sort((a, b) => { + // 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 + return [...filtered].sort((a, b) => { const aFrom = a.metadata?.verse?.from ?? 0; const bFrom = b.metadata?.verse?.from ?? 0; return aFrom - bFrom; }); + }, [assets]); - // First build the list with auto separators + assets - const result: ListItem[] = []; - let currentFrom: number | undefined; - let currentTo: number | undefined; - - // Process assets with metadata - for (const asset of assetsWithMeta) { - const from = asset.metadata?.verse?.from; - const to = asset.metadata?.verse?.to; - - // If different from current group, add separator - 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 - }); - } - - // Prepare unassigned block (append later) - 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 manual separators - // If separator has assetId, insert it right above that asset - // Otherwise, insert by verse order - const separatorsWithAssetId = manualSeparators.filter((sep) => sep.assetId); - const separatorsWithoutAssetId = manualSeparators.filter( - (sep) => !sep.assetId - ); - - // Insert separators with assetId right above their assets - for (const sep of separatorsWithAssetId) { - if (!sep.assetId) continue; + // Step 2: Get assets without metadata (only recomputes when assets change) + const assetsWithoutMeta = React.useMemo(() => { + return assets.filter((a) => a.metadata?.verse?.from == null); + }, [assets]); - // First try to find the asset in the result (assets with metadata) - const assetIndex = result.findIndex( - (item) => item.type === 'asset' && item.content.id === sep.assetId - ); + // Step 3: Split manual separators by type (only recomputes when separators change) + const separatorsWithAssetId = React.useMemo(() => { + return manualSeparators.filter((sep) => sep.assetId); + }, [manualSeparators]); - if (assetIndex !== -1) { - // Found in result, insert separator above it - const sepItem: ListItemSeparator = { - type: 'separator', - from: sep.from, - to: sep.to, - key: sep.key - }; - result.splice(assetIndex, 0, sepItem); - } else { - // Asset is in unassignedBlock (or not found yet) - // Insert separator BEFORE the unassignedBlock so assets with labels - // will appear above "No Verse Assigned" once they receive metadata - const sepItem: ListItemSeparator = { - type: 'separator', - from: sep.from, - to: sep.to, - key: sep.key - }; - // Insert at the end of result, before unassignedBlock - result.push(sepItem); - } - } + const sortedSeparatorsWithoutAssetId = React.useMemo(() => { + return manualSeparators + .filter((sep) => !sep.assetId) + .sort((a, b) => a.from - b.from); + }, [manualSeparators]); - // Insert separators without assetId by verse order - const sortedManualSeps = [...separatorsWithoutAssetId].sort( - (a, b) => a.from - b.from + // 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 ); - - for (const sep of sortedManualSeps) { - const sepItem: ListItemSeparator = { - type: 'separator', - from: sep.from, - to: sep.to, - key: sep.key - }; - - // Find the first separator with 'from' greater than this sep.from - 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); - } - - // Final assembly: result (with manual seps inserted) + unassigned block - const combined: ListItem[] = [...result, ...unassignedBlock]; - - // Build a set of manual separator ranges to check against - const manualSeparatorRanges = new Set(); - for (const sep of manualSeparators) { - const range = `${sep.from ?? 'none'}-${sep.to ?? 'none'}`; - manualSeparatorRanges.add(range); - } - - // Deduplicate separators with the same range to avoid duplicates - // Prefer manual separators over auto-generated ones - const seenSeparatorRanges = new Set(); - const deduped: ListItem[] = []; - const manualSeparatorKeys = new Set(manualSeparators.map((sep) => sep.key)); - - 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); - - // If we've seen this range before, skip duplicates - if (seenSeparatorRanges.has(sepRange)) { - // Always skip auto-generated separators if we've seen the range - // (either from a manual separator or another auto one) - if (!isManualSeparator) { - continue; - } - // If this is a manual separator and we already added one, skip - continue; - } - - // Skip auto-generated separators if there's a manual separator for this range - if (!isManualSeparator && hasManualSeparatorForRange) { - continue; - } - - seenSeparatorRanges.add(sepRange); - } - deduped.push(item); - } - - return deduped; - }, [assets, manualSeparators]); + }, [ + assetsWithMeta, + assetsWithoutMeta, + separatorsWithAssetId, + sortedSeparatorsWithoutAssetId, + manualSeparators + ]); // Auto-assign labels to assets when a separator is created with assetId React.useEffect(() => { @@ -1394,31 +1430,34 @@ export default function BibleAssetsView() { audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && currentlyPlayingAssetId === asset.id; - const dragHandle = !isPublished ? ( - - - - ) : null; + // Only show drag handle when NOT in selection mode + const dragHandle = + !isPublished && !isSelectionMode ? ( + + + + ) : null; // Check if there are available verses for this asset const assetRange = getRangeForAsset(asset.id); const hasAvailableVerses = assetRange.availableVerses.length > 0; + const isSelected = selectedAssetIds.has(asset.id); return ( {/* Add verse button - positioned above and to the right */} - {/* Only show if there are available verses */} - {!isPublished && hasAvailableVerses && ( + {/* Only show if there are available verses and NOT in selection mode */} + {!isPublished && hasAvailableVerses && !isSelectionMode && ( { const range = getRangeForAsset(asset.id); @@ -1448,6 +1487,11 @@ export default function BibleAssetsView() { onPlay={(assetId) => handlePlayAssetRef.current(assetId)} isPublished={isPublished} dragHandle={dragHandle} + // Selection mode only works when NOT published + isSelectionMode={!isPublished && isSelectionMode} + isSelected={!isPublished && isSelected} + onToggleSelect={!isPublished ? toggleSelect : undefined} + onEnterSelection={!isPublished ? enterSelection : undefined} /> ); @@ -1459,9 +1503,11 @@ export default function BibleAssetsView() { audioContext.currentAudioId, currentlyPlayingAssetId, handleAssetUpdate, - getRangeForAsset - // fixedItemsIndexesRef.current.length - //isPublished + getRangeForAsset, + isSelectionMode, + selectedAssetIds, + toggleSelect, + enterSelection ] ); @@ -2502,6 +2548,7 @@ export default function BibleAssetsView() { overDrag="vertical" onDragEnd={(params) => void _handleSorting(params)} customHandle + sortEnabled={!isSelectionMode} // Disable sorting in selection mode // autoScrollActivationOffset={75} // autoScrollSpeed={1} // autoScrollEnabled={true} @@ -2532,75 +2579,98 @@ export default function BibleAssetsView() { - + ) : ( + + )} )} - - - - {/* For anonymous users, only show info button */} - {currentUser ? ( - <> - {allowSettings && isOwner ? ( - setShowSettingsModal(true)} - /> - ) : !hasReported ? ( - setShowReportModal(true)} - /> - ) : null} - - ) : null} - {/* 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(); - } - }} - /> - - - - + {/* Hide SpeedDial in selection mode */} + {!isSelectionMode && ( + + + + {/* For anonymous users, only show info button */} + {currentUser ? ( + <> + {allowSettings && isOwner ? ( + setShowSettingsModal(true)} + /> + ) : !hasReported ? ( + setShowReportModal(true)} + /> + ) : null} + + ) : null} + {/* 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(); + } + }} + /> + + + + + )} {allowSettings && isOwner && ( Date: Sat, 10 Jan 2026 14:45:59 -0800 Subject: [PATCH 18/39] Modify Recording View to Bible Projects --- components/VerseSeparator.tsx | 24 +- database_services/assetService.ts | 44 +- views/new/BibleAssetListItem.tsx | 20 +- views/new/BibleAssetsView.tsx | 199 +- .../components/BibleRecordingView.tsx | 2349 +++++++++++++++++ 5 files changed, 2585 insertions(+), 51 deletions(-) create mode 100644 views/new/recording/components/BibleRecordingView.tsx diff --git a/components/VerseSeparator.tsx b/components/VerseSeparator.tsx index cda10a951..645603876 100644 --- a/components/VerseSeparator.tsx +++ b/components/VerseSeparator.tsx @@ -1,4 +1,8 @@ -import { AlertCircleIcon, MoveVerticalIcon } from 'lucide-react-native'; +import { + AlertCircleIcon, + MoveVerticalIcon, + PencilIcon +} from 'lucide-react-native'; import React from 'react'; import { Pressable, View } from 'react-native'; import { Icon } from './ui/icon'; @@ -86,6 +90,16 @@ export function VerseSeparator({ > {getText()} + {/* Edit icon - only shown when editable and onPress is provided */} + {editable && onPress && ( + + + + )} ); @@ -94,14 +108,8 @@ export function VerseSeparator({ {DragHandleComponent && editable ? ( - {onPress ? ( - {pillContent} - ) : ( - pillContent - )} + {pillContent} - ) : onPress && editable ? ( - {pillContent} ) : ( pillContent )} diff --git a/database_services/assetService.ts b/database_services/assetService.ts index a7a845cac..3c05424a5 100644 --- a/database_services/assetService.ts +++ b/database_services/assetService.ts @@ -198,11 +198,20 @@ export async function updateAssetMetadata( } /** - * Batch update asset metadata for multiple assets - * @param updates - Array of { assetId, metadata } objects + * 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: { assetId: string; metadata: AssetMetadata | null }[] + updates: AssetUpdatePayload[] ): Promise { if (updates.length === 0) return; @@ -229,18 +238,33 @@ export async function batchUpdateAssetMetadata( } // Update each local asset - for (const { assetId, metadata } of localUpdates) { - const metadataStr = metadata ? JSON.stringify(metadata) : null; - console.log(metadataStr); + 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({ metadata: metadataStr }) - .where(eq(assetLocalTable.id, assetId)); + .set(setPayload) + .where(eq(assetLocalTable.id, update.assetId)); } - console.log(`✅ Updated metadata for ${localUpdates.length} assets`); + console.log(`✅ Updated ${localUpdates.length} assets`); } catch (error) { - console.error('Failed to batch update asset metadata:', error); + console.error('Failed to batch update assets:', error); throw error; } } diff --git a/views/new/BibleAssetListItem.tsx b/views/new/BibleAssetListItem.tsx index 145d9592d..f2e1dd978 100644 --- a/views/new/BibleAssetListItem.tsx +++ b/views/new/BibleAssetListItem.tsx @@ -50,11 +50,14 @@ export interface BibleAssetListItemProps { attachmentState?: AttachmentRecord; isCurrentlyPlaying?: boolean; dragHandle?: React.ReactNode; - // Selection mode props + // 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; } export const BibleAssetListItem: React.FC = ({ @@ -69,7 +72,9 @@ export const BibleAssetListItem: React.FC = ({ isSelectionMode = false, isSelected = false, onToggleSelect, - onEnterSelection + onEnterSelection, + isSelectedForRecording = false, + onSelectForRecording }) => { const { goToAsset, currentProjectData, currentQuestData } = useAppNavigation(); @@ -148,7 +153,12 @@ export const BibleAssetListItem: React.FC = ({ return; } - if (!isPublished) return; + // If not published, select for recording (toggle) + if (!isPublished) { + onSelectForRecording?.(asset.id); + return; + } + layerStatus.setLayerStatus( LayerType.ASSET, { @@ -215,7 +225,9 @@ export const BibleAssetListItem: React.FC = ({ !allowEditing ? 'opacity-50' : '' } ${invisible ? 'opacity-30' : ''} ${ isCurrentlyPlaying ? 'border-2 border-primary bg-primary/5' : '' - } ${isSelected ? 'border-2 border-primary bg-primary/10' : ''} p-3`} + } ${isSelected ? 'border-2 border-primary bg-primary/10' : ''} ${ + isSelectedForRecording ? 'border-2 border-primary bg-primary/15' : '' + } p-3`} > diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 76fc6c6d2..6600fdeb9 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -76,6 +76,7 @@ import { 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 } from '@/database_services/assetService'; import { AppConfig } from '@/db/supabase/AppConfig'; import { useAssetsByQuest } from '@/hooks/db/useAssets'; @@ -93,7 +94,7 @@ import { eq } from 'drizzle-orm'; import { ScrollView as GHScrollView } from 'react-native-gesture-handler'; import Sortable from 'react-native-sortables'; import { BibleAssetListItem } from './BibleAssetListItem'; -import RecordingViewSimplified from './recording/components/NewRecordingViewSimplified'; +import BibleRecordingView from './recording/components/BibleRecordingView'; import { SelectionControls } from './recording/components/SelectionControls'; import { useSelectionMode } from './recording/hooks/useSelectionMode'; // import RecordingViewSimplified from './recording/components/RecordingViewSimplified'; @@ -572,6 +573,15 @@ export default function BibleAssetsView() { const [showRecording, setShowRecording] = React.useState(false); + // Track selected asset for recording insertion + // When an asset is selected, new recordings will be inserted after it + const [selectedForRecording, setSelectedForRecording] = React.useState<{ + assetId: string; + orderIndex: number; + metadata: AssetMetadata | null; + verseName: string; // e.g., "1:5" or "1:5-7" + } | null>(null); + const { membership } = useUserPermissions( currentProjectId || '', 'open_project', @@ -673,17 +683,25 @@ export default function BibleAssetsView() { // 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 + // 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; - return aFrom - bFrom; + 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); + return assets + .filter((a) => a.metadata?.verse?.from == null) + .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)); }, [assets]); // Step 3: Split manual separators by type (only recomputes when separators change) @@ -714,6 +732,50 @@ export default function BibleAssetsView() { manualSeparators ]); + // Handler for selecting/deselecting an asset for recording insertion + const handleSelectForRecording = React.useCallback( + (assetId: string) => { + // Toggle: if same asset clicked, deselect + if (selectedForRecording?.assetId === assetId) { + setSelectedForRecording(null); + return; + } + + // Find the asset in our list + const assetItem = listItems.find( + (item) => item.type === 'asset' && item.content.id === assetId + ); + + if (!assetItem || assetItem.type !== 'asset') { + console.warn('Asset not found:', assetId); + return; + } + + const asset = assetItem.content; + 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({ + assetId, + orderIndex, + metadata, + verseName + }); + }, + [selectedForRecording?.assetId, listItems] + ); + // Auto-assign labels to assets when a separator is created with assetId React.useEffect(() => { const processNewSeparators = async () => { @@ -744,9 +806,9 @@ export default function BibleAssetsView() { // Check if asset is in unassigned (no metadata) const isUnassigned = !targetAsset.metadata?.verse?.from; - // Find all assets to update - const assetsToUpdate: { assetId: string; metadata: AssetMetadata }[] = - []; + // 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 @@ -778,8 +840,11 @@ export default function BibleAssetsView() { break; } - // If it's an asset, add it to the update list + // If it's an asset, add it to the update list with order_index if (item.type === 'asset') { + const newOrderIndex = separator.from * 1000 + sequentialInGroup; + sequentialInGroup++; + assetsToUpdate.push({ assetId: item.content.id, metadata: { @@ -787,8 +852,13 @@ export default function BibleAssetsView() { from: separator.from, to: separator.to ?? separator.from } - } + }, + order_index: newOrderIndex }); + + console.log( + `📝 "${item.content.name}" | verse: ${separator.from}-${separator.to ?? separator.from} | order_index: ${newOrderIndex}` + ); } } } else { @@ -818,8 +888,11 @@ export default function BibleAssetsView() { break; } - // If it's an asset, add it to the update list + // If it's an asset, add it to the update list with order_index if (item.type === 'asset') { + const newOrderIndex = separator.from * 1000 + sequentialInGroup; + sequentialInGroup++; + console.log( `➕ Adding asset ${item.content.id} to update list (index ${i})` ); @@ -830,8 +903,13 @@ export default function BibleAssetsView() { from: separator.from, to: separator.to ?? separator.from } - } + }, + order_index: newOrderIndex }); + + console.log( + `📝 "${item.content.name}" | verse: ${separator.from}-${separator.to ?? separator.from} | order_index: ${newOrderIndex}` + ); } } } @@ -934,7 +1012,8 @@ export default function BibleAssetsView() { } // Find all assets below this separator until we hit another separator - const assetsToUpdate: { assetId: string; metadata: AssetMetadata }[] = []; + 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]; @@ -945,8 +1024,11 @@ export default function BibleAssetsView() { break; } - // If it's an asset, add it to the update list + // If it's an asset, add it to the update list with order_index if (item.type === 'asset') { + const newOrderIndex = newFrom * 1000 + sequentialInGroup; + sequentialInGroup++; + assetsToUpdate.push({ assetId: item.content.id, metadata: { @@ -954,8 +1036,13 @@ export default function BibleAssetsView() { from: newFrom, to: newTo } - } + }, + order_index: newOrderIndex }); + + console.log( + `📝 "${item.content.name}" | verse: ${newFrom}-${newTo} | order_index: ${newOrderIndex}` + ); } } @@ -964,7 +1051,7 @@ export default function BibleAssetsView() { try { await batchUpdateAssetMetadata(assetsToUpdate); console.log( - `✅ Updated ${assetsToUpdate.length} asset(s) below separator with new verse range ${newFrom}-${newTo}` + `✅ Updated ${assetsToUpdate.length} asset(s) below separator with new verse range ${newFrom}-${newTo} (with order_index)` ); // Invalidate queries to refresh the UI @@ -1492,6 +1579,13 @@ export default function BibleAssetsView() { isSelected={!isPublished && isSelected} onToggleSelect={!isPublished ? toggleSelect : undefined} onEnterSelection={!isPublished ? enterSelection : undefined} + // Recording insertion point selection + isSelectedForRecording={ + !isPublished && selectedForRecording?.assetId === asset.id + } + onSelectForRecording={ + !isPublished ? handleSelectForRecording : undefined + } /> ); @@ -1507,7 +1601,9 @@ export default function BibleAssetsView() { isSelectionMode, selectedAssetIds, toggleSelect, - enterSelection + enterSelection, + selectedForRecording?.assetId, + handleSelectForRecording ] ); @@ -2218,13 +2314,17 @@ export default function BibleAssetsView() { if (showRecording) { // Pass existing assets as initial data for instant rendering return ( - { setShowRecording(false); + setSelectedForRecording(null); // Clear selection when exiting // Refetch to show newly recorded assets void refetch(); }} initialAssets={assets} + label={selectedForRecording?.verseName} + initialOrderIndex={selectedForRecording?.orderIndex} + verse={selectedForRecording?.metadata?.verse} /> ); } @@ -2236,6 +2336,17 @@ export default function BibleAssetsView() { // Note: queriedProjectData doesn't include name, so we only use currentProjectData const projectName = currentProjectData?.name || ''; + // ============================================================================ + // ORDER_INDEX CALCULATION + // Formula: order_index = from * 1000 + sequential + // - 'from' is the verse number from the separator (999 for unassigned) + // - 'sequential' is the position within that verse group (1-based, starts at 1) + // Example: verse 7, first asset → 7001, second → 7002, etc. + // This ensures assets are ordered by verse first, then by position within verse + // ============================================================================ + + const UNASSIGNED_VERSE_BASE = 999; // High value so unassigned assets appear at the end + async function _handleSorting(params: { indexToKey: string[]; data: ListItem[]; @@ -2244,10 +2355,11 @@ export default function BibleAssetsView() { // 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 + // Iterate through the new order and update asset metadata + order_index // based on the preceding separator let currentSeparator: ListItemSeparator | null = null; - const updates: { assetId: string; metadata: AssetMetadata | 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); @@ -2255,7 +2367,13 @@ export default function BibleAssetsView() { 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 + const verseBase = currentSeparator?.from ?? UNASSIGNED_VERSE_BASE; + const newOrderIndex = verseBase * 1000 + sequentialInGroup; + sequentialInGroup++; + // Determine the metadata based on the current separator const newMetadata: AssetMetadata | null = currentSeparator?.from ? { @@ -2266,16 +2384,35 @@ export default function BibleAssetsView() { } : null; - // Check if metadata has changed + // Check if metadata or order_index has changed const currentMetadata = item.content.metadata; - const hasChanged = + const currentOrderIndex = item.content.order_index; + + const metadataChanged = JSON.stringify(newMetadata) !== JSON.stringify(currentMetadata); + const orderIndexChanged = newOrderIndex !== currentOrderIndex; - if (hasChanged) { - updates.push({ - assetId: item.content.id, - metadata: newMetadata - }); + 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; + } + + // Log asset change details + console.log( + `📝 "${item.content.name}" (${item.content.id.slice(0, 8)}...) | ` + + `metadata: ${metadataChanged ? `${JSON.stringify(currentMetadata)} → ${JSON.stringify(newMetadata)}` : '(unchanged)'} | ` + + `order_index: ${orderIndexChanged ? `${currentOrderIndex} → ${newOrderIndex}` : '(unchanged)'}` + ); + + updates.push(update); } } } @@ -2284,13 +2421,15 @@ export default function BibleAssetsView() { if (updates.length > 0) { try { await batchUpdateAssetMetadata(updates); - console.log(`✅ Updated ${updates.length} asset(s) metadata`); + console.log( + `✅ Updated ${updates.length} asset(s) (metadata + order_index)` + ); // 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 asset metadata:', err); + console.error('Failed to update assets:', err); } } } @@ -2613,7 +2752,9 @@ export default function BibleAssetsView() { className="text-destructive-foreground" /> - {t('doRecord')} + {selectedForRecording?.verseName + ? `${t('doRecord')} ${bookChapterLabelRef.current}:${selectedForRecording.verseName}` + : t('doRecord')} )} diff --git a/views/new/recording/components/BibleRecordingView.tsx b/views/new/recording/components/BibleRecordingView.tsx new file mode 100644 index 000000000..48089cc47 --- /dev/null +++ b/views/new/recording/components/BibleRecordingView.tsx @@ -0,0 +1,2349 @@ +import type { ArrayInsertionWheelHandle } from '@/components/ArrayInsertionWheel'; +import ArrayInsertionWheel from '@/components/ArrayInsertionWheel'; +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 type { LegendListRef } from '@legendapp/list'; +import { LegendList } from '@legendapp/list'; +import { toCompilableQuery } from '@powersync/drizzle-driver'; +import { useQueryClient } from '@tanstack/react-query'; +import { and, asc, eq } from 'drizzle-orm'; +import { Audio } from 'expo-av'; +import { ArrowLeft, PauseIcon, PlayIcon } from 'lucide-react-native'; +import React 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 { getNextOrderIndex, 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 { + id: string; + name: string; + created_at: string; + order_index: number; + source: 'local' | 'synced' | 'cloud'; + segmentCount: number; + duration?: number; // Total duration in milliseconds +} + +// Default order_index for unassigned verses (999 * 1000 + 1 = 999001) +// Sequence starts at 1, not 0 (e.g., verse 7 → 7001, 7002...) +const DEFAULT_ORDER_INDEX = 999001; + +// Verse metadata type +interface VerseRange { + from: number; + to: number; +} + +interface BibleRecordingViewProps { + onBack: () => 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; +} + +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 +}: BibleRecordingViewProps) => { + 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 + const currentRecordingOrderRef = React.useRef(0); + 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()); + + // 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; + + // Selection mode for batch operations (merge, delete) + const { + isSelectionMode, + selectedAssetIds, + enterSelection, + toggleSelect, + cancelSelection + } = 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 ASSETS: Only show assets created during this recording session + // When user exits and returns, the list starts empty + // Assets are still saved to database, but we don't load existing ones + const [sessionAssets, setSessionAssets] = React.useState([]); + + // Helper to add a new asset to the session list + const addSessionAsset = React.useCallback( + (newAsset: { id: string; name: string; order_index: number }) => { + const uiAsset: UIAsset = { + id: newAsset.id, + name: newAsset.name, + created_at: new Date().toISOString(), + order_index: newAsset.order_index, + source: 'local', + segmentCount: 1, + duration: undefined + }; + + setSessionAssets((prev) => { + // Insert at correct position based on order_index + const newList = [...prev, uiAsset]; + return newList.sort((a, b) => a.order_index - b.order_index); + }); + + debugLog( + `➕ Added session asset: "${newAsset.name}" (order_index: ${newAsset.order_index})` + ); + }, + [] + ); + + // Use session assets instead of database query + const rawAssets = sessionAssets; + + // 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'; + }; + // 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 { + 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 + }; + }); + + // 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]); + + // 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 asset list that only updates when content actually changes + // We intentionally use assetContentKey instead of assets to prevent re-renders + // when assets array reference changes but content is identical + const assetsForLegendList = React.useMemo(() => assets, [assets]); + + // Clamp insertion index when asset count changes + React.useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (USE_INSERTION_WHEEL) { + const maxIndex = assets.length; // Can insert at 0..N (after last item) + if (insertionIndex > maxIndex) { + debugLog( + `📍 Clamping insertion index from ${insertionIndex} to ${maxIndex}` + ); + setInsertionIndex(maxIndex); + } + } + }, [assets.length, insertionIndex]); + + // Ref for LegendList to enable scrolling + const listRef = React.useRef(null); + + // Track asset count to detect new insertions + const previousAssetCountRef = React.useRef(assets.length); + + // Auto-scroll behavior differs between list and wheel + React.useEffect(() => { + const currentCount = assets.length; + const previousCount = previousAssetCountRef.current; + + // Only scroll if a new asset was added (count increased) + if (currentCount > previousCount && currentCount > 0) { + debugLog('📜 Auto-scrolling to new asset'); + + // Small delay to ensure the new item is rendered before scrolling + const timeoutId = setTimeout(() => { + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (USE_INSERTION_WHEEL) { + // For wheel: scroll to the newly inserted item's position + // After insertion at index N, the new item is at position N + const newItemIndex = Math.min(insertionIndex, currentCount - 1); + wheelRef.current?.scrollToInsertionIndex(newItemIndex + 1, true); + } else { + // For list: scroll to end + listRef.current?.scrollToEnd({ animated: true }); + } + } catch (error) { + console.error('Failed to scroll:', error); + } + timeoutIdsRef.current.delete(timeoutId); + }, 100); + timeoutIdsRef.current.add(timeoutId); + } + + previousAssetCountRef.current = currentCount; + }, [assets.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 + // ============================================================================ + + // Store insertion index in ref to prevent stale closure issues + const insertionIndexRef = React.useRef(insertionIndex); + React.useEffect(() => { + insertionIndexRef.current = insertionIndex; + }, [insertionIndex]); + + // Initialize VAD counter when VAD mode activates + React.useEffect(() => { + if (isVADLocked && vadCounterRef.current === null) { + // CRITICAL: Use ref to get the LATEST insertionIndex value + // This prevents issues when fullscreen overlay blocks the wheel and causes + // insertionIndex state updates to be delayed or missed + const currentInsertionIndex = insertionIndexRef.current; + const currentAssets = assets; + + debugLog( + `🎯 VAD initializing | insertionIndex (ref): ${currentInsertionIndex} | insertionIndex (state): ${insertionIndex} | assets.length: ${currentAssets.length}` + ); + + void (async () => { + let targetOrder: number; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (USE_INSERTION_WHEEL) { + // Respect insertion wheel position (same logic as manual recordings) + // insertionIndex is the boundary BEFORE an item + // When at bottom (insertionIndex === assets.length), append to end + // When in middle, insert after the currently viewed item + + if (currentInsertionIndex >= currentAssets.length) { + // At or past the end - append + targetOrder = + currentAssets.length > 0 + ? (currentAssets[currentAssets.length - 1]?.order_index ?? + currentAssets.length - 1) + 1 + : 0; + debugLog( + `🎯 VAD: At bottom, appending with order_index: ${targetOrder}` + ); + } else { + // In the middle - insert after current item + const actualInsertionIndex = currentInsertionIndex + 1; + if (actualInsertionIndex < currentAssets.length) { + targetOrder = + currentAssets[actualInsertionIndex]?.order_index ?? + actualInsertionIndex; + } else { + targetOrder = + currentAssets.length > 0 + ? (currentAssets[currentAssets.length - 1]?.order_index ?? + currentAssets.length - 1) + 1 + : 0; + } + debugLog( + `🎯 VAD: In middle at visual index ${currentInsertionIndex}, inserting at order_index: ${targetOrder}` + ); + } + } else { + // Legacy: append to end + targetOrder = await getNextOrderIndex(currentQuestId!); + debugLog(`🎯 VAD counter initialized to end: ${targetOrder}`); + } + + vadCounterRef.current = targetOrder; + })(); + } else if (!isVADLocked) { + vadCounterRef.current = null; + } + // IMPORTANT: Only depend on isVADLocked and currentQuestId + // insertionIndex is read from ref to avoid stale closure issues + // assets is captured from closure (intentional - we want the state at activation time) + }, [isVADLocked, currentQuestId, assets, insertionIndex]); + + // Manual recording handlers + const handleRecordingStart = React.useCallback(() => { + if (isRecording) return; + debugLog('🎬 Manual recording start'); + setIsRecording(true); + + // Set order index for manual recording + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (USE_INSERTION_WHEEL) { + // IMPORTANT: insertionIndex is the boundary BEFORE an item + // When user sees item 0 centered, insertionIndex = 0 (before item 0) + // But they want to insert AFTER the item they're viewing + // So we use insertionIndex + 1 for the actual insertion position + const actualInsertionIndex = insertionIndex + 1; + + const targetOrder = + actualInsertionIndex < assets.length + ? (assets[actualInsertionIndex]?.order_index ?? actualInsertionIndex) + : (assets[assets.length - 1]?.order_index ?? assets.length - 1) + 1; + currentRecordingOrderRef.current = targetOrder; + debugLog( + `🎯 Recording will insert AFTER item at visual index ${insertionIndex} (boundary ${actualInsertionIndex}) with order_index ${targetOrder}` + ); + } else { + // Legacy: append to end + const targetOrder = + assets.length > 0 + ? (assets[assets.length - 1]?.order_index ?? 0) + 1 + : 0; + currentRecordingOrderRef.current = targetOrder; + } + }, [isRecording, assets, insertionIndex]); + + 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[]) => { + const targetOrder = currentRecordingOrderRef.current; + + try { + debugLog('💾 Saving recording | order_index:', targetOrder); + + // Validate required data + if ( + !currentProjectId || + !currentQuestId || + !currentProject || + !currentUser + ) { + console.error('❌ Missing required data'); + return; + } + + // Generate name immediately and reserve it to prevent duplicates + // In VAD mode: Use the VAD counter which is already incremented per segment + // In manual mode: Use total count (existing + pending) for simple sequential naming + const nextNumber = isVADLocked + ? targetOrder + 1 // VAD: use order_index + 1 for naming (order is 0-based, names are 1-based) + : assets.length + pendingAssetNamesRef.current.size + 1; + const assetName = String(nextNumber).padStart(3, '0'); + pendingAssetNamesRef.current.add(assetName); + debugLog( + `🏷️ Reserved name: ${assetName} (${isVADLocked ? 'VAD mode' : 'manual mode'}) | order_index: ${targetOrder}, asset count: ${assets.length}, pending: ${pendingAssetNamesRef.current.size}` + ); + + // 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'); + } + const newAssetId = await saveRecording({ + questId: currentQuestId, + projectId: currentProjectId, + targetLanguoidId: targetLanguoidId, + userId: currentUser.id, + orderIndex: targetOrder, + audioUri: localUri, + assetName: assetName // Pass the reserved name + }); + + // Add to session assets list (UI only - not loaded from DB) + addSessionAsset({ + id: newAssetId, + name: assetName, + order_index: targetOrder + }); + + // 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 refresh asset list in parent view (not here) + if (!isVADLocked) { + 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, + isVADLocked, + assets, + targetLanguoidId, + addSessionAsset + ] + ); + + // VAD segment handlers + const handleVADSegmentStart = React.useCallback(() => { + if (vadCounterRef.current === null) { + console.error('❌ VAD counter not initialized!'); + return; + } + + const targetOrder = vadCounterRef.current; + debugLog('🎬 VAD: Segment starting | order_index:', targetOrder); + + currentRecordingOrderRef.current = targetOrder; + vadCounterRef.current = targetOrder + 1; // Increment for next segment + }, []); + + 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 + setSessionAssets((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) + setSessionAssets((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)); + setSessionAssets((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)); + setSessionAssets((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]); + + // ============================================================================ + // 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 + 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 + ]); + + // Memoized render function for LegendList + // OPTIMIZED: No audioContext.position dependency - progress now uses SharedValues! + // This eliminates 10 re-renders/second during audio playback + const renderAssetItem = React.useCallback( + ({ item, index }: { item: UIAsset; index: number }) => { + // 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); + const canMergeDown = + index < assets.length - 1 && assets[index + 1]?.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; + + return ( + { + if (isSelectionMode) { + stableToggleSelect(item.id); + } else { + void stableHandlePlayAsset(item.id); + } + }} + onLongPress={() => { + stableEnterSelection(item.id); + }} + onPlay={() => { + void stableHandlePlayAsset(item.id); + }} + onDelete={stableHandleDeleteLocalAsset} + onMerge={stableHandleMergeDownLocal} + onRename={stableHandleRenameAsset} + /> + ); + }, + [ + audioContext.isPlaying, + audioContext.currentAudioId, + currentlyPlayingAssetId, + // audioContext.position REMOVED - uses SharedValues now! + // audioContext.duration REMOVED - not needed for render + selectedAssetIds, + isSelectionMode, + assets, + stableHandlePlayAsset, + stableToggleSelect, + stableEnterSelection, + stableHandleDeleteLocalAsset, + stableHandleMergeDownLocal, + stableHandleRenameAsset + ] + ); + + // Memoized children for ArrayInsertionWheel + // OPTIMIZED: No audioContext.position/duration dependencies - progress now uses SharedValues! + // This eliminates re-creating all children 10+ times per second during audio playback + const wheelChildren = React.useMemo(() => { + return assetsForLegendList.map((item, index) => { + // 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); + const canMergeDown = + index < assetsForLegendList.length - 1 && + assetsForLegendList[index + 1]?.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; + + return ( + { + if (isSelectionMode) { + stableToggleSelect(item.id); + } else { + void stableHandlePlayAsset(item.id); + } + }} + onLongPress={() => { + stableEnterSelection(item.id); + }} + onPlay={() => { + void stableHandlePlayAsset(item.id); + }} + onDelete={stableHandleDeleteLocalAsset} + onMerge={stableHandleMergeDownLocal} + onRename={stableHandleRenameAsset} + /> + ); + }); + }, [ + assetsForLegendList, + audioContext.isPlaying, + audioContext.currentAudioId, + currentlyPlayingAssetId, + // assetProgressSharedMap REMOVED - it's a ref, accessed directly in render + // audioContext.position REMOVED - uses SharedValues now! + // audioContext.duration REMOVED - not needed for render + selectedAssetIds, + isSelectionMode, + stableHandlePlayAsset, + stableToggleSelect, + stableEnterSelection, + 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'; + + return ( + + {/* Full-screen VAD overlay - takes over entire screen */} + {showFullScreenOverlay && ( + { + // Cancel VAD mode + setIsVADLocked(false); + }} + /> + )} + + {/* Header */} + + + + + {t('doRecord')} + + + {t('assets')} ({assets.length}) + + + {assets.length > 0 && enablePlayAll && ( + + )} + + + {/* Scrollable list area - full height with padding for controls */} + + {assets.length === 0 && ( + + + No assets yet. Start recording to create your first asset. + + + )} + + {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */} + {USE_INSERTION_WHEEL ? ( + // ArrayInsertionWheel mode - always show wheel, even when empty + + {wheelChildren} + + ) : ( + // LegendList mode (legacy) + assetsForLegendList.length > 0 && ( + + ) + )} + + + {/* 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; From 8520915da7292b7d992f8d241637ef33f071eef5 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Sun, 11 Jan 2026 18:21:30 -0800 Subject: [PATCH 19/39] Enhancing ordering assets --- views/new/BibleAssetsView.tsx | 186 ++++++++-- views/new/recording/components/AssetCard.tsx | 4 +- .../components/BibleRecordingView.tsx | 329 ++++++++++++------ .../recording/services/recordingService.ts | 15 +- 4 files changed, 387 insertions(+), 147 deletions(-) diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 6600fdeb9..c1f66acb3 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -29,7 +29,7 @@ import { useLocalStore } from '@/store/localStore'; import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; import RNAlert from '@blazejkustra/react-native-alert'; import { - BookmarkIcon, + BookmarkPlusIcon, CheckCheck, CloudUpload, FlagIcon, @@ -38,7 +38,6 @@ import { LockIcon, MicIcon, PauseIcon, - PencilIcon, PlayIcon, RefreshCwIcon, SearchIcon, @@ -90,7 +89,7 @@ import { offloadQuest } from '@/utils/questOffloadUtils'; import { getThemeColor } from '@/utils/styleUtils'; import { toCompilableQuery } from '@powersync/drizzle-driver'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { eq } from 'drizzle-orm'; +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'; @@ -704,6 +703,18 @@ export default function BibleAssetsView() { .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); @@ -842,7 +853,8 @@ export default function BibleAssetsView() { // If it's an asset, add it to the update list with order_index if (item.type === 'asset') { - const newOrderIndex = separator.from * 1000 + sequentialInGroup; + const newOrderIndex = + (separator.from * 1000 + sequentialInGroup) * 1000; sequentialInGroup++; assetsToUpdate.push({ @@ -890,7 +902,8 @@ export default function BibleAssetsView() { // If it's an asset, add it to the update list with order_index if (item.type === 'asset') { - const newOrderIndex = separator.from * 1000 + sequentialInGroup; + const newOrderIndex = + (separator.from * 1000 + sequentialInGroup) * 1000; sequentialInGroup++; console.log( @@ -1026,7 +1039,7 @@ export default function BibleAssetsView() { // If it's an asset, add it to the update list with order_index if (item.type === 'asset') { - const newOrderIndex = newFrom * 1000 + sequentialInGroup; + const newOrderIndex = (newFrom * 1000 + sequentialInGroup) * 1000; sequentialInGroup++; assetsToUpdate.push({ @@ -1282,6 +1295,107 @@ export default function BibleAssetsView() { }); }, [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; + + console.log( + `🔄 Normalizing order_index for ${verses.length} verse(s): [${verses.join(', ')}]` + ); + + 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) { + console.log(` ⏭️ Verse ${verse}: no assets found, skipping`); + 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 + }); + + console.log( + ` 📝 "${asset.name}" | ${asset.order_index} → ${newOrderIndex}` + ); + } + } + + if (hasChanges && updates.length > 0) { + await batchUpdateAssetMetadata(updates); + console.log( + ` ✅ Verse ${verse}: normalized ${updates.length} of ${assetsInVerse.length} asset(s)` + ); + } else { + console.log( + ` ⏭️ Verse ${verse}: ${assetsInVerse.length} asset(s) already normalized` + ); + } + } catch (error) { + console.error(` ❌ Failed to normalize verse ${verse}:`, error); + } + } + + console.log(`🔄 Normalization complete`); + }, + [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( @@ -1558,7 +1672,7 @@ export default function BibleAssetsView() { className="absolute -top-2 right-4 z-[999] rounded-full bg-primary/50 p-1.5 shadow-sm active:bg-primary/90" > @@ -2312,18 +2426,32 @@ export default function BibleAssetsView() { // 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 ( { + onBack={async (recordedVerses) => { setShowRecording(false); setSelectedForRecording(null); // Clear selection when exiting - // Refetch to show newly recorded assets + + // 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={selectedForRecording?.orderIndex} + initialOrderIndex={recordingOrderIndex} verse={selectedForRecording?.metadata?.verse} /> ); @@ -2338,10 +2466,11 @@ export default function BibleAssetsView() { // ============================================================================ // ORDER_INDEX CALCULATION - // Formula: order_index = from * 1000 + sequential + // Formula: order_index = (from * 1000 + sequential) * 1000 // - 'from' is the verse number from the separator (999 for unassigned) // - 'sequential' is the position within that verse group (1-based, starts at 1) - // Example: verse 7, first asset → 7001, second → 7002, etc. + // - Final value is multiplied by 1000 to leave space for future insertions + // Example: verse 7, first asset → 7001000, second → 7002000, etc. // This ensures assets are ordered by verse first, then by position within verse // ============================================================================ @@ -2369,9 +2498,9 @@ export default function BibleAssetsView() { currentSeparator = item; sequentialInGroup = 1; // Reset counter for new group (starts at 1) } else if (item.type === 'asset') { - // Calculate order_index: from * 1000 + sequential + // Calculate order_index: (from * 1000 + sequential) * 1000 const verseBase = currentSeparator?.from ?? UNASSIGNED_VERSE_BASE; - const newOrderIndex = verseBase * 1000 + sequentialInGroup; + const newOrderIndex = (verseBase * 1000 + sequentialInGroup) * 1000; sequentialInGroup++; // Determine the metadata based on the current separator @@ -2437,11 +2566,22 @@ export default function BibleAssetsView() { return ( - - - {/* Title */} + {/* 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 */} + )} - - {isPublished ? ( // Only show cloud-check icon if user is creator, member, or owner canSeePublishedBadge ? ( @@ -2597,17 +2735,9 @@ export default function BibleAssetsView() { getAvailableVerses().length === 0 } > - + )} - {currentQuestId && currentProjectId && ( void; + // 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") @@ -91,6 +95,13 @@ const BibleRecordingView = ({ initialOrderIndex: _initialOrderIndex = DEFAULT_ORDER_INDEX, // TODO: Use for order_index calculation verse: _verse // TODO: Use for verse tracking and metadata }: BibleRecordingViewProps) => { + // Log props on mount + React.useEffect(() => { + console.log( + `📥 BibleRecordingView props | initialOrderIndex: ${_initialOrderIndex} | label: "${_label}" | verse: ${_verse ? `${_verse.from}-${_verse.to}` : 'null'}` + ); + }, [_initialOrderIndex, _label, _verse]); + const queryClient = useQueryClient(); const { t } = useLocalization(); const navigation = useCurrentNavigation(); @@ -164,6 +175,60 @@ const BibleRecordingView = ({ // 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()); + + // 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 @@ -255,28 +320,54 @@ const BibleRecordingView = ({ // Assets are still saved to database, but we don't load existing ones const [sessionAssets, setSessionAssets] = React.useState([]); + // 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 }) => { - const uiAsset: UIAsset = { - id: newAsset.id, - name: newAsset.name, - created_at: new Date().toISOString(), - order_index: newAsset.order_index, - source: 'local', - segmentCount: 1, - duration: undefined - }; + const targetOrderIndex = newAsset.order_index; setSessionAssets((prev) => { - // Insert at correct position based on order_index - const newList = [...prev, uiAsset]; - return newList.sort((a, b) => a.order_index - b.order_index); - }); + // 1. Shift existing assets with order_index >= targetOrderIndex + // This mirrors the logic in recordingService.ts + const shifted = prev.map((asset) => { + if (asset.order_index >= targetOrderIndex) { + console.log( + `📊 UI Shift: "${asset.name}" ${asset.order_index} → ${asset.order_index + 1}` + ); + return { ...asset, order_index: asset.order_index + 1 }; + } + return asset; + }); - debugLog( - `➕ Added session asset: "${newAsset.name}" (order_index: ${newAsset.order_index})` - ); + // 2. Create new asset with the target order_index + const uiAsset: UIAsset = { + id: newAsset.id, + name: newAsset.name, + created_at: new Date().toISOString(), + order_index: targetOrderIndex, + source: 'local', + segmentCount: 1, + duration: undefined + }; + + console.log( + `➕ Adding "${newAsset.name}" with order_index: ${targetOrderIndex}` + ); + + // 3. Add new asset and sort by order_index + const newList = [...shifted, uiAsset]; + return newList.sort((a, b) => + a.order_index === b.order_index + ? a.created_at.localeCompare(b.created_at) + : a.order_index > b.order_index + ? 1 + : -1 + ); + }); }, [] ); @@ -1074,72 +1165,40 @@ const BibleRecordingView = ({ 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); + // Initialize VAD counter when VAD mode activates React.useEffect(() => { if (isVADLocked && vadCounterRef.current === null) { - // CRITICAL: Use ref to get the LATEST insertionIndex value - // This prevents issues when fullscreen overlay blocks the wheel and causes - // insertionIndex state updates to be delayed or missed - const currentInsertionIndex = insertionIndexRef.current; - const currentAssets = assets; - - debugLog( - `🎯 VAD initializing | insertionIndex (ref): ${currentInsertionIndex} | insertionIndex (state): ${insertionIndex} | assets.length: ${currentAssets.length}` - ); - - void (async () => { - let targetOrder: number; - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (USE_INSERTION_WHEEL) { - // Respect insertion wheel position (same logic as manual recordings) - // insertionIndex is the boundary BEFORE an item - // When at bottom (insertionIndex === assets.length), append to end - // When in middle, insert after the currently viewed item - - if (currentInsertionIndex >= currentAssets.length) { - // At or past the end - append - targetOrder = - currentAssets.length > 0 - ? (currentAssets[currentAssets.length - 1]?.order_index ?? - currentAssets.length - 1) + 1 - : 0; - debugLog( - `🎯 VAD: At bottom, appending with order_index: ${targetOrder}` - ); - } else { - // In the middle - insert after current item - const actualInsertionIndex = currentInsertionIndex + 1; - if (actualInsertionIndex < currentAssets.length) { - targetOrder = - currentAssets[actualInsertionIndex]?.order_index ?? - actualInsertionIndex; - } else { - targetOrder = - currentAssets.length > 0 - ? (currentAssets[currentAssets.length - 1]?.order_index ?? - currentAssets.length - 1) + 1 - : 0; - } - debugLog( - `🎯 VAD: In middle at visual index ${currentInsertionIndex}, inserting at order_index: ${targetOrder}` - ); - } - } else { - // Legacy: append to end - targetOrder = await getNextOrderIndex(currentQuestId!); - debugLog(`🎯 VAD counter initialized to end: ${targetOrder}`); - } - - vadCounterRef.current = targetOrder; - })(); + // Capture current position when VAD starts + vadInsertionIndexRef.current = insertionIndexRef.current; + const isAtEnd = + assets.length === 0 || insertionIndexRef.current >= assets.length; + + if (isAtEnd) { + // At end: use append mode, will increment for each segment + vadCounterRef.current = appendOrderIndexRef.current; + debugLog( + `🎯 VAD initialized at END | order_index: ${vadCounterRef.current}` + ); + } else { + // In middle: use selected asset's order_index + 1 to insert BELOW it + const selectedAsset = assets[insertionIndexRef.current]; + const selectedOrderIndex = + selectedAsset?.order_index ?? insertionIndexRef.current; + vadCounterRef.current = selectedOrderIndex + 1; + debugLog( + `🎯 VAD initialized in MIDDLE | order_index: ${vadCounterRef.current} (below "${selectedAsset?.name}" which has ${selectedOrderIndex})` + ); + } } else if (!isVADLocked) { vadCounterRef.current = null; + vadInsertionIndexRef.current = null; } - // IMPORTANT: Only depend on isVADLocked and currentQuestId - // insertionIndex is read from ref to avoid stale closure issues - // assets is captured from closure (intentional - we want the state at activation time) - }, [isVADLocked, currentQuestId, assets, insertionIndex]); + }, [isVADLocked, assets]); // Manual recording handlers const handleRecordingStart = React.useCallback(() => { @@ -1147,30 +1206,28 @@ const BibleRecordingView = ({ debugLog('🎬 Manual recording start'); setIsRecording(true); - // Set order index for manual recording - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (USE_INSERTION_WHEEL) { - // IMPORTANT: insertionIndex is the boundary BEFORE an item - // When user sees item 0 centered, insertionIndex = 0 (before item 0) - // But they want to insert AFTER the item they're viewing - // So we use insertionIndex + 1 for the actual insertion position - const actualInsertionIndex = insertionIndex + 1; - - const targetOrder = - actualInsertionIndex < assets.length - ? (assets[actualInsertionIndex]?.order_index ?? actualInsertionIndex) - : (assets[assets.length - 1]?.order_index ?? assets.length - 1) + 1; + // Calculate order_index based on current Wheel position + // - At end: use appendOrderIndexRef (increments automatically) + // - In middle: use selected asset's order_index + 1 (to insert BELOW the selected asset) + const isAtEnd = assets.length === 0 || insertionIndex >= assets.length; + + if (isAtEnd) { + // At end: use append mode + const targetOrder = appendOrderIndexRef.current; + appendOrderIndexRef.current = targetOrder + 1; currentRecordingOrderRef.current = targetOrder; debugLog( - `🎯 Recording will insert AFTER item at visual index ${insertionIndex} (boundary ${actualInsertionIndex}) with order_index ${targetOrder}` + `🎯 Recording at END | order_index: ${targetOrder}, next append: ${appendOrderIndexRef.current}` ); } else { - // Legacy: append to end - const targetOrder = - assets.length > 0 - ? (assets[assets.length - 1]?.order_index ?? 0) + 1 - : 0; + // In middle: use selected asset's order_index + 1 to insert BELOW it + const selectedAsset = assets[insertionIndex]; + const selectedOrderIndex = selectedAsset?.order_index ?? insertionIndex; + const targetOrder = selectedOrderIndex + 1; currentRecordingOrderRef.current = targetOrder; + debugLog( + `🎯 Recording in MIDDLE | order_index: ${targetOrder} (below "${selectedAsset?.name}" which has ${selectedOrderIndex})` + ); } }, [isRecording, assets, insertionIndex]); @@ -1202,16 +1259,14 @@ const BibleRecordingView = ({ return; } - // Generate name immediately and reserve it to prevent duplicates - // In VAD mode: Use the VAD counter which is already incremented per segment - // In manual mode: Use total count (existing + pending) for simple sequential naming - const nextNumber = isVADLocked - ? targetOrder + 1 // VAD: use order_index + 1 for naming (order is 0-based, names are 1-based) - : assets.length + pendingAssetNamesRef.current.size + 1; + // 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); - debugLog( - `🏷️ Reserved name: ${assetName} (${isVADLocked ? 'VAD mode' : 'manual mode'}) | order_index: ${targetOrder}, asset count: ${assets.length}, pending: ${pendingAssetNamesRef.current.size}` + console.log( + `🏷️ Reserved name: ${assetName} | counter: ${nextNumber} → ${nameCounterRef.current} | order_index: ${targetOrder}` ); // Native module flushes the file before sending onSegmentComplete event. @@ -1250,9 +1305,15 @@ const BibleRecordingView = ({ userId: currentUser.id, orderIndex: targetOrder, audioUri: localUri, - assetName: assetName // Pass the reserved name + assetName: assetName, // Pass the reserved name + metadata: _verse ? { verse: _verse } : null // Pass verse metadata if provided }); + // Log the saved asset details + console.log( + `📼 Asset saved | name: "${assetName}" | order_index: ${targetOrder} | propsOrderIndex: ${_initialOrderIndex} | verse: ${_verse ? `${_verse.from}-${_verse.to}` : 'null'}` + ); + // Add to session assets list (UI only - not loaded from DB) addSessionAsset({ id: newAssetId, @@ -1260,6 +1321,17 @@ const BibleRecordingView = ({ order_index: targetOrder }); + // Track which verse was recorded (for order_index normalization on return) + // If no verse is assigned, use 999 (UNASSIGNED_VERSE_BASE) + const verseToTrack = _verse?.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( @@ -1297,9 +1369,11 @@ const BibleRecordingView = ({ currentUser, queryClient, isVADLocked, - assets, targetLanguoidId, - addSessionAsset + addSessionAsset, + _verse, + _initialOrderIndex, + saveNameCounter ] ); @@ -1311,11 +1385,23 @@ const BibleRecordingView = ({ } const targetOrder = vadCounterRef.current; - debugLog('🎬 VAD: Segment starting | order_index:', targetOrder); + const isAtEnd = + vadInsertionIndexRef.current === null || + vadInsertionIndexRef.current >= assets.length; + + debugLog( + `🎬 VAD: Segment starting | order_index: ${targetOrder} | isAtEnd: ${isAtEnd}` + ); currentRecordingOrderRef.current = targetOrder; - vadCounterRef.current = targetOrder + 1; // Increment for next segment - }, []); + + // 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 + } + }, [assets.length]); const handleVADSegmentComplete = React.useCallback( (uri: string) => { @@ -2209,7 +2295,20 @@ const BibleRecordingView = ({ {/* Header */} - diff --git a/views/new/recording/services/recordingService.ts b/views/new/recording/services/recordingService.ts index af790ad03..86682acd1 100644 --- a/views/new/recording/services/recordingService.ts +++ b/views/new/recording/services/recordingService.ts @@ -14,6 +14,14 @@ import { resolveTable } from '@/utils/dbUtils'; import { and, eq, gte } from 'drizzle-orm'; import uuid from 'react-native-uuid'; +// Asset metadata interface (verse information) +export interface AssetMetadata { + verse?: { + from: number; + to: number; + }; +} + export interface SaveRecordingParams { questId: string; projectId: string; @@ -22,6 +30,7 @@ export interface SaveRecordingParams { orderIndex: number; audioUri: string; assetName: string; // Pre-determined asset name (reserved to prevent duplicates) + metadata?: AssetMetadata | null; // Optional verse metadata } /** @@ -41,7 +50,8 @@ export async function saveRecording( userId, orderIndex, audioUri, - assetName + assetName, + metadata } = params; const newAssetId = String(uuid.v4()); @@ -94,7 +104,8 @@ export async function saveRecording( source_language_id: targetLanguoidId, // Deprecated field, kept for backward compatibility project_id: projectId, creator_id: userId, - download_profiles: [userId] + download_profiles: [userId], + metadata: metadata ? JSON.stringify(metadata) : null }) .returning(); From d89a1b50d0b5bc48a44d34f9b83a26eb1b966f6d Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Mon, 12 Jan 2026 06:34:00 -0800 Subject: [PATCH 20/39] Implemented Merge and Delete functions --- components/VerseSeparator.tsx | 100 +++++-- views/new/BibleAssetsView.tsx | 255 ++++++++++++++++-- .../components/BibleSelectionControls.tsx | 71 +++++ .../components/SelectionControls.tsx | 13 +- 4 files changed, 390 insertions(+), 49 deletions(-) create mode 100644 views/new/recording/components/BibleSelectionControls.tsx diff --git a/components/VerseSeparator.tsx b/components/VerseSeparator.tsx index 645603876..342ff09ec 100644 --- a/components/VerseSeparator.tsx +++ b/components/VerseSeparator.tsx @@ -16,6 +16,9 @@ interface VerseSeparatorProps { 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; @@ -33,6 +36,8 @@ export function VerseSeparator({ editable = false, largeText = false, onPress, + isSelectedForRecording = false, + onSelectForRecording, dragHandleComponent: DragHandleComponent, dragHandleProps }: VerseSeparatorProps) { @@ -56,40 +61,95 @@ export function VerseSeparator({ 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 ( - - - - - {getText()} - + + + + {/* 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 && ( )} - - {getText()} - + {/* 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 && ( { // Toggle: if same asset clicked, deselect - if (selectedForRecording?.assetId === assetId) { + if ( + selectedForRecording?.type === 'asset' && + selectedForRecording?.assetId === assetId + ) { setSelectedForRecording(null); return; } @@ -778,15 +788,199 @@ export default function BibleAssetsView() { } setSelectedForRecording({ + type: 'asset', assetId, orderIndex, metadata, verseName }); }, - [selectedForRecording?.assetId, listItems] + [selectedForRecording?.type, selectedForRecording?.assetId, listItems] ); + // 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 + }); + + console.log( + `🎯 Selected separator for recording | verse: ${verse} | orderIndex: ${orderIndex} | verseName: "${verseName}"` + ); + }, + [selectedForRecording?.type, selectedForRecording?.separatorKey] + ); + + // Handle batch delete of selected assets + 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 + ]); + // Auto-assign labels to assets when a separator is created with assetId React.useEffect(() => { const processNewSeparators = async () => { @@ -1592,6 +1786,11 @@ export default function BibleAssetsView() { index: number; }) => { if (item.type === 'separator') { + // Check if this separator is selected for recording + const isSeparatorSelected = + selectedForRecording?.type === 'separator' && + selectedForRecording?.separatorKey === item.key; + return ( + handleSelectSeparatorForRecording( + item.key, + item.from, + item.to + ) + : undefined + } dragHandleComponent={!isPublished ? Sortable.Handle : undefined} dragHandleProps={ !isPublished @@ -1695,7 +1906,9 @@ export default function BibleAssetsView() { onEnterSelection={!isPublished ? enterSelection : undefined} // Recording insertion point selection isSelectedForRecording={ - !isPublished && selectedForRecording?.assetId === asset.id + !isPublished && + selectedForRecording?.type === 'asset' && + selectedForRecording?.assetId === asset.id } onSelectForRecording={ !isPublished ? handleSelectForRecording : undefined @@ -1716,8 +1929,11 @@ export default function BibleAssetsView() { selectedAssetIds, toggleSelect, enterSelection, + selectedForRecording?.type, selectedForRecording?.assetId, - handleSelectForRecording + selectedForRecording?.separatorKey, + handleSelectForRecording, + handleSelectSeparatorForRecording ] ); @@ -2853,17 +3069,11 @@ export default function BibleAssetsView() { className="px-2" > {isSelectionMode ? ( - { - // TODO: Implement batch merge for BibleAssetsView - console.log('Batch merge not yet implemented'); - }} - onDelete={() => { - // TODO: Implement batch delete for BibleAssetsView - console.log('Batch delete not yet implemented'); - }} + onMerge={handleBatchMergeSelected} + onDelete={handleBatchDeleteSelected} onAssignVerse={() => { // TODO: Implement batch verse assignment for BibleAssetsView console.log('Batch assign verse not yet implemented'); @@ -3023,6 +3233,8 @@ export default function BibleAssetsView() { ScrollViewComponent={GHScrollView} onApply={(from, to) => { addVerseSeparator(from, to); + // Clear recording selection when any label is added + setSelectedForRecording(null); setVerseSelectorState({ isOpen: false, key: null }); }} onCancel={() => @@ -3055,6 +3267,8 @@ export default function BibleAssetsView() { getMaxToForFrom={getMaxToForFrom} onApply={(from, to) => { addVerseSeparator(from, to); + // Clear recording selection when any label is added + setSelectedForRecording(null); setNewLabelSelectorState({ isOpen: false }); }} onCancel={() => setNewLabelSelectorState({ isOpen: false })} @@ -3094,6 +3308,8 @@ export default function BibleAssetsView() { } else { addVerseSeparator(from, to); } + // Clear recording selection when any label is added + setSelectedForRecording(null); setAssetVerseSelectorState({ isOpen: false, assetId: null }); }} onCancel={() => @@ -3145,6 +3361,9 @@ export default function BibleAssetsView() { 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={() => diff --git a/views/new/recording/components/BibleSelectionControls.tsx b/views/new/recording/components/BibleSelectionControls.tsx new file mode 100644 index 000000000..52d082bfa --- /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/SelectionControls.tsx b/views/new/recording/components/SelectionControls.tsx index 15e72b63c..adfbd97b8 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 { Bookmark, Merge, Trash2, X } from 'lucide-react-native'; +import { Merge, Trash2, X } from 'lucide-react-native'; import React from 'react'; import { View } from 'react-native'; @@ -21,15 +21,13 @@ interface SelectionControlsProps { onCancel: () => void; onMerge: () => void; onDelete: () => void; - onAssignVerse?: () => void; } export const SelectionControls = React.memo(function SelectionControls({ selectedCount, onCancel, onMerge, - onDelete, - onAssignVerse + onDelete }: SelectionControlsProps) { const { t } = useLocalization(); return ( @@ -37,13 +35,6 @@ export const SelectionControls = React.memo(function SelectionControls({ ({selectedCount}) - + + + )} + {/* Bottom controls - absolutely positioned */} - + {isSelectionMode ? ( Date: Thu, 15 Jan 2026 06:24:57 -0800 Subject: [PATCH 24/39] Create bible verses in recording view --- components/TagModal.md | 80 ----- views/new/AssetListItem.tsx | 116 +++--- .../components/BibleRecordingView.tsx | 335 ++++++++++++------ 3 files changed, 283 insertions(+), 248 deletions(-) delete mode 100644 components/TagModal.md diff --git a/components/TagModal.md b/components/TagModal.md deleted file mode 100644 index 087732188..000000000 --- a/components/TagModal.md +++ /dev/null @@ -1,80 +0,0 @@ -# TagModal Component - -## Descrição -Componente modal para atribuição de tags. Permite buscar e selecionar múltiplas tags da tabela `tag` do Drizzle usando o `tagService`. - -## Funcionalidades do TagService - -O componente utiliza os seguintes métodos do `tagService`: - -- `searchTags(searchTerm?, limit)`: Busca tags por padrão na chave (`key LIKE %termo%`) -- `getAllActiveTags(limit)`: Retorna todas as tags ativas -- Ordenação automática por `key` -- Filtro de tags ativas (`active = true`) - -## Propriedades - -```typescript -interface TagModalProps { - isVisible: boolean; // Controla a visibilidade do modal - selectedTag?: Tag; // Tag pré-selecionada (opcional) - searchTerm?: string; // Termo de busca inicial (opcional) - limit?: number; // Número máximo de tags retornadas (padrão: 20) - onClose: () => void; // Função chamada ao fechar o modal - onAssignTags: (tags: Tag[]) => void; // Função chamada ao atribuir tags -} -``` - -## Comportamento - -- **Se `searchTerm` for fornecido**: Lista todas as tags onde a `key` contenha o termo de busca -- **Se `searchTerm` estiver vazio**: Mostra um input de busca para o usuário pesquisar -- **Limitador**: O parâmetro `limit` controla quantas tags são retornadas (padrão: 20) -- **Seleção múltipla**: Permite selecionar/desselecionar múltiplas tags -- **Tag pré-selecionada**: Se `selectedTag` for fornecida, inicia com essa tag selecionada - -## Exemplo de Uso - -```typescript -import { TagModal } from './TagModal'; -import type { Tag } from '@/hooks/db/useSearchTags'; - -function MyComponent() { - const [isTagModalVisible, setIsTagModalVisible] = useState(false); - const [selectedTag, setSelectedTag] = useState(); - - const handleAssignTags = (tags: Tag[]) => { - console.log('Tags selecionadas:', tags); - // Implementar lógica de atribuição das tags - }; - - return ( - <> - - - setIsTagModalVisible(false)} - onAssignTags={handleAssignTags} - /> - - ); -} -``` - -## Busca com Termo Pré-definido - -```typescript - setIsTagModalVisible(false)} - onAssignTags={handleAssignTags} -/> -``` \ No newline at end of file diff --git a/views/new/AssetListItem.tsx b/views/new/AssetListItem.tsx index e10f5dddf..94d0e1964 100644 --- a/views/new/AssetListItem.tsx +++ b/views/new/AssetListItem.tsx @@ -1,5 +1,4 @@ import { DownloadIndicator } from '@/components/DownloadIndicator'; -import { Badge } from '@/components/ui/badge'; import { Card, CardDescription, @@ -9,24 +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 { 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 { useTagStore } from '@/hooks/useTagStore'; import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; import type { AttachmentRecord } from '@powersync/attachments'; import { EyeOffIcon, HardDriveIcon, - PauseIcon, - Plus, - TagIcon + PauseIcon + // Plus, + // TagIcon } from 'lucide-react-native'; import React from 'react'; import { Pressable, View } from 'react-native'; -import { TagModal } from '../../components/TagModal'; +// import { TagModal } from '../../components/TagModal'; import { useItemDownload, useItemDownloadStatus } from './useHybridData'; // Define props locally to avoid require cycle @@ -36,7 +35,7 @@ type Asset = typeof asset_type.$inferSelect; type AssetQuestLink = Asset & { quest_active: boolean; quest_visible: boolean; - tag_ids?: string[] | undefined; + // tag_ids?: string[] | undefined; }; export interface AssetListItemProps { asset: AssetQuestLink; @@ -51,9 +50,9 @@ export const AssetListItem: React.FC = ({ asset, questId, isCurrentlyPlaying = false, - isPublished, - onUpdate, - attachmentState + isPublished: _isPublished, + onUpdate: _onUpdate, + attachmentState: _attachmentState }) => { const { goToAsset, currentProjectData, currentQuestData } = useAppNavigation(); @@ -62,20 +61,21 @@ export const AssetListItem: React.FC = ({ // Check if asset is downloaded const isDownloaded = useItemDownloadStatus(asset, currentUser?.id); - 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]); + // 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( @@ -83,34 +83,34 @@ export const AssetListItem: React.FC = ({ asset.id ); - // Tag modal state - const [isTagModalVisible, setIsTagModalVisible] = React.useState(false); + // 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 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 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( @@ -157,7 +157,7 @@ export const AssetListItem: React.FC = ({ downloadAsset({ userId: currentUser.id, download: !isDownloaded }); }; - const tag = tags.length > 0 ? tags[0] : null; + // const tag = tags.length > 0 ? tags[0] : null; return ( @@ -191,7 +191,8 @@ export const AssetListItem: React.FC = ({ - + {/* Tags temporarily disabled */} + {/* @@ -216,7 +217,7 @@ export const AssetListItem: React.FC = ({ )} - + */} = ({ */} - setIsTagModalVisible(false)} onAssignTags={handleAssignTags} - /> + /> */} ); }; diff --git a/views/new/recording/components/BibleRecordingView.tsx b/views/new/recording/components/BibleRecordingView.tsx index 2eb00b46e..df4828c75 100644 --- a/views/new/recording/components/BibleRecordingView.tsx +++ b/views/new/recording/components/BibleRecordingView.tsx @@ -60,6 +60,7 @@ function debugLog(...args: unknown[]) { } interface UIAsset { + type: 'asset'; id: string; name: string; created_at: string; @@ -70,6 +71,22 @@ interface UIAsset { 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 @@ -367,10 +384,23 @@ const BibleRecordingView = ({ Map >(new Map()); - // SESSION-ONLY ASSETS: Only show assets created during this recording session - // When user exits and returns, the list starts empty + // 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 [sessionAssets, setSessionAssets] = React.useState([]); + const [sessionItems, setSessionItems] = React.useState(() => { + // Initialize with the initial verse pill + const initialVerse = _verse ?? null; + const initialPill: VersePillItem = { + type: 'pill', + id: `pill-initial-${_initialOrderIndex}`, + order_index: _initialOrderIndex, + verse: initialVerse + }; + console.log( + `🏷️ Initial pill created | order_index: ${_initialOrderIndex} | verse: ${initialVerse ? `${initialVerse.from}-${initialVerse.to}` : 'null'}` + ); + 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 @@ -387,21 +417,25 @@ const BibleRecordingView = ({ }) => { const targetOrderIndex = newAsset.order_index; - setSessionAssets((prev) => { - // 1. Shift existing assets with order_index >= targetOrderIndex + 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((asset) => { - if (asset.order_index >= targetOrderIndex) { + 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: "${asset.name}" ${asset.order_index} → ${asset.order_index + 1}` + `📊 UI Shift: "${itemName}" ${item.order_index} → ${item.order_index + 1}` ); - return { ...asset, order_index: asset.order_index + 1 }; + return { ...item, order_index: item.order_index + 1 }; } - return asset; + 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(), @@ -418,20 +452,65 @@ const BibleRecordingView = ({ // 3. Add new asset and sort by order_index const newList = [...shifted, uiAsset]; - return newList.sort((a, b) => - a.order_index === b.order_index - ? a.created_at.localeCompare(b.created_at) - : a.order_index > b.order_index - ? 1 - : -1 - ); + 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 assets instead of database query - const rawAssets = sessionAssets; + // 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: @@ -475,6 +554,7 @@ const BibleRecordingView = ({ } return { + type: 'asset' as const, id: obj.id, name: obj.name, created_at: obj.created_at, @@ -498,28 +578,38 @@ const BibleRecordingView = ({ return result; }, [rawAssets, assetSegmentCounts, assetDurations]); - // Check if we're at the end of the list (for VersePill behavior) + // Check if we're at the end of the list (for add verse button behavior) const isAtEndOfList = React.useMemo( - () => assets.length === 0 || insertionIndex >= assets.length, - [assets.length, insertionIndex] + () => allItems.length === 0 || insertionIndex >= allItems.length, + [allItems.length, insertionIndex] ); - // Get the asset at the current insertion position (center of wheel) - // This is used to show the verse in VersePill when scrolling through items - const highlightedAsset = React.useMemo(() => { - if (assets.length === 0) return null; - // insertionIndex can be 0 to assets.length (inclusive) - // When at the end (insertionIndex >= assets.length), we still want to show the last asset - const idx = Math.min(insertionIndex, assets.length - 1); - return assets[idx] ?? null; - }, [assets, insertionIndex]); - - // Get verse metadata from highlighted asset (uses actual metadata, not order_index) + // 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 highlightedAssetVerse = React.useMemo(() => { - if (!highlightedAsset) return null; - return highlightedAsset.verse ?? null; - }, [highlightedAsset]); + 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( @@ -625,35 +715,42 @@ const BibleRecordingView = ({ const handleAddNextVerse = React.useCallback(() => { if (verseToAdd === null) return; - console.log(`➕ Adding verse ${verseToAdd} to VersePill`); + 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; - appendOrderIndexRef.current = newOrderIndex; + appendOrderIndexRef.current = newOrderIndex + 1; // Next asset goes after the pill console.log( `📊 Updated order_index for verse ${verseToAdd}: ${newOrderIndex}` ); - // Set currentDynamicVerse to this verse - // The VersePill will now show this verse - // The button will automatically calculate the next verse (verseToAdd + 1) + // Add the verse pill to the list + addVersePill(verseToAdd, newOrderIndex); + + // Set currentDynamicVerse to this verse (for button calculation) setCurrentDynamicVerse(verseToAdd); + // Scroll to the new pill (end of list) after it's added + // The wheel will update its item count and we should move to the new item + setTimeout(() => { + setInsertionIndex(allItems.length); // Will be the index after the new pill is added + }, 50); + // If VAD is active, also update the currentRecordingVerseRef // This ensures that the next VAD segment uses the new verse metadata if (isVADLocked) { const newVerse = { from: verseToAdd, to: verseToAdd }; currentRecordingVerseRef.current = newVerse; - // Also update VAD counter to use the new order_index - vadCounterRef.current = newOrderIndex; + // Also update VAD counter to use the new order_index (after the pill) + vadCounterRef.current = newOrderIndex + 1; console.log( - `🎯 VAD: Updated verse to ${verseToAdd} and order_index to ${newOrderIndex}` + `🎯 VAD: Updated verse to ${verseToAdd} and order_index to ${newOrderIndex + 1}` ); } - }, [verseToAdd, isVADLocked]); + }, [verseToAdd, isVADLocked, addVersePill, allItems.length]); // Map assets to SharedValues from the pool (after assets is declared) const assetIdsKey = React.useMemo( @@ -680,16 +777,19 @@ const BibleRecordingView = ({ } }, [assetIdsKey, assets, progressPool]); - // Stable asset list that only updates when content actually changes - // We intentionally use assetContentKey instead of assets to prevent re-renders - // when assets array reference changes but content is identical + // 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]); + + // Assets only (for legacy LegendList) const assetsForLegendList = React.useMemo(() => assets, [assets]); - // Clamp insertion index when asset count changes + // Clamp insertion index when item count changes React.useEffect(() => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (USE_INSERTION_WHEEL) { - const maxIndex = assets.length; // Can insert at 0..N (after last item) + const maxIndex = allItems.length; // Can insert at 0..N (after last item) if (insertionIndex > maxIndex) { debugLog( `📍 Clamping insertion index from ${insertionIndex} to ${maxIndex}` @@ -697,22 +797,22 @@ const BibleRecordingView = ({ setInsertionIndex(maxIndex); } } - }, [assets.length, insertionIndex]); + }, [allItems.length, insertionIndex]); // Ref for LegendList to enable scrolling const listRef = React.useRef(null); - // Track asset count to detect new insertions - const previousAssetCountRef = React.useRef(assets.length); + // Track item count to detect new insertions + const previousItemCountRef = React.useRef(allItems.length); // Auto-scroll behavior differs between list and wheel React.useEffect(() => { - const currentCount = assets.length; - const previousCount = previousAssetCountRef.current; + const currentCount = allItems.length; + const previousCount = previousItemCountRef.current; - // Only scroll if a new asset was added (count increased) + // Only scroll if a new item was added (count increased) if (currentCount > previousCount && currentCount > 0) { - debugLog('📜 Auto-scrolling to new asset'); + debugLog('📜 Auto-scrolling to new item'); // If we were at the end, update insertionIndex to stay at the end // This is crucial for the Add Verse button to remain visible @@ -745,8 +845,8 @@ const BibleRecordingView = ({ timeoutIdsRef.current.add(timeoutId); } - previousAssetCountRef.current = currentCount; - }, [assets.length, insertionIndex]); + previousItemCountRef.current = currentCount; + }, [allItems.length, insertionIndex]); // ============================================================================ // AUDIO PLAYBACK @@ -1414,28 +1514,32 @@ const BibleRecordingView = ({ // At end: use append mode, will increment for each segment vadCounterRef.current = appendOrderIndexRef.current; - // Verse: use dynamic verse if set, otherwise persisted verse - const verseToUse = currentDynamicVerse - ? { from: currentDynamicVerse, to: currentDynamicVerse } - : (persistedVerseRef.current ?? null); + // Verse: use the verse from the last item in the list (could be a pill) + const lastItem = allItems[allItems.length - 1]; + const verseToUse = lastItem?.verse ?? persistedVerseRef.current ?? null; currentRecordingVerseRef.current = verseToUse; debugLog( `🎯 VAD initialized at END | order_index: ${vadCounterRef.current} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'}` ); } else { - // In middle: use asset at insertionIndex - const selectedAsset = assets[insertionIndexRef.current]; + // In middle: use item at insertionIndex (could be asset or pill) + const selectedItem = allItems[insertionIndexRef.current]; const selectedOrderIndex = - selectedAsset?.order_index ?? insertionIndexRef.current; + selectedItem?.order_index ?? insertionIndexRef.current; vadCounterRef.current = selectedOrderIndex + 1; - // Verse: use the same verse as the selected asset - const verseToUse = selectedAsset?.verse ?? null; + // Verse: use the same verse as the selected item + const verseToUse = selectedItem?.verse ?? null; currentRecordingVerseRef.current = verseToUse; + const itemName = selectedItem + ? isAsset(selectedItem) + ? selectedItem.name + : `pill-${selectedItem.verse?.from ?? 'null'}` + : 'unknown'; debugLog( - `🎯 VAD initialized in MIDDLE | order_index: ${vadCounterRef.current} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'} (same as "${selectedAsset?.name}")` + `🎯 VAD initialized in MIDDLE | order_index: ${vadCounterRef.current} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'} (same as "${itemName}")` ); } } else if (!isVADLocked) { @@ -1443,7 +1547,7 @@ const BibleRecordingView = ({ vadInsertionIndexRef.current = null; vadIsAtEndRef.current = false; } - }, [isVADLocked, assets, currentDynamicVerse]); + }, [isVADLocked, allItems, currentDynamicVerse]); // Manual recording handlers const handleRecordingStart = React.useCallback(() => { @@ -1453,8 +1557,8 @@ const BibleRecordingView = ({ // Calculate order_index based on current Wheel position // - At end: use appendOrderIndexRef (increments automatically) - // - In middle: use selected asset's order_index + 1 (to insert BELOW the selected asset) - const isAtEnd = assets.length === 0 || insertionIndex >= assets.length; + // - In middle: use selected item's order_index + 1 (to insert BELOW the selected item) + const isAtEnd = allItems.length === 0 || insertionIndex >= allItems.length; if (isAtEnd) { // At end: use append mode @@ -1462,31 +1566,35 @@ const BibleRecordingView = ({ appendOrderIndexRef.current = targetOrder + 1; currentRecordingOrderRef.current = targetOrder; - // Verse: use dynamic verse if set, otherwise persisted verse - const verseToUse = currentDynamicVerse - ? { from: currentDynamicVerse, to: currentDynamicVerse } - : (persistedVerseRef.current ?? null); + // Verse: use the verse from the last item in the list (could be a pill) + const lastItem = allItems[allItems.length - 1]; + const verseToUse = lastItem?.verse ?? persistedVerseRef.current ?? null; currentRecordingVerseRef.current = verseToUse; debugLog( `🎯 Recording at END | order_index: ${targetOrder} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'}` ); } else { - // In middle: use the asset at insertionIndex - const selectedAsset = assets[insertionIndex]; - const selectedOrderIndex = selectedAsset?.order_index ?? insertionIndex; + // In middle: use the item at insertionIndex (could be asset or pill) + const selectedItem = allItems[insertionIndex]; + const selectedOrderIndex = selectedItem?.order_index ?? insertionIndex; const targetOrder = selectedOrderIndex + 1; currentRecordingOrderRef.current = targetOrder; - // Verse: use the same verse as the selected asset - const verseToUse = selectedAsset?.verse ?? null; + // Verse: use the same verse as the selected item + const verseToUse = selectedItem?.verse ?? null; currentRecordingVerseRef.current = verseToUse; + const itemName = selectedItem + ? isAsset(selectedItem) + ? selectedItem.name + : `pill-${selectedItem.verse?.from ?? 'null'}` + : 'unknown'; debugLog( - `🎯 Recording in MIDDLE | order_index: ${targetOrder} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'} (same as "${selectedAsset?.name}")` + `🎯 Recording in MIDDLE | order_index: ${targetOrder} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'} (same as "${itemName}")` ); } - }, [isRecording, assets, insertionIndex, currentDynamicVerse]); + }, [isRecording, allItems, insertionIndex]); const handleRecordingStop = React.useCallback(() => { debugLog('🛑 Manual recording stop'); @@ -2031,7 +2139,7 @@ const BibleRecordingView = ({ await audioSegmentService.deleteAudioSegment(assetId); // Remove from session assets list - setSessionAssets((prev) => prev.filter((a) => a.id !== assetId)); + setSessionItems((prev) => prev.filter((a) => a.id !== assetId)); await queryClient.invalidateQueries({ queryKey: ['assets', 'by-quest', currentQuestId], @@ -2075,7 +2183,7 @@ const BibleRecordingView = ({ await audioSegmentService.deleteAudioSegment(second.id); // Remove merged asset from session list (second one gets deleted) - setSessionAssets((prev) => prev.filter((a) => a.id !== second.id)); + setSessionItems((prev) => prev.filter((a) => a.id !== second.id)); // Force re-load of segment count for the merged asset debugLog( @@ -2156,7 +2264,7 @@ const BibleRecordingView = ({ // Remove merged assets from session list (all except target get deleted) const deletedIds = new Set(rest.map((a) => a.id)); - setSessionAssets((prev) => + setSessionItems((prev) => prev.filter((a) => !deletedIds.has(a.id)) ); @@ -2230,7 +2338,7 @@ const BibleRecordingView = ({ // Remove deleted assets from session list const deletedIds = new Set(selectedOrdered.map((a) => a.id)); - setSessionAssets((prev) => + setSessionItems((prev) => prev.filter((a) => !deletedIds.has(a.id)) ); @@ -2281,7 +2389,7 @@ const BibleRecordingView = ({ // Update the name directly in sessionAssets to reflect in UI immediately // This is safe because the database was already updated successfully - setSessionAssets((prev) => + setSessionItems((prev) => prev.map((asset) => asset.id === renameAssetId ? { ...asset, name: newName } : asset ) @@ -2463,7 +2571,23 @@ const BibleRecordingView = ({ // OPTIMIZED: No audioContext.position/duration dependencies - progress now uses SharedValues! // This eliminates re-creating all children 10+ times per second during audio playback const wheelChildren = React.useMemo(() => { - return assetsForLegendList.map((item, index) => { + return itemsForWheel.map((item, index) => { + // 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; @@ -2474,9 +2598,14 @@ const BibleRecordingView = ({ 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 < assetsForLegendList.length - 1 && - assetsForLegendList[index + 1]?.source !== 'cloud'; + index < itemsForWheel.length - 1 && + nextItem && + isAsset(nextItem) && + nextItem.source !== 'cloud'; // Duration from lazy-loaded metadata const duration = item.duration; @@ -2520,7 +2649,8 @@ const BibleRecordingView = ({ ); }); }, [ - assetsForLegendList, + itemsForWheel, + formatVerseRange, audioContext.isPlaying, audioContext.currentAudioId, currentlyPlayingAssetId, @@ -2607,27 +2737,10 @@ const BibleRecordingView = ({ {/* Scrollable list area - full height with padding for controls */} - {assets.length === 0 && ( - - - Start recording to create assets. - - - )} - {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */} {USE_INSERTION_WHEEL ? ( - // ArrayInsertionWheel mode - always show wheel, even when empty + // ArrayInsertionWheel mode - always show wheel (starts with initial verse pill) - {/* Verse pill positioned above the center item */} - {versePillText && ( - - - - )} Date: Thu, 15 Jan 2026 07:33:48 -0800 Subject: [PATCH 25/39] Fix assets positioning --- .../components/BibleRecordingView.tsx | 141 +++++++++++++----- 1 file changed, 100 insertions(+), 41 deletions(-) diff --git a/views/new/recording/components/BibleRecordingView.tsx b/views/new/recording/components/BibleRecordingView.tsx index df4828c75..1f695e691 100644 --- a/views/new/recording/components/BibleRecordingView.tsx +++ b/views/new/recording/components/BibleRecordingView.tsx @@ -727,17 +727,17 @@ const BibleRecordingView = ({ `📊 Updated order_index for verse ${verseToAdd}: ${newOrderIndex}` ); + // Mark that a pill was added (so auto-scroll doesn't move the wheel) + wasPillAddedRef.current = true; + // Add the verse pill to the list addVersePill(verseToAdd, newOrderIndex); // Set currentDynamicVerse to this verse (for button calculation) setCurrentDynamicVerse(verseToAdd); - // Scroll to the new pill (end of list) after it's added - // The wheel will update its item count and we should move to the new item - setTimeout(() => { - setInsertionIndex(allItems.length); // Will be the index after the new pill is added - }, 50); + // NOTE: We intentionally do NOT update insertionIndex here + // This allows the user to stay at their current position // If VAD is active, also update the currentRecordingVerseRef // This ensures that the next VAD segment uses the new verse metadata @@ -750,7 +750,7 @@ const BibleRecordingView = ({ `🎯 VAD: Updated verse to ${verseToAdd} and order_index to ${newOrderIndex + 1}` ); } - }, [verseToAdd, isVADLocked, addVersePill, allItems.length]); + }, [verseToAdd, isVADLocked, addVersePill]); // Map assets to SharedValues from the pool (after assets is declared) const assetIdsKey = React.useMemo( @@ -805,6 +805,13 @@ const BibleRecordingView = ({ // 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; @@ -812,37 +819,56 @@ const BibleRecordingView = ({ // Only scroll if a new item was added (count increased) if (currentCount > previousCount && currentCount > 0) { - debugLog('📜 Auto-scrolling to new item'); + console.log( + `📜 Item added | prevCount: ${previousCount} → ${currentCount} | insertionIndex: ${insertionIndex} | wasInMiddle: ${wasRecordingInMiddleRef.current} | wasPillAdded: ${wasPillAddedRef.current}` + ); - // If we were at the end, update insertionIndex to stay at the end - // This is crucial for the Add Verse button to remain visible - const wasAtEnd = insertionIndex >= previousCount; - if (wasAtEnd) { - debugLog( - `📍 Updating insertionIndex to stay at end: ${insertionIndex} → ${currentCount}` + const wasInMiddle = wasRecordingInMiddleRef.current; + const wasPillAdded = wasPillAddedRef.current; + + // Reset the flags + wasRecordingInMiddleRef.current = false; + wasPillAddedRef.current = false; + + if (wasPillAdded) { + // A pill was added - don't move, let user stay where they are + console.log('📍 Pill added - not moving insertionIndex'); + } 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); - } - // Small delay to ensure the new item is rendered before scrolling - const timeoutId = setTimeout(() => { - try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (USE_INSERTION_WHEEL) { - // For wheel: scroll to the newly inserted item's position - // After insertion at index N, the new item is at position N - const newItemIndex = Math.min(insertionIndex, currentCount - 1); - wheelRef.current?.scrollToInsertionIndex(newItemIndex + 1, true); - } else { - // For list: scroll to end - listRef.current?.scrollToEnd({ animated: true }); + // Scroll to the end + const timeoutId = setTimeout(() => { + try { + wheelRef.current?.scrollToInsertionIndex(currentCount, true); + } catch (error) { + console.error('Failed to scroll:', error); } - } catch (error) { - console.error('Failed to scroll:', error); - } - timeoutIdsRef.current.delete(timeoutId); - }, 100); - timeoutIdsRef.current.add(timeoutId); + timeoutIdsRef.current.delete(timeoutId); + }, 100); + timeoutIdsRef.current.add(timeoutId); + } } previousItemCountRef.current = currentCount; @@ -1552,13 +1578,35 @@ const BibleRecordingView = ({ // Manual recording handlers const handleRecordingStart = React.useCallback(() => { if (isRecording) return; - debugLog('🎬 Manual recording start'); + + // Use ref for most up-to-date value (avoid stale closure) + const currentInsertionIndex = insertionIndexRef.current; + + console.log( + `🎬 Recording START | insertionIndex state: ${insertionIndex} | ref: ${currentInsertionIndex} | allItems.length: ${allItems.length}` + ); + + // Log all items for debugging + console.log( + '📋 All items:', + allItems + .map( + (item, idx) => + `[${idx}] ${isPill(item) ? `Pill-${item.verse?.from}` : item.name} (order: ${item.order_index})` + ) + .join(', ') + ); + setIsRecording(true); // Calculate order_index based on current Wheel position // - At end: use appendOrderIndexRef (increments automatically) // - In middle: use selected item's order_index + 1 (to insert BELOW the selected item) - const isAtEnd = allItems.length === 0 || insertionIndex >= allItems.length; + const isAtEnd = + allItems.length === 0 || currentInsertionIndex >= allItems.length; + + // Track if we're recording in the middle (for auto-scroll behavior) + wasRecordingInMiddleRef.current = !isAtEnd; if (isAtEnd) { // At end: use append mode @@ -1571,13 +1619,14 @@ const BibleRecordingView = ({ const verseToUse = lastItem?.verse ?? persistedVerseRef.current ?? null; currentRecordingVerseRef.current = verseToUse; - debugLog( + console.log( `🎯 Recording at END | order_index: ${targetOrder} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'}` ); } else { - // In middle: use the item at insertionIndex (could be asset or pill) - const selectedItem = allItems[insertionIndex]; - const selectedOrderIndex = selectedItem?.order_index ?? insertionIndex; + // In middle: use the item at currentInsertionIndex (could be asset or pill) + const selectedItem = allItems[currentInsertionIndex]; + const selectedOrderIndex = + selectedItem?.order_index ?? currentInsertionIndex; const targetOrder = selectedOrderIndex + 1; currentRecordingOrderRef.current = targetOrder; @@ -1590,8 +1639,8 @@ const BibleRecordingView = ({ ? selectedItem.name : `pill-${selectedItem.verse?.from ?? 'null'}` : 'unknown'; - debugLog( - `🎯 Recording in MIDDLE | order_index: ${targetOrder} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'} (same as "${itemName}")` + console.log( + `🎯 Recording in MIDDLE | insertionIndex: ${currentInsertionIndex} | item: "${itemName}" | order_index: ${targetOrder} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'}` ); } }, [isRecording, allItems, insertionIndex]); @@ -1756,6 +1805,9 @@ const BibleRecordingView = ({ // 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}` ); @@ -2744,7 +2796,14 @@ const BibleRecordingView = ({ { + console.log( + `🎡 Wheel onChange: ${insertionIndex} → ${newIndex}` + ); + setInsertionIndex(newIndex); + // Also update the ref immediately for recording callbacks + insertionIndexRef.current = newIndex; + }} rowHeight={ROW_HEIGHT} className="h-full flex-1" bottomInset={footerHeight} From 14223f7ab3afce3d69787b24ca072f14dd55ac72 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Sat, 17 Jan 2026 08:26:44 -0800 Subject: [PATCH 26/39] Remove Infinite Scroll when loading locally --- hooks/db/useAssets.ts | 331 +++++++++++++++++++++++++++++++++- views/new/BibleAssetsView.tsx | 16 +- 2 files changed, 337 insertions(+), 10 deletions(-) diff --git a/hooks/db/useAssets.ts b/hooks/db/useAssets.ts index cb6704c80..212e0b8e0 100644 --- a/hooks/db/useAssets.ts +++ b/hooks/db/useAssets.ts @@ -9,6 +9,7 @@ import { tag } from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; +import { useNetworkStatus } from '@/hooks/useNetworkStatus'; import { blockedContentQuery, blockedUsersQuery } from '@/utils/dbUtils'; import { getOptionShowHiddenContent } from '@/utils/settingsUtils'; import { @@ -16,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, @@ -31,7 +33,7 @@ import { or, sql } from 'drizzle-orm'; -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { createHybridQueryConfig, useHybridInfiniteQuery, @@ -1180,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 @@ -1227,3 +1230,327 @@ export function useAssetsByQuest( 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 { + ...asset, + tag_ids: tagIds + } as AssetQuestLink; + }); + + 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; + }, + 1000 // pageSize + ); + + return { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isOnline, + isFetching, + refetch + }; +} +*/ +// End of legacy infinite scroll implementation (commented out) diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index b1afaf540..d559a0256 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -89,7 +89,7 @@ import { } from '@/database_services/assetService'; import { audioSegmentService } from '@/database_services/audioSegmentService'; import { AppConfig } from '@/db/supabase/AppConfig'; -import { useAssetsByQuest } from '@/hooks/db/useAssets'; +import { useAssetsByQuest, useLocalAssetsByQuest } from '@/hooks/db/useAssets'; import { useBlockedAssetsCount } from '@/hooks/useBlockedCount'; import { useQuestOffloadVerification } from '@/hooks/useQuestOffloadVerification'; import { useHasUserReported } from '@/hooks/useReports'; @@ -634,11 +634,11 @@ export default function BibleAssetsView() { debouncedSearchQuery, showInvisibleContent ); - // const _localAssets = useLocalAssetsByQuest( - // currentQuestId || '', - // debouncedSearchQuery, - // showInvisibleContent - // ); + const localAssets = useLocalAssetsByQuest( + currentQuestId || '', + debouncedSearchQuery, + showInvisibleContent + ); // Use the appropriate hook result based on isPublished condition const { @@ -650,8 +650,8 @@ export default function BibleAssetsView() { isOnline, isFetching, refetch - } = publishedAssets; - // } = isPublished ? publishedAssets : localAssets; + //} = 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 From 11718132f672fef3a3b95c668ab37e57154d81b6 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Sun, 18 Jan 2026 18:51:16 -0800 Subject: [PATCH 27/39] Refactor recording view placement function --- views/new/BibleAssetsView.tsx | 107 +++--- .../components/BibleRecordingView.tsx | 341 +++++++++++------- 2 files changed, 269 insertions(+), 179 deletions(-) diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index d559a0256..3fee4e5ac 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -150,6 +150,14 @@ interface ManualSeparator { assetId?: string; } +const RecordingPlaceIndicator = () => ( + + {/* */} + {' '} + REC + +); + // ============================================================================ // HELPER FUNCTIONS (moved outside component for better performance) // ============================================================================ @@ -2116,46 +2124,51 @@ export default function BibleAssetsView() { 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 - } - /> + + { + 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 && ( + + )} + ); } @@ -2189,6 +2202,11 @@ export default function BibleAssetsView() { const hasAvailableVerses = assetRange.availableVerses.length > 0; const isSelected = selectedAssetIds.has(asset.id); + const isAssetSelectedForRecording = + !isPublished && + selectedForRecording?.type === 'asset' && + selectedForRecording?.assetId === asset.id; + return ( {/* Add verse button - positioned above and to the right */} @@ -2229,16 +2247,15 @@ export default function BibleAssetsView() { onToggleSelect={!isPublished ? toggleSelect : undefined} onEnterSelection={!isPublished ? enterSelection : undefined} // Recording insertion point selection - isSelectedForRecording={ - !isPublished && - selectedForRecording?.type === 'asset' && - selectedForRecording?.assetId === asset.id - } + isSelectedForRecording={isAssetSelectedForRecording} onSelectForRecording={ !isPublished ? handleSelectForRecording : undefined } onRename={!isPublished ? handleRenameAsset : undefined} /> + {!isPublished && !isSelectionMode && isAssetSelectedForRecording && ( + + )} ); }, diff --git a/views/new/recording/components/BibleRecordingView.tsx b/views/new/recording/components/BibleRecordingView.tsx index 1f695e691..55456b6e8 100644 --- a/views/new/recording/components/BibleRecordingView.tsx +++ b/views/new/recording/components/BibleRecordingView.tsx @@ -28,12 +28,7 @@ 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 { - ArrowLeft, - BookmarkPlusIcon, - PauseIcon, - PlayIcon -} from 'lucide-react-native'; +import { ArrowLeft, PauseIcon, PlayIcon, Plus } from 'lucide-react-native'; import React from 'react'; import { InteractionManager, View } from 'react-native'; import { useSharedValue } from 'react-native-reanimated'; @@ -227,6 +222,9 @@ const BibleRecordingView = ({ // 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; @@ -389,7 +387,8 @@ const BibleRecordingView = ({ // Assets are still saved to database, but we don't load existing ones const [sessionItems, setSessionItems] = React.useState(() => { // Initialize with the initial verse pill - const initialVerse = _verse ?? null; + if (!_verse) return []; + const initialVerse = _verse; const initialPill: VersePillItem = { type: 'pill', id: `pill-initial-${_initialOrderIndex}`, @@ -397,7 +396,7 @@ const BibleRecordingView = ({ verse: initialVerse }; console.log( - `🏷️ Initial pill created | order_index: ${_initialOrderIndex} | verse: ${initialVerse ? `${initialVerse.from}-${initialVerse.to}` : 'null'}` + `🏷️ Initial pill created | order_index: ${_initialOrderIndex} | verse: ${initialVerse.from}-${initialVerse.to}` ); return [initialPill]; }); @@ -446,6 +445,10 @@ const BibleRecordingView = ({ 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'}` ); @@ -694,7 +697,7 @@ const BibleRecordingView = ({ ? `${highlightedAssetVerse.from}-${highlightedAssetVerse.to}` : 'null'; console.log( - `🔘 State | insertionIdx: ${insertionIndex} | assetsLen: ${assets.length} | isAtEnd: ${isAtEndOfList} | debouncedIsAtEnd: ${debouncedIsAtEnd} | highlightedVerse: ${highlightedVerseStr} | verseToAdd: ${verseToAdd} | currentDynamic: ${currentDynamicVerse} | persistedLimit: ${persistedLimitVerseRef.current} | persistedNext: ${persistedNextVerseRef.current} | showBtn: ${showAddVerseButton} | pillText: ${versePillText}` + `🔘 State | insertionIdx: ${insertionIndex} | assetsLen: ${assets.length} | isAtEnd: ${isAtEndOfList} | debouncedIsAtEnd: ${debouncedIsAtEnd} | highlightedVerse: ${highlightedVerseStr} | verseToAdd: ${verseToAdd} | currentDynamic: ${currentDynamicVerse} | pillText: ${versePillText}` ); }, [ insertionIndex, @@ -709,8 +712,7 @@ const BibleRecordingView = ({ ]); // Handle adding next verse metadata - // When clicked, sets currentDynamicVerse to verseToAdd - // The VersePill will update to show this verse + // 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; @@ -721,13 +723,15 @@ const BibleRecordingView = ({ // Formula: (verse * 1000 + 1) * 1000 // This positions it at the beginning of the verse range const newOrderIndex = (verseToAdd * 1000 + 1) * 1000; - appendOrderIndexRef.current = newOrderIndex + 1; // Next asset goes after the pill + + // Update appendOrderIndexRef to point to after this new pill + appendOrderIndexRef.current = newOrderIndex + 1; console.log( - `📊 Updated order_index for verse ${verseToAdd}: ${newOrderIndex}` + `📊 Adding pill for verse ${verseToAdd} | order_index: ${newOrderIndex} | next append: ${appendOrderIndexRef.current}` ); - // Mark that a pill was added (so auto-scroll doesn't move the wheel) + // Mark that a pill was added (so auto-scroll moves to end) wasPillAddedRef.current = true; // Add the verse pill to the list @@ -736,18 +740,16 @@ const BibleRecordingView = ({ // Set currentDynamicVerse to this verse (for button calculation) setCurrentDynamicVerse(verseToAdd); - // NOTE: We intentionally do NOT update insertionIndex here - // This allows the user to stay at their current position + // 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, also update the currentRecordingVerseRef - // This ensures that the next VAD segment uses the new verse metadata + // If VAD is active, update recording context to use the new pill if (isVADLocked) { const newVerse = { from: verseToAdd, to: verseToAdd }; currentRecordingVerseRef.current = newVerse; - // Also update VAD counter to use the new order_index (after the pill) vadCounterRef.current = newOrderIndex + 1; console.log( - `🎯 VAD: Updated verse to ${verseToAdd} and order_index to ${newOrderIndex + 1}` + `🎯 VAD: Updated to verse ${verseToAdd} | order_index: ${newOrderIndex + 1}` ); } }, [verseToAdd, isVADLocked, addVersePill]); @@ -823,6 +825,11 @@ const BibleRecordingView = ({ `📜 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; @@ -831,8 +838,22 @@ const BibleRecordingView = ({ wasPillAddedRef.current = false; if (wasPillAdded) { - // A pill was added - don't move, let user stay where they are - console.log('📍 Pill added - not moving insertionIndex'); + // 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; @@ -1512,6 +1533,61 @@ const BibleRecordingView = ({ // 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(() => { @@ -1531,119 +1607,100 @@ const BibleRecordingView = ({ if (isVADLocked && vadCounterRef.current === null) { // Capture current position when VAD starts vadInsertionIndexRef.current = insertionIndexRef.current; - const isAtEnd = - assets.length === 0 || insertionIndexRef.current >= assets.length; - // Capture isAtEnd state once - this won't change during VAD session - vadIsAtEndRef.current = isAtEnd; - if (isAtEnd) { - // At end: use append mode, will increment for each segment - vadCounterRef.current = appendOrderIndexRef.current; + // Get insertion context based on current position + const { + orderIndex, + verse, + isAtEnd: contextIsAtEnd + } = getInsertionContext(insertionIndexRef.current); - // Verse: use the verse from the last item in the list (could be a pill) - const lastItem = allItems[allItems.length - 1]; - const verseToUse = lastItem?.verse ?? persistedVerseRef.current ?? null; - currentRecordingVerseRef.current = verseToUse; + vadCounterRef.current = orderIndex; + currentRecordingVerseRef.current = verse; - debugLog( - `🎯 VAD initialized at END | order_index: ${vadCounterRef.current} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'}` - ); - } else { - // In middle: use item at insertionIndex (could be asset or pill) - const selectedItem = allItems[insertionIndexRef.current]; - const selectedOrderIndex = - selectedItem?.order_index ?? insertionIndexRef.current; - vadCounterRef.current = selectedOrderIndex + 1; - - // Verse: use the same verse as the selected item - const verseToUse = selectedItem?.verse ?? null; - currentRecordingVerseRef.current = verseToUse; - - const itemName = selectedItem - ? isAsset(selectedItem) - ? selectedItem.name - : `pill-${selectedItem.verse?.from ?? 'null'}` - : 'unknown'; - debugLog( - `🎯 VAD initialized in MIDDLE | order_index: ${vadCounterRef.current} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'} (same as "${itemName}")` - ); - } + 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]); + }, [ + isVADLocked, + allItems, + currentDynamicVerse, + assets.length, + getInsertionContext + ]); // Manual recording handlers const handleRecordingStart = React.useCallback(() => { if (isRecording) return; - // Use ref for most up-to-date value (avoid stale closure) const currentInsertionIndex = insertionIndexRef.current; console.log( - `🎬 Recording START | insertionIndex state: ${insertionIndex} | ref: ${currentInsertionIndex} | allItems.length: ${allItems.length}` + `🎬 Recording START | insertionIndex: ${currentInsertionIndex} | allItems.length: ${allItems.length}` ); - // Log all items for debugging - console.log( - '📋 All items:', - allItems - .map( - (item, idx) => - `[${idx}] ${isPill(item) ? `Pill-${item.verse?.from}` : item.name} (order: ${item.order_index})` - ) - .join(', ') + // Get insertion context (order_index and verse) based on current position + const { orderIndex, verse, isAtEnd } = getInsertionContext( + currentInsertionIndex ); - setIsRecording(true); - - // Calculate order_index based on current Wheel position - // - At end: use appendOrderIndexRef (increments automatically) - // - In middle: use selected item's order_index + 1 (to insert BELOW the selected item) - const isAtEnd = - allItems.length === 0 || currentInsertionIndex >= allItems.length; + // 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) { - // At end: use append mode - const targetOrder = appendOrderIndexRef.current; - appendOrderIndexRef.current = targetOrder + 1; - currentRecordingOrderRef.current = targetOrder; - - // Verse: use the verse from the last item in the list (could be a pill) - const lastItem = allItems[allItems.length - 1]; - const verseToUse = lastItem?.verse ?? persistedVerseRef.current ?? null; - currentRecordingVerseRef.current = verseToUse; - + appendOrderIndexRef.current = orderIndex + 1; console.log( - `🎯 Recording at END | order_index: ${targetOrder} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'}` + '📊 Updated appendOrderIndexRef:', + appendOrderIndexRef.current, + '(for next recording at end)' ); - } else { - // In middle: use the item at currentInsertionIndex (could be asset or pill) - const selectedItem = allItems[currentInsertionIndex]; - const selectedOrderIndex = - selectedItem?.order_index ?? currentInsertionIndex; - const targetOrder = selectedOrderIndex + 1; - currentRecordingOrderRef.current = targetOrder; - - // Verse: use the same verse as the selected item - const verseToUse = selectedItem?.verse ?? null; - currentRecordingVerseRef.current = verseToUse; + } - const itemName = selectedItem - ? isAsset(selectedItem) - ? selectedItem.name - : `pill-${selectedItem.verse?.from ?? 'null'}` - : 'unknown'; - console.log( - `🎯 Recording in MIDDLE | insertionIndex: ${currentInsertionIndex} | item: "${itemName}" | order_index: ${targetOrder} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'}` - ); + // If starting recording without verse, disable adding new verses + if (!verse) { + allowAddVerseRef.current = false; } - }, [isRecording, allItems, insertionIndex]); + + 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'); @@ -1657,7 +1714,15 @@ const BibleRecordingView = ({ const handleRecordingComplete = React.useCallback( async (uri: string, _duration: number, _waveformData: number[]) => { - const targetOrder = currentRecordingOrderRef.current; + // 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); @@ -1729,7 +1794,7 @@ const BibleRecordingView = ({ // Log the saved asset details console.log( - `📼 Asset saved | name: "${assetName}" | order_index: ${targetOrder} | propsOrderIndex: ${_initialOrderIndex} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'} | dynamic: ${currentDynamicVerse}` + `📼 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) @@ -1788,8 +1853,8 @@ const BibleRecordingView = ({ queryClient, targetLanguoidId, addSessionAsset, - _initialOrderIndex, - saveNameCounter + saveNameCounter, + getInsertionContext ] ); @@ -2797,12 +2862,16 @@ const BibleRecordingView = ({ 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}` + `🎡 Wheel onChange: ${insertionIndex} → ${newIndex} | ${itemDesc} ${item?.order_index}` ); setInsertionIndex(newIndex); - // Also update the ref immediately for recording callbacks - insertionIndexRef.current = newIndex; }} rowHeight={ROW_HEIGHT} className="h-full flex-1" @@ -2824,28 +2893,32 @@ const BibleRecordingView = ({ {/* Add verse button - floats above recording controls */} - {!isSelectionMode && showAddVerseButton && verseToAdd !== null && ( - - - + {!isSelectionMode && + showAddVerseButton && + verseToAdd !== null && + !isVADRecording && + allowAddVerseRef.current && ( + + + + + - - )} + )} {/* Bottom controls - absolutely positioned */} From 2e65692988fc7b0dd97e6f6c04053b347a923089 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Mon, 19 Jan 2026 16:27:10 -0800 Subject: [PATCH 28/39] Improve add verses logic --- components/AssetsDeletionDrawer.tsx | 126 +++++++++++++++++++++++++++ views/new/BibleAssetsView.tsx | 127 ++++++++++++++++++++++------ 2 files changed, 226 insertions(+), 27 deletions(-) create mode 100644 components/AssetsDeletionDrawer.tsx diff --git a/components/AssetsDeletionDrawer.tsx b/components/AssetsDeletionDrawer.tsx new file mode 100644 index 000000000..e89ca2832 --- /dev/null +++ b/components/AssetsDeletionDrawer.tsx @@ -0,0 +1,126 @@ +import { Button } from '@/components/ui/button'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle +} from '@/components/ui/drawer'; +import { Text } from '@/components/ui/text'; +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; + countdown?: number; // Countdown duration in seconds (default: 10) +} + +export const AssetsDeletionDrawer: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + description, + countdown = 10 +}) => { + const [timeLeft, setTimeLeft] = React.useState(countdown); + const [isExecuting, setIsExecuting] = React.useState(false); + + // Reset timer when drawer opens + React.useEffect(() => { + if (isOpen) { + setTimeLeft(countdown); + setIsExecuting(false); + } + }, [isOpen, countdown]); + + // Countdown timer + React.useEffect(() => { + if (!isOpen || timeLeft <= 0) return; + + const timer = setInterval(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [isOpen, timeLeft]); + + const handleConfirm = async () => { + if (timeLeft > 0 || isExecuting) return; + + setIsExecuting(true); + try { + await onConfirm(); + onClose(); + } catch (error) { + console.error('Error executing deletion:', error); + } finally { + setIsExecuting(false); + } + }; + + const isButtonDisabled = timeLeft > 0 || isExecuting; + + return ( + !open && onClose()}> + + + + + + {title} + + {description} + + + + + + + + + + + + + ); +}; diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 3fee4e5ac..28e3f52ca 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -1,4 +1,5 @@ /* 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'; @@ -32,8 +33,10 @@ 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 { BookmarkPlusIcon, + BrushCleaning, CheckCheck, CloudUpload, FlagIcon, @@ -153,7 +156,7 @@ interface ManualSeparator { const RecordingPlaceIndicator = () => ( {/* */} - {' '} + REC ); @@ -332,6 +335,7 @@ export default function BibleAssetsView() { 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; @@ -871,6 +875,49 @@ export default function BibleAssetsView() { ); // 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); + console.log( + `🔄 Name counter reset for quest ${currentQuestId.slice(0, 8)}` + ); + + 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( @@ -2197,9 +2244,6 @@ export default function BibleAssetsView() { ) : null; - // Check if there are available verses for this asset - const assetRange = getRangeForAsset(asset.id); - const hasAvailableVerses = assetRange.availableVerses.length > 0; const isSelected = selectedAssetIds.has(asset.id); const isAssetSelectedForRecording = @@ -2207,30 +2251,42 @@ export default function BibleAssetsView() { 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 - positioned above and to the right */} - {/* Only show if there are available verses and NOT in selection mode */} - {!isPublished && hasAvailableVerses && !isSelectionMode && ( - { - const range = getRangeForAsset(asset.id); - setAssetVerseSelectorState({ - isOpen: true, - assetId: asset.id, - from: range.from, - to: range.to - }); - }} - className="absolute -top-2 right-4 z-[999] rounded-full bg-primary/50 p-1.5 shadow-sm active:bg-primary/90" - > - - - )} + {/* 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" + > + + + + )} { if (hasNextPage && !isFetchingNextPage) { - fetchNextPage(); + void fetchNextPage(); } }, [hasNextPage, isFetchingNextPage, fetchNextPage]); @@ -3473,6 +3529,13 @@ export default function BibleAssetsView() { ) : null} ) : null} + {!isPublished && ( + setShowDeleteAllDrawer(true)} + /> + )} {/* Info button always visible */} )} + + {/* Delete All Assets Drawer */} + 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." + countdown={10} + /> {selectedQuest && ( Date: Mon, 19 Jan 2026 19:28:29 -0800 Subject: [PATCH 29/39] Improve performance --- views/new/BibleAssetListItem.tsx | 98 +++++++++++++++++++++++++++++++- views/new/BibleAssetsView.tsx | 50 +++++++--------- 2 files changed, 117 insertions(+), 31 deletions(-) diff --git a/views/new/BibleAssetListItem.tsx b/views/new/BibleAssetListItem.tsx index 38f73633e..f4ebcc190 100644 --- a/views/new/BibleAssetListItem.tsx +++ b/views/new/BibleAssetListItem.tsx @@ -20,6 +20,7 @@ import type { AttachmentRecord } from '@powersync/attachments'; import { CheckSquareIcon, EyeOffIcon, + GripVerticalIcon, HardDriveIcon, PauseIcon, PencilLineIcon, @@ -30,6 +31,7 @@ import { } 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'; @@ -50,7 +52,9 @@ export interface BibleAssetListItemProps { onPlay?: (assetId: string) => void | Promise; attachmentState?: AttachmentRecord; isCurrentlyPlaying?: boolean; - dragHandle?: React.ReactNode; + // 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; @@ -63,7 +67,7 @@ export interface BibleAssetListItemProps { onRename?: (assetId: string, currentName: string | null) => void; } -export const BibleAssetListItem: React.FC = ({ +const BibleAssetListItemComponent: React.FC = ({ asset, questId, isCurrentlyPlaying = false, @@ -71,7 +75,8 @@ export const BibleAssetListItem: React.FC = ({ onUpdate: _onUpdate, onPlay, attachmentState: _attachmentState, - dragHandle, + showDragHandle = false, + isDragFixed = false, isSelectionMode = false, isSelected = false, onToggleSelect, @@ -205,6 +210,21 @@ export const BibleAssetListItem: React.FC = ({ // 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 ? ( = ({ ); }; + +/** + * 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 index 28e3f52ca..8b3c239b5 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -40,7 +40,6 @@ import { CheckCheck, CloudUpload, FlagIcon, - GripVerticalIcon, InfoIcon, LockIcon, MicIcon, @@ -778,7 +777,14 @@ export default function BibleAssetsView() { 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 @@ -790,17 +796,14 @@ export default function BibleAssetsView() { return; } - // Find the asset in our list - const assetItem = listItems.find( - (item) => item.type === 'asset' && item.content.id === assetId - ); + // Find the asset using ref (stable across renders) + const asset = assetsRef.current.find((a) => a.id === assetId); - if (!assetItem || assetItem.type !== 'asset') { + if (!asset) { console.warn('Asset not found:', assetId); return; } - const asset = assetItem.content; const metadata = asset.metadata as AssetMetadata | null; const orderIndex = asset.order_index ?? 0; @@ -823,7 +826,7 @@ export default function BibleAssetsView() { verseName }); }, - [selectedForRecording?.type, selectedForRecording?.assetId, listItems] + [selectedForRecording?.type, selectedForRecording?.assetId] ); // Handler for selecting/deselecting a separator for recording insertion @@ -2154,6 +2157,12 @@ export default function BibleAssetsView() { [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, @@ -2226,24 +2235,6 @@ export default function BibleAssetsView() { audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && currentlyPlayingAssetId === asset.id; - // Only show drag handle when NOT in selection mode - const dragHandle = - !isPublished && !isSelectionMode ? ( - - - - ) : null; - const isSelected = selectedAssetIds.has(asset.id); const isAssetSelectedForRecording = @@ -2294,9 +2285,11 @@ export default function BibleAssetsView() { questId={currentQuestId || ''} isCurrentlyPlaying={isPlaying} onUpdate={handleAssetUpdate} - onPlay={(assetId) => handlePlayAssetRef.current(assetId)} + onPlay={stableOnPlay} isPublished={isPublished} - dragHandle={dragHandle} + // Drag handle props (primitives instead of ReactNode) + showDragHandle={!isPublished && !isSelectionMode} + isDragFixed={fixedItemsIndexesRef.current.includes(index)} // Selection mode only works when NOT published isSelectionMode={!isPublished && isSelectionMode} isSelected={!isPublished && isSelected} @@ -2322,6 +2315,7 @@ export default function BibleAssetsView() { audioContext.currentAudioId, currentlyPlayingAssetId, handleAssetUpdate, + stableOnPlay, getRangeForAsset, isSelectionMode, selectedAssetIds, From 515c255aa27bd01bc5413a56f1ef8fd79c569ee0 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Mon, 19 Jan 2026 19:49:00 -0800 Subject: [PATCH 30/39] Delete unused component --- .../components/NewRecordingViewSimplified.tsx | 2996 ----------------- 1 file changed, 2996 deletions(-) delete mode 100644 views/new/recording/components/NewRecordingViewSimplified.tsx diff --git a/views/new/recording/components/NewRecordingViewSimplified.tsx b/views/new/recording/components/NewRecordingViewSimplified.tsx deleted file mode 100644 index c450b28f2..000000000 --- a/views/new/recording/components/NewRecordingViewSimplified.tsx +++ /dev/null @@ -1,2996 +0,0 @@ -import type { ArrayInsertionWheelHandle } from '@/components/ArrayInsertionWheel'; -import ArrayInsertionWheel from '@/components/ArrayInsertionWheel'; -import { VerseAssigner } from '@/components/VerseAssigner'; -import { VerseSeparator } from '@/components/VerseSeparator'; -import { Button } from '@/components/ui/button'; -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle -} from '@/components/ui/drawer'; -import { Icon } from '@/components/ui/icon'; -import { Text } from '@/components/ui/text'; -import { BIBLE_BOOKS } from '@/constants/bibleStructure'; -import { useAudio } from '@/contexts/AudioContext'; -import { useAuth } from '@/contexts/AuthContext'; -import type { AssetMetadata } from '@/database_services/assetService'; -import { - batchUpdateAssetMetadata, - renameAsset, - updateAssetMetadata -} from '@/database_services/assetService'; -import { audioSegmentService } from '@/database_services/audioSegmentService'; -import { - asset, - asset_content_link, - project_language_link, - quest_asset_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 type { LegendListRef } from '@legendapp/list'; -import { LegendList } from '@legendapp/list'; -import { toCompilableQuery } from '@powersync/drizzle-driver'; -import { useQueryClient } from '@tanstack/react-query'; -import { and, asc, eq, getTableColumns } from 'drizzle-orm'; -import { Audio } from 'expo-av'; -import { - ArrowLeft, - ArrowUpDown, - PauseIcon, - PlayIcon -} from 'lucide-react-native'; -import React from 'react'; -import { InteractionManager, View } from 'react-native'; -import { ScrollView as GHScrollView } from 'react-native-gesture-handler'; -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 { getNextOrderIndex, saveRecording } from '../services/recordingService'; -import { FullScreenVADOverlay } from './FullScreenVADOverlay'; -import { LabeledAssetCard } from './LabeledAssetCard'; -import { RecordingControls } from './RecordingControls'; -import { RenameAssetModal } from './RenameAssetModal'; -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 { - id: string; - name: string; - created_at: string; - order_index: number; - source: 'local' | 'synced' | 'cloud'; - segmentCount: number; - duration?: number; // Total duration in milliseconds - metadata?: string | { verse?: { from: number; to: number } } | null; -} - -interface RecordingViewSimplifiedProps { - onBack: () => void; - // Pass existing assets as initial data to avoid redundant query - initialAssets?: unknown[]; -} - -const RecordingViewSimplified = ({ - onBack, - initialAssets -}: RecordingViewSimplifiedProps) => { - const queryClient = useQueryClient(); - const { t } = useLocalization(); - const navigation = useCurrentNavigation(); - const { currentQuestId, currentProjectId, currentBookId, currentQuestData } = - 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 [showVADSettings, setShowVADSettings] = React.useState(false); - const [autoCalibrateOnOpen, setAutoCalibrateOnOpen] = React.useState(false); - - // Track current recording order index - const currentRecordingOrderRef = React.useRef(0); - 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()); - - // 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); - - // 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); - - // Sort order state: 'original' = by recording order, 'verse' = by verse metadata - const [sortOrder, setSortOrder] = React.useState<'original' | 'verse'>( - 'verse' - ); - - // Track footer height for proper scrolling - const [footerHeight, setFooterHeight] = React.useState(0); - const ROW_HEIGHT = 80; - - // Selection mode for batch operations (merge, delete) - const { - isSelectionMode, - selectedAssetIds, - enterSelection, - toggleSelect, - cancelSelection - } = useSelectionMode(); - - // Rename modal state - const [showRenameModal, setShowRenameModal] = React.useState(false); - const [renameAssetId, setRenameAssetId] = React.useState(null); - const [renameAssetName, setRenameAssetName] = React.useState(''); - - // Verse assigner modal state - const [showVerseAssignerModal, setShowVerseAssignerModal] = - React.useState(false); - - // 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()); - - // Load quest data to get verse count - const questTable = resolveTable('quest', { localOverride: true }); - type Quest = typeof questTable.$inferSelect; - const { data: queriedQuestData } = 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) - const selectedQuest = React.useMemo(() => { - if (queriedQuestData.length > 0) { - return queriedQuestData[0]; - } - if (currentQuestData) { - return currentQuestData as Quest; - } - return undefined; - }, [currentQuestData, queriedQuestData]); - - // Store book name and chapter number for VerseSeparator label - const bookChapterLabelRef = React.useRef('Verse'); - - // Calculate book chapter label - 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 - 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]); - - // Load assets from database - // Use initialAssets if provided to avoid redundant query and instant render - const { - data: rawAssets = [], - isOfflineLoading, - isError, - offlineError - } = useHybridData({ - dataType: 'assets', - queryKeyParams: [currentQuestId], - offlineQuery: toCompilableQuery( - system.db - .select({ - ...getTableColumns(asset), - quest_id: quest_asset_link.quest_id - }) - .from(asset) - .innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id)) - .where(eq(quest_asset_link.quest_id, currentQuestId!)) - .orderBy(asc(asset.order_index), asc(asset.created_at), asc(asset.name)) - ), - cloudQueryFn: async () => { - const { data, error } = await system.supabaseConnector.client - .from('quest_asset_link') - .select('asset:asset_id(*)') - .eq('quest_id', currentQuestId) - .order('order_index', { ascending: true }) - .order('created_at', { ascending: true }) - .order('name', { ascending: true }); - if (error) throw error; - - return data.map((d: { asset: unknown }) => d.asset).filter(Boolean); - }, - enableOfflineQuery: true, - enableCloudQuery: true, - lazyLoadCloud: true, // Show local data immediately - getItemId: (item) => { - const typedItem = item as unknown as { id: string }; - return typedItem.id; - }, - // Use initial data if provided - renders instantly with cached data - offlineQueryOptions: initialAssets - ? { - initialData: initialAssets, - staleTime: 0 // Still refetch to ensure fresh data - } - : undefined - }); - - // 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'; - metadata?: string | { 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 metadata - if (obj.metadata) { - debugLog( - `📋 Asset "${obj.name}" (${obj.id.slice(0, 8)}) has metadata:`, - obj.metadata - ); - } - - // DEBUG: Log assets with multiple segments - if (segmentCount > 1) { - debugLog( - `📊 Asset "${obj.name}" (${obj.id.slice(0, 8)}) has ${segmentCount} segments` - ); - } - - return { - 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, - metadata: obj.metadata - }; - }); - - // 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]); - - // 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 asset list that only updates when content actually changes - // Sorted by verse range (assets without metadata go to the bottom) or by original order - const assetsForLegendList = React.useMemo(() => { - if (sortOrder === 'original') { - // Return assets in their original order (as they come from the database) - return assets; - } - - // Sort by verse metadata (verse.from) - // Create a copy to avoid mutating the original array - const sorted = [...assets].sort((a, b) => { - // If one doesn't have metadata, it goes to the bottom - if (!a.metadata && !b.metadata) return 0; // Both without metadata: maintain order - if (!a.metadata) return 1; // a goes to bottom - if (!b.metadata) return -1; // b goes to bottom - - // Parse JSON metadata if it's a string - let aMetadata: unknown; - let bMetadata: unknown; - - try { - aMetadata = - typeof a.metadata === 'string' ? JSON.parse(a.metadata) : a.metadata; - bMetadata = - typeof b.metadata === 'string' ? JSON.parse(b.metadata) : b.metadata; - } catch { - // If parsing fails, treat as no metadata (goes to bottom) - if (!aMetadata) return 1; - if (!bMetadata) return -1; - return 0; - } - - // Extract verse range - const aVerse = - aMetadata && typeof aMetadata === 'object' && 'verse' in aMetadata - ? (aMetadata as { verse?: { from?: number; to?: number } }).verse - ?.from - : undefined; - const bVerse = - bMetadata && typeof bMetadata === 'object' && 'verse' in bMetadata - ? (bMetadata as { verse?: { from?: number; to?: number } }).verse - ?.from - : undefined; - - // If verse is undefined, treat as no metadata (goes to bottom) - if (aVerse === undefined && bVerse === undefined) return 0; - if (aVerse === undefined) return 1; // a goes to bottom - if (bVerse === undefined) return -1; // b goes to bottom - - // Both have verse ranges, compare them - return aVerse - bVerse; - }); - - return sorted; - }, [assets, sortOrder]); - - // Helper function to extract verse from metadata - const getVerseFromMetadata = React.useCallback( - ( - metadata: - | string - | { verse?: { from: number; to: number } } - | null - | undefined - ): { - from?: number; - to?: number; - } | null => { - if (!metadata) return null; - - try { - const parsed: unknown = - typeof metadata === 'string' ? JSON.parse(metadata) : metadata; - - if ( - parsed && - typeof parsed === 'object' && - 'verse' in parsed && - parsed.verse && - typeof parsed.verse === 'object' && - 'from' in parsed.verse - ) { - const verse = parsed.verse as { from: unknown; to?: unknown }; - const from = typeof verse.from === 'number' ? verse.from : undefined; - const to = - typeof verse.to === 'number' - ? verse.to - : typeof verse.from === 'number' - ? verse.from - : undefined; - - if (from !== undefined) { - return { from, to }; - } - } - } catch { - // Ignore parsing errors - } - - return null; - }, - [] - ); - - // Calculate total number of elements in the wheel (including separators) - // This needs to match the logic in wheelChildren to ensure correct clamping - const totalWheelItems = React.useMemo(() => { - let separatorCount = 0; - - if (sortOrder === 'verse') { - assetsForLegendList.forEach((item, index) => { - const currentVerse = getVerseFromMetadata(item.metadata); - const prevItem = index > 0 ? assetsForLegendList[index - 1] : null; - const prevVerse = prevItem - ? getVerseFromMetadata(prevItem.metadata) - : null; - - // Check if this is the start of a new verse group - if (index === 0) { - separatorCount++; - } else if (!currentVerse && prevVerse) { - separatorCount++; - } else if (currentVerse && !prevVerse) { - separatorCount++; - } else if (currentVerse && prevVerse) { - if ( - currentVerse.from !== prevVerse.from || - (currentVerse.to ?? currentVerse.from) !== - (prevVerse.to ?? prevVerse.from) - ) { - separatorCount++; - } - } - }); - } - - // Total = assets + separators - return assetsForLegendList.length + separatorCount; - }, [assetsForLegendList, sortOrder, getVerseFromMetadata]); - - // Clamp insertion index when wheel items count changes - // Note: insertionIndex represents insertion boundaries, so maxIndex = totalWheelItems - // (can insert at 0..N boundaries, where N is the number of items) - React.useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (USE_INSERTION_WHEEL) { - const maxIndex = totalWheelItems; // Can insert at 0..N (after last item) - if (insertionIndex > maxIndex) { - debugLog( - `📍 Clamping insertion index from ${insertionIndex} to ${maxIndex} (total wheel items: ${totalWheelItems})` - ); - setInsertionIndex(maxIndex); - } - } - }, [totalWheelItems, insertionIndex]); - - // Ref for LegendList to enable scrolling - const listRef = React.useRef(null); - - // Track asset count to detect new insertions - const previousAssetCountRef = React.useRef(assets.length); - - // Auto-scroll behavior differs between list and wheel - React.useEffect(() => { - const currentCount = assets.length; - const previousCount = previousAssetCountRef.current; - - // Only scroll if a new asset was added (count increased) - if (currentCount > previousCount && currentCount > 0) { - debugLog('📜 Auto-scrolling to new asset'); - - // Small delay to ensure the new item is rendered before scrolling - setTimeout(() => { - try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (USE_INSERTION_WHEEL) { - // For wheel: scroll to the newly inserted item's position - // After insertion at index N, the new item is at position N - const newItemIndex = Math.min(insertionIndex, currentCount - 1); - wheelRef.current?.scrollToInsertionIndex(newItemIndex + 1, true); - } else { - // For list: scroll to end - listRef.current?.scrollToEnd({ animated: true }); - } - } catch (error) { - console.error('Failed to scroll:', error); - } - }, 100); - } - - previousAssetCountRef.current = currentCount; - }, [assets.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. - }, [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 - // ============================================================================ - - // Store insertion index in ref to prevent stale closure issues - const insertionIndexRef = React.useRef(insertionIndex); - React.useEffect(() => { - insertionIndexRef.current = insertionIndex; - }, [insertionIndex]); - - // Initialize VAD counter when VAD mode activates - React.useEffect(() => { - if (isVADLocked && vadCounterRef.current === null) { - // CRITICAL: Use ref to get the LATEST insertionIndex value - // This prevents issues when fullscreen overlay blocks the wheel and causes - // insertionIndex state updates to be delayed or missed - const currentInsertionIndex = insertionIndexRef.current; - const currentAssets = assets; - - debugLog( - `🎯 VAD initializing | insertionIndex (ref): ${currentInsertionIndex} | insertionIndex (state): ${insertionIndex} | assets.length: ${currentAssets.length}` - ); - - void (async () => { - let targetOrder: number; - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (USE_INSERTION_WHEEL) { - // Respect insertion wheel position (same logic as manual recordings) - // insertionIndex is the boundary BEFORE an item - // When at bottom (insertionIndex === assets.length), append to end - // When in middle, insert after the currently viewed item - - if (currentInsertionIndex >= currentAssets.length) { - // At or past the end - append - targetOrder = - currentAssets.length > 0 - ? (currentAssets[currentAssets.length - 1]?.order_index ?? - currentAssets.length - 1) + 1 - : 0; - debugLog( - `🎯 VAD: At bottom, appending with order_index: ${targetOrder}` - ); - } else { - // In the middle - insert after current item - const actualInsertionIndex = currentInsertionIndex + 1; - if (actualInsertionIndex < currentAssets.length) { - targetOrder = - currentAssets[actualInsertionIndex]?.order_index ?? - actualInsertionIndex; - } else { - targetOrder = - currentAssets.length > 0 - ? (currentAssets[currentAssets.length - 1]?.order_index ?? - currentAssets.length - 1) + 1 - : 0; - } - debugLog( - `🎯 VAD: In middle at visual index ${currentInsertionIndex}, inserting at order_index: ${targetOrder}` - ); - } - } else { - // Legacy: append to end - targetOrder = await getNextOrderIndex(currentQuestId!); - debugLog(`🎯 VAD counter initialized to end: ${targetOrder}`); - } - - vadCounterRef.current = targetOrder; - })(); - } else if (!isVADLocked) { - vadCounterRef.current = null; - } - // IMPORTANT: Only depend on isVADLocked and currentQuestId - // insertionIndex is read from ref to avoid stale closure issues - // assets is captured from closure (intentional - we want the state at activation time) - }, [isVADLocked, currentQuestId, assets, insertionIndex]); - - // Manual recording handlers - const handleRecordingStart = React.useCallback(() => { - if (isRecording) return; - debugLog('🎬 Manual recording start'); - setIsRecording(true); - - // Set order index for manual recording - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (USE_INSERTION_WHEEL) { - // IMPORTANT: insertionIndex is the boundary BEFORE an item - // When user sees item 0 centered, insertionIndex = 0 (before item 0) - // But they want to insert AFTER the item they're viewing - // So we use insertionIndex + 1 for the actual insertion position - const actualInsertionIndex = insertionIndex + 1; - - const targetOrder = - actualInsertionIndex < assets.length - ? (assets[actualInsertionIndex]?.order_index ?? actualInsertionIndex) - : (assets[assets.length - 1]?.order_index ?? assets.length - 1) + 1; - currentRecordingOrderRef.current = targetOrder; - debugLog( - `🎯 Recording will insert AFTER item at visual index ${insertionIndex} (boundary ${actualInsertionIndex}) with order_index ${targetOrder}` - ); - } else { - // Legacy: append to end - const targetOrder = - assets.length > 0 - ? (assets[assets.length - 1]?.order_index ?? 0) + 1 - : 0; - currentRecordingOrderRef.current = targetOrder; - } - }, [isRecording, assets, insertionIndex]); - - const handleRecordingStop = React.useCallback(() => { - debugLog('🛑 Manual recording stop'); - setIsRecording(false); - }, []); - - const handleRecordingDiscarded = React.useCallback(() => { - debugLog('🗑️ Recording discarded'); - setIsRecording(false); - }, []); - - // Helper function to determine verse at insertion position when sorting by verse - const getVerseAtInsertionIndex = - React.useCallback((): AssetMetadata | null => { - if (sortOrder !== 'verse' || assetsForLegendList.length === 0) { - return null; - } - - // insertionIndex represents the insertion point in the wheelChildren array - // We need to find which verse group this insertion point belongs to - let wheelPosition = 0; - - for (let i = 0; i < assetsForLegendList.length; i++) { - const item = assetsForLegendList[i]; - if (!item) continue; - - // Check if we need a separator before this item - const currentVerse = getVerseFromMetadata(item.metadata); - const prevItem = i > 0 ? assetsForLegendList[i - 1] : null; - const prevVerse = prevItem - ? getVerseFromMetadata(prevItem.metadata) - : null; - - let shouldShowVerseSeparator = false; - if (i === 0) { - shouldShowVerseSeparator = true; - } else if (!currentVerse && prevVerse) { - shouldShowVerseSeparator = true; - } else if (currentVerse && !prevVerse) { - shouldShowVerseSeparator = true; - } else if (currentVerse && prevVerse) { - shouldShowVerseSeparator = - currentVerse.from !== prevVerse.from || - (currentVerse.to ?? currentVerse.from) !== - (prevVerse.to ?? prevVerse.from); - } - - // If insertionIndex is at or before this separator, return the verse - if (shouldShowVerseSeparator) { - if (insertionIndex <= wheelPosition) { - if (currentVerse?.from !== undefined) { - return { - verse: { - from: currentVerse.from, - to: currentVerse.to ?? currentVerse.from - } - }; - } - return null; - } - wheelPosition++; // Separator takes one position - } - - // Check if insertionIndex is at or before this asset - if (insertionIndex <= wheelPosition) { - const verse = getVerseFromMetadata(item.metadata); - if (verse?.from !== undefined) { - return { - verse: { - from: verse.from, - to: verse.to ?? verse.from - } - }; - } - return null; - } - wheelPosition++; // Asset takes one position - } - - // If insertionIndex is after all items, use the verse of the last item - const lastItem = assetsForLegendList[assetsForLegendList.length - 1]; - const verse = lastItem ? getVerseFromMetadata(lastItem.metadata) : null; - if (verse?.from !== undefined) { - return { - verse: { - from: verse.from, - to: verse.to ?? verse.from - } - }; - } - - return null; - }, [sortOrder, assetsForLegendList, insertionIndex, getVerseFromMetadata]); - - const handleRecordingComplete = React.useCallback( - async (uri: string, _duration: number, _waveformData: number[]) => { - const targetOrder = currentRecordingOrderRef.current; - - try { - debugLog('💾 Saving recording | order_index:', targetOrder); - - // Validate required data - if ( - !currentProjectId || - !currentQuestId || - !currentProject || - !currentUser - ) { - console.error('❌ Missing required data'); - return; - } - - // Generate name immediately and reserve it to prevent duplicates - // In VAD mode: Use the VAD counter which is already incremented per segment - // In manual mode: Use total count (existing + pending) for simple sequential naming - const nextNumber = isVADLocked - ? targetOrder + 1 // VAD: use order_index + 1 for naming (order is 0-based, names are 1-based) - : assets.length + pendingAssetNamesRef.current.size + 1; - const assetName = String(nextNumber).padStart(3, '0'); - pendingAssetNamesRef.current.add(assetName); - debugLog( - `🏷️ Reserved name: ${assetName} (${isVADLocked ? 'VAD mode' : 'manual mode'}) | order_index: ${targetOrder}, asset count: ${assets.length}, pending: ${pendingAssetNamesRef.current.size}` - ); - - // 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) - let newAssetId: string | undefined; - dbWriteQueueRef.current = dbWriteQueueRef.current - .then(async () => { - if (!targetLanguoidId) { - throw new Error('Target languoid not found for project'); - } - const assetId = await saveRecording({ - questId: currentQuestId, - projectId: currentProjectId, - targetLanguoidId: targetLanguoidId, - userId: currentUser.id, - orderIndex: targetOrder, - audioUri: localUri, - assetName: assetName // Pass the reserved name - }); - newAssetId = assetId; - // 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; - - // If sorting by verse, automatically apply verse metadata to the new asset - if (sortOrder === 'verse' && newAssetId) { - try { - const verseMetadata = getVerseAtInsertionIndex(); - if (verseMetadata) { - await updateAssetMetadata(newAssetId, verseMetadata); - debugLog( - `✅ Applied verse metadata to new asset: ${JSON.stringify(verseMetadata)}` - ); - } - } catch (error) { - console.error('❌ Failed to apply verse metadata:', error); - // Don't throw - asset was created successfully, metadata is optional - } - } - - // Invalidate queries to refresh asset list - if (!isVADLocked) { - 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, - isVADLocked, - assets, - targetLanguoidId, - sortOrder, - getVerseAtInsertionIndex - ] - ); - - // VAD segment handlers - const handleVADSegmentStart = React.useCallback(() => { - if (vadCounterRef.current === null) { - console.error('❌ VAD counter not initialized!'); - return; - } - - const targetOrder = vadCounterRef.current; - debugLog('🎬 VAD: Segment starting | order_index:', targetOrder); - - currentRecordingOrderRef.current = targetOrder; - vadCounterRef.current = targetOrder + 1; // Increment for next segment - }, []); - - 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(); - - // 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 - setTimeout(() => { - void processBatch(startIdx + BATCH_SIZE); - }, 16); // One frame delay (60fps) - } - } 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(); - }; - // Depend on assetIds, assetMetadata, and state maps - // State maps are included so we detect when durations are missing (e.g., after remount) - // The effect safely handles updates by only loading missing assets - }, [assetIds, assetMetadata, assetSegmentCounts, assetDurations]); - - // ============================================================================ - // ASSET OPERATIONS (Delete, Merge) - // ============================================================================ - - const handleDeleteLocalAsset = React.useCallback( - async (assetId: string) => { - try { - await audioSegmentService.deleteAudioSegment(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); - - // 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); - } - - // 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); - } - - 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]); - - // Collect existing verse labels from all assets - const existingLabels = React.useMemo(() => { - const labelsMap = new Map(); - - for (const asset of assets) { - if (!asset.metadata) continue; - - 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; - 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' - ) { - const key = `${verse.from}-${verse.to}`; - if (!labelsMap.has(key)) { - labelsMap.set(key, { from: verse.from, to: verse.to }); - } - } - } - } - } catch { - // Skip invalid metadata - } - } - - return Array.from(labelsMap.values()).sort((a, b) => { - if (a.from !== b.from) return a.from - b.from; - return a.to - b.to; - }); - }, [assets]); - - // Calculate available verses (excluding occupied ones) - const availableVerses = React.useMemo(() => { - if (verseCount === 0) return []; - - // Create a set of occupied verses - const occupiedVerses = new Set(); - for (const label of existingLabels) { - for (let verse = label.from; verse <= label.to; verse++) { - occupiedVerses.add(verse); - } - } - - // Return array of available verses (1 to verseCount, excluding occupied) - const available: number[] = []; - for (let verse = 1; verse <= verseCount; verse++) { - if (!occupiedVerses.has(verse)) { - available.push(verse); - } - } - - return available; - }, [existingLabels, 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) => { - // 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 first existing label that starts after selectedFrom - const sortedLabels = [...existingLabels].sort((a, b) => a.from - b.from); - const nextLabel = sortedLabels.find((label) => label.from > selectedFrom); - - if (nextLabel) { - // Return the verse just before the next label starts - return nextLabel.from - 1; - } - - // No label after selectedFrom, can go to the end - return verseCount || 1; - }, - [existingLabels, verseCount, availableVerses] - ); - - // Check if selected assets have verse labels - const hasSelectedAssetsWithLabels = React.useMemo(() => { - const selectedAssets = assets.filter( - (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' - ); - return selectedAssets.some((asset) => { - if (!asset.metadata) return false; - try { - const metadata: unknown = - typeof asset.metadata === 'string' - ? JSON.parse(asset.metadata) - : asset.metadata; - if ( - metadata && - typeof metadata === 'object' && - 'verse' in metadata && - metadata.verse && - typeof metadata.verse === 'object' && - 'from' in metadata.verse && - 'to' in metadata.verse - ) { - return true; - } - } catch { - // Skip invalid metadata - } - return false; - }); - }, [assets, selectedAssetIds]); - - // Handle verse assignment to selected assets - const handleAssignVerse = React.useCallback( - (from: number, to: number) => { - const selectedOrdered = assets.filter( - (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' - ); - if (selectedOrdered.length < 1) return; - - void (async () => { - try { - const updates = selectedOrdered.map((asset) => ({ - assetId: asset.id, - metadata: { verse: { from, to } } as AssetMetadata - })); - - await batchUpdateAssetMetadata(updates); - - cancelSelection(); - setShowVerseAssignerModal(false); - await queryClient.invalidateQueries({ - queryKey: ['assets', 'by-quest', currentQuestId], - exact: false - }); - - debugLog( - `✅ Verse assignment completed: ${selectedOrdered.length} assets assigned verse ${from}-${to}` - ); - } catch (e) { - console.error('Failed to assign verse to assets', e); - RNAlert.alert( - 'Error', - 'Failed to assign verse to assets. Please try again.' - ); - } - })(); - }, - [assets, selectedAssetIds, cancelSelection, queryClient, currentQuestId] - ); - - // Handle verse label removal from selected assets - const handleRemoveVerse = React.useCallback(() => { - const selectedOrdered = assets.filter( - (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' - ); - if (selectedOrdered.length < 1) return; - - void (async () => { - try { - // Remove verse metadata while preserving other metadata properties - const updates = selectedOrdered.map((asset) => { - let newMetadata: AssetMetadata | null = null; - - // Parse existing metadata if it exists - if (asset.metadata) { - try { - const existingMetadata: unknown = - typeof asset.metadata === 'string' - ? JSON.parse(asset.metadata) - : asset.metadata; - - if (existingMetadata && typeof existingMetadata === 'object') { - // Create new metadata object without the verse property - const { verse, ...rest } = existingMetadata as { - verse?: unknown; - [key: string]: unknown; - }; - // Only keep metadata if there are other properties, otherwise set to null - newMetadata = - Object.keys(rest).length > 0 ? (rest as AssetMetadata) : null; - } - } catch { - // If parsing fails, set to null - newMetadata = null; - } - } - - return { - assetId: asset.id, - metadata: newMetadata - }; - }); - - await batchUpdateAssetMetadata(updates); - - cancelSelection(); - setShowVerseAssignerModal(false); - await queryClient.invalidateQueries({ - queryKey: ['assets', 'by-quest', currentQuestId], - exact: false - }); - - debugLog( - `✅ Verse removal completed: ${selectedOrdered.length} assets had verse labels removed` - ); - } catch (e) { - console.error('Failed to remove verse from assets', e); - RNAlert.alert( - 'Error', - 'Failed to remove verse labels from assets. Please try again.' - ); - } - })(); - }, [assets, selectedAssetIds, cancelSelection, queryClient, currentQuestId]); - - // ============================================================================ - // RENAME ASSET - // ============================================================================ - - const handleRenameAsset = React.useCallback( - (assetId: string, currentName: string | null) => { - setRenameAssetId(assetId); - setRenameAssetName(currentName ?? ''); - setShowRenameModal(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 - 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] - ); - - // ============================================================================ - // 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 - ]); - - // Memoized render function for LegendList - // OPTIMIZED: No audioContext.position dependency - progress now uses SharedValues! - // This eliminates 10 re-renders/second during audio playback - const renderAssetItem = React.useCallback( - ({ item, index }: { item: UIAsset; index: number }) => { - // 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); - const canMergeDown = - index < assets.length - 1 && assets[index + 1]?.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; - - return ( - { - if (isSelectionMode) { - stableToggleSelect(item.id); - } else { - void stableHandlePlayAsset(item.id); - } - }} - onLongPress={() => { - stableEnterSelection(item.id); - }} - onPlay={() => { - void stableHandlePlayAsset(item.id); - }} - onDelete={stableHandleDeleteLocalAsset} - onMerge={stableHandleMergeDownLocal} - onRename={stableHandleRenameAsset} - /> - ); - }, - [ - audioContext.isPlaying, - audioContext.currentAudioId, - currentlyPlayingAssetId, - // audioContext.position REMOVED - uses SharedValues now! - // audioContext.duration REMOVED - not needed for render - selectedAssetIds, - isSelectionMode, - assets, - sortOrder, - stableHandlePlayAsset, - stableToggleSelect, - stableEnterSelection, - stableHandleDeleteLocalAsset, - stableHandleMergeDownLocal, - stableHandleRenameAsset - ] - ); - - // Memoized children for ArrayInsertionWheel - // OPTIMIZED: No audioContext.position/duration dependencies - progress now uses SharedValues! - // This eliminates re-creating all children 10+ times per second during audio playback - const wheelChildren = React.useMemo(() => { - // Map assets to wheel items - return assetsForLegendList - .map((item, index) => { - // 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); - const canMergeDown = - index < assetsForLegendList.length - 1 && - assetsForLegendList[index + 1]?.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; - - // Check if we need to show VerseSeparator (when sorting by verse) - let shouldShowVerseSeparator = false; - let currentVerse: { from?: number; to?: number } | null = null; - - if (sortOrder === 'verse') { - currentVerse = getVerseFromMetadata(item.metadata); - const prevItem = index > 0 ? assetsForLegendList[index - 1] : null; - const prevVerse = prevItem - ? getVerseFromMetadata(prevItem.metadata) - : null; - - // Check if this is the start of a new verse group - if (index === 0) { - // First item - always show separator - shouldShowVerseSeparator = true; - } else if (!currentVerse && prevVerse) { - // Transition from verse to no verse - shouldShowVerseSeparator = true; - } else if (currentVerse && !prevVerse) { - // Transition from no verse to verse - shouldShowVerseSeparator = true; - } else if (currentVerse && prevVerse) { - // Both have verses - check if they're different - shouldShowVerseSeparator = - currentVerse.from !== prevVerse.from || - (currentVerse.to ?? currentVerse.from) !== - (prevVerse.to ?? prevVerse.from); - } - } - - // Return array with separator (if needed) and card as separate items - const items: React.ReactNode[] = []; - - // Add separator as a separate list item if needed - if (shouldShowVerseSeparator) { - items.push( - - ); - } - - // Add asset card as a separate list item - items.push( - { - if (isSelectionMode) { - stableToggleSelect(item.id); - } else { - void stableHandlePlayAsset(item.id); - } - }} - onLongPress={() => { - stableEnterSelection(item.id); - }} - onPlay={() => { - void stableHandlePlayAsset(item.id); - }} - onDelete={stableHandleDeleteLocalAsset} - onMerge={stableHandleMergeDownLocal} - onRename={stableHandleRenameAsset} - /> - ); - - return items; - }) - .flat(); - }, [ - assetsForLegendList, - sortOrder, - getVerseFromMetadata, - audioContext.isPlaying, - audioContext.currentAudioId, - currentlyPlayingAssetId, - // assetProgressSharedMap REMOVED - it's a ref, accessed directly in render - // audioContext.position REMOVED - uses SharedValues now! - // audioContext.duration REMOVED - not needed for render - selectedAssetIds, - isSelectionMode, - stableHandlePlayAsset, - stableToggleSelect, - stableEnterSelection, - stableHandleDeleteLocalAsset, - stableHandleMergeDownLocal, - stableHandleRenameAsset - ]); - - // Render loading state - if (isOfflineLoading) { - return ( - - - {t('loading') || 'Loading assets...'} - - - ); - } - - // Render error state - if (isError && offlineError) { - return ( - - Error loading assets - - {offlineError.message} - - - ); - } - - // Show full-screen overlay when VAD is locked and display mode is fullscreen - const showFullScreenOverlay = isVADLocked && vadDisplayMode === 'fullscreen'; - - return ( - - {/* Full-screen VAD overlay - takes over entire screen */} - {showFullScreenOverlay && ( - { - // Cancel VAD mode - setIsVADLocked(false); - }} - /> - )} - - {/* Header */} - - - - - {t('doRecord')} - - - {t('assets')} ({assets.length}) - - - {assets.length > 0 && ( - - )} - - - {/* Scrollable list area - full height with padding for controls */} - - {/* Sort button - positioned absolutely at the top */} - - - - - {assets.length === 0 && ( - - - No assets yet. Start recording to create your first asset. - - - )} - - {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */} - {USE_INSERTION_WHEEL ? ( - // ArrayInsertionWheel mode - always show wheel, even when empty - - {wheelChildren} - - ) : ( - // LegendList mode (legacy) - assetsForLegendList.length > 0 && ( - - ) - )} - - - {/* Bottom controls - absolutely positioned */} - - {isSelectionMode ? ( - - setShowVerseAssignerModal(true)} - /> - - ) : ( - setShowVADSettings(true)} - onAutoCalibratePress={() => { - setAutoCalibrateOnOpen(true); - setShowVADSettings(true); - }} - currentEnergy={currentEnergy} - vadThreshold={vadThreshold} - energyShared={energyShared} - isRecordingShared={isRecordingShared} - displayMode={vadDisplayMode} - /> - )} - - - {/* Rename modal */} - setShowRenameModal(false)} - onSave={handleSaveRename} - /> - - {/* Verse Assigner Drawer */} - { - if (!open) { - setShowVerseAssignerModal(false); - } - }} - snapPoints={['50%']} - enableDynamicSizing={false} - > - - - Assign Verse Label - - - setShowVerseAssignerModal(false)} - /> - - - - - {/* 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} - /> - - ); -}; - -export default RecordingViewSimplified; From 94da0550b86815e46c8d60b3a40988ded468ed3f Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Tue, 20 Jan 2026 06:34:27 -0800 Subject: [PATCH 31/39] Improving performance on the Recording View --- components/VersePill.tsx | 28 ++++++- .../components/BibleRecordingView.tsx | 80 +++++++++++++++---- 2 files changed, 89 insertions(+), 19 deletions(-) diff --git a/components/VersePill.tsx b/components/VersePill.tsx index b50c334b1..def7081ee 100644 --- a/components/VersePill.tsx +++ b/components/VersePill.tsx @@ -8,11 +8,11 @@ interface VersePillProps { largeText?: boolean; } -export function VersePill({ +const VersePillComponent = ({ text, className = '', largeText = false -}: VersePillProps) { +}: VersePillProps) => { return ( ); -} +}; + +/** + * 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/views/new/recording/components/BibleRecordingView.tsx b/views/new/recording/components/BibleRecordingView.tsx index 55456b6e8..de97ac9b4 100644 --- a/views/new/recording/components/BibleRecordingView.tsx +++ b/views/new/recording/components/BibleRecordingView.tsx @@ -2608,6 +2608,57 @@ const BibleRecordingView = ({ 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]); + // Memoized render function for LegendList // OPTIMIZED: No audioContext.position dependency - progress now uses SharedValues! // This eliminates 10 re-renders/second during audio playback @@ -2734,6 +2785,15 @@ const BibleRecordingView = ({ ? 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 null; + } + return ( { - if (isSelectionMode) { - stableToggleSelect(item.id); - } else { - void stableHandlePlayAsset(item.id); - } - }} - onLongPress={() => { - stableEnterSelection(item.id); - }} - onPlay={() => { - void stableHandlePlayAsset(item.id); - }} + onPress={callbacks.onPress} + onLongPress={callbacks.onLongPress} + onPlay={callbacks.onPlay} onDelete={stableHandleDeleteLocalAsset} onMerge={stableHandleMergeDownLocal} onRename={stableHandleRenameAsset} @@ -2776,9 +2826,7 @@ const BibleRecordingView = ({ // audioContext.duration REMOVED - not needed for render selectedAssetIds, isSelectionMode, - stableHandlePlayAsset, - stableToggleSelect, - stableEnterSelection, + assetCallbacksMap, // OPTIMIZED: Map of stable callbacks per asset stableHandleDeleteLocalAsset, stableHandleMergeDownLocal, stableHandleRenameAsset From 16078fb04f911b9e0afff8a27f95759766cf2975 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Wed, 21 Jan 2026 06:46:23 -0800 Subject: [PATCH 32/39] Identify verse being recorded --- services/localizations.ts | 14 ++++ views/new/BibleAssetsView.tsx | 3 +- .../components/BibleRecordingView.tsx | 69 ++++++++++++------- 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/services/localizations.ts b/services/localizations.ts index dfc02504f..1de6cea3a 100644 --- a/services/localizations.ts +++ b/services/localizations.ts @@ -3416,6 +3416,20 @@ 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' + }, audioSegments: { english: 'Audio Segments', spanish: 'Pistas de Audio', diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 8b3c239b5..7c8e151c9 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -483,7 +483,7 @@ export default function BibleAssetsView() { // Store book name and chapter number for VerseSeparator label const bookChapterLabelRef = React.useRef('Verse'); - // Calculate book chapter label + // Calculate book chapter label (short name for separators) const bookChapterLabel = React.useMemo(() => { if (!selectedQuest || !currentBookId) { return 'Verse'; @@ -3063,6 +3063,7 @@ export default function BibleAssetsView() { initialOrderIndex={recordingOrderIndex} verse={selectedForRecording?.metadata?.verse} bookChapterLabel={bookChapterLabel} + bookChapterLabelFull={selectedQuest?.name} nextVerse={nextVerse} limitVerse={limitVerse} /> diff --git a/views/new/recording/components/BibleRecordingView.tsx b/views/new/recording/components/BibleRecordingView.tsx index de97ac9b4..7d10b9577 100644 --- a/views/new/recording/components/BibleRecordingView.tsx +++ b/views/new/recording/components/BibleRecordingView.tsx @@ -110,8 +110,10 @@ interface BibleRecordingViewProps { initialOrderIndex?: number; // Verse metadata from the selected asset verse?: VerseRange; - // Book chapter label (e.g., "Gen 1" or "Mat 3") + // 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) @@ -124,7 +126,8 @@ const BibleRecordingView = ({ 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 (e.g., "Gen 1" or "Mat 3") + 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) => { @@ -2874,32 +2877,52 @@ const BibleRecordingView = ({ - {t('doRecord')} + {bookChapterLabelFull || bookChapterLabel} - - {t('assets')} ({assets.length}) + {/* + {t('doRecord')} + */} + + + + {assets.length} {t('assets').toLowerCase()} + {assets.length > 0 && enablePlayAll && ( + + )} - {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 */} {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */} From d42264ddcd16fa5ce842f5d830d5eaa1754410aa Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Wed, 21 Jan 2026 07:16:11 -0800 Subject: [PATCH 33/39] Remove debug lines --- views/new/BibleAssetsView.tsx | 56 ----------------------------------- 1 file changed, 56 deletions(-) diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 7c8e151c9..5c7d4b069 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -1081,7 +1081,6 @@ export default function BibleAssetsView() { void queryClient.invalidateQueries({ queryKey: ['assets'] }); void refetch(); - console.log('✅ Asset renamed successfully'); } catch (error) { console.error('❌ Failed to rename asset:', error); if (error instanceof Error) { @@ -1174,9 +1173,6 @@ export default function BibleAssetsView() { order_index: newOrderIndex }); - console.log( - `📝 "${item.content.name}" | verse: ${separator.from}-${separator.to ?? separator.from} | order_index: ${newOrderIndex}` - ); } } } else { @@ -1200,9 +1196,6 @@ export default function BibleAssetsView() { // Stop if we encounter another separator if (item.type === 'separator') { - console.log( - `🛑 Found another separator at index ${i}, stopping asset collection` - ); break; } @@ -1212,9 +1205,6 @@ export default function BibleAssetsView() { (separator.from * 1000 + sequentialInGroup) * 1000; sequentialInGroup++; - console.log( - `➕ Adding asset ${item.content.id} to update list (index ${i})` - ); assetsToUpdate.push({ assetId: item.content.id, metadata: { @@ -1226,9 +1216,6 @@ export default function BibleAssetsView() { order_index: newOrderIndex }); - console.log( - `📝 "${item.content.name}" | verse: ${separator.from}-${separator.to ?? separator.from} | order_index: ${newOrderIndex}` - ); } } } @@ -1359,9 +1346,6 @@ export default function BibleAssetsView() { order_index: newOrderIndex }); - console.log( - `📝 "${item.content.name}" | verse: ${newFrom}-${newTo} | order_index: ${newOrderIndex}` - ); } } @@ -1595,16 +1579,9 @@ export default function BibleAssetsView() { // Get the current verse range from selectedForRecording const currentVerse = selectedForRecording?.metadata?.verse; - console.log( - `📊 Calculating nextVerse/limitVerse | verseCount: ${verseCount} | currentVerse: ${currentVerse ? `${currentVerse.from}-${currentVerse.to}` : 'none'} | existingLabels: ${existingLabels.map((l) => `${l.from}-${l.to}`).join(', ')}` - ); - // If no labels exist yet, start from verse 1 if (existingLabels.length === 0) { const result = { nextVerse: 1, limitVerse: verseCount }; - console.log( - `✅ Result: nextVerse=${result.nextVerse}, limitVerse=${result.limitVerse} (no labels)` - ); return result; } @@ -1614,26 +1591,17 @@ export default function BibleAssetsView() { const lastLabel = existingLabels[existingLabels.length - 1]; if (!lastLabel) { const result = { nextVerse: 1, limitVerse: verseCount }; - console.log( - `✅ Result: nextVerse=${result.nextVerse}, limitVerse=${result.limitVerse} (no last label)` - ); return result; } // If there's space after the last label if (lastLabel.to < verseCount) { const result = { nextVerse: lastLabel.to + 1, limitVerse: verseCount }; - console.log( - `✅ Result: nextVerse=${result.nextVerse}, limitVerse=${result.limitVerse} (after last label ${lastLabel.from}-${lastLabel.to})` - ); return result; } // No space available const result = { nextVerse: null, limitVerse: null }; - console.log( - `✅ Result: nextVerse=${result.nextVerse}, limitVerse=${result.limitVerse} (no space)` - ); return result; } @@ -1651,32 +1619,20 @@ export default function BibleAssetsView() { nextVerse: currentTo + 1, limitVerse: nextLabel.from - 1 }; - console.log( - `✅ Result: nextVerse=${result.nextVerse}, limitVerse=${result.limitVerse} (gap between ${currentTo} and ${nextLabel.from})` - ); return result; } else { // No gap - next verse is already occupied const result = { nextVerse: null, limitVerse: null }; - console.log( - `✅ Result: nextVerse=${result.nextVerse}, limitVerse=${result.limitVerse} (no gap, next is ${nextLabel.from})` - ); return result; } } else { // No next label - check if there's space until the end if (currentTo < verseCount) { const result = { nextVerse: currentTo + 1, limitVerse: verseCount }; - console.log( - `✅ Result: nextVerse=${result.nextVerse}, limitVerse=${result.limitVerse} (from ${currentTo} to end)` - ); return result; } else { // Already at the end const result = { nextVerse: null, limitVerse: null }; - console.log( - `✅ Result: nextVerse=${result.nextVerse}, limitVerse=${result.limitVerse} (already at end)` - ); return result; } } @@ -1735,10 +1691,6 @@ export default function BibleAssetsView() { } } - console.log( - `📊 Verse ${from}: last sequential = ${lastSequential}, assigning ${selectedAssets.length} asset(s) starting at ${lastSequential + 1}` - ); - // Calculate order_index continuing from the last existing asset const updates: AssetUpdatePayload[] = selectedAssets.map( (asset, index) => ({ @@ -1881,10 +1833,6 @@ export default function BibleAssetsView() { async (verses: number[]) => { if (!currentQuestId || verses.length === 0) return; - console.log( - `🔄 Normalizing order_index for ${verses.length} verse(s): [${verses.join(', ')}]` - ); - const assetTable = resolveTable('asset', { localOverride: true }); const questAssetLinkTable = resolveTable('quest_asset_link', { localOverride: true @@ -1945,10 +1893,6 @@ export default function BibleAssetsView() { assetId: asset.id, order_index: newOrderIndex }); - - console.log( - ` 📝 "${asset.name}" | ${asset.order_index} → ${newOrderIndex}` - ); } } From e2f8b1e84474eb374057e8b65f79c686c0d66478 Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Thu, 22 Jan 2026 18:58:34 -0800 Subject: [PATCH 34/39] Enhance UI experience --- components/ArrayInsertionWheel.tsx | 10 +- components/ui/speed-dial.tsx | 3 +- services/localizations.ts | 7 + views/new/BibleAssetsView.tsx | 100 ++++++----- .../components/BibleRecordingView.tsx | 162 ++++++++++++++++-- .../components/BibleSelectionControls.tsx | 2 +- .../components/SelectionControls.tsx | 15 +- 7 files changed, 233 insertions(+), 66 deletions(-) diff --git a/components/ArrayInsertionWheel.tsx b/components/ArrayInsertionWheel.tsx index 47c4e7a8e..c60bcd15a 100644 --- a/components/ArrayInsertionWheel.tsx +++ b/components/ArrayInsertionWheel.tsx @@ -20,6 +20,7 @@ interface ArrayInsertionWheelProps { 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; } function ArrayInsertionWheelInternal( @@ -30,7 +31,8 @@ function ArrayInsertionWheelInternal( rowHeight, className, topInset = 0, - bottomInset = 0 + bottomInset = 0, + boundaryComponent }: ArrayInsertionWheelProps, ref: React.Ref ) { @@ -132,6 +134,10 @@ function ArrayInsertionWheelInternal( // Final boundary (i === children.length) // 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] + [children, rowHeight, boundaryComponent] ); return ( diff --git a/components/ui/speed-dial.tsx b/components/ui/speed-dial.tsx index a6219ee20..3d603efb2 100644 --- a/components/ui/speed-dial.tsx +++ b/components/ui/speed-dial.tsx @@ -198,4 +198,5 @@ SpeedDialItem.displayName = 'SpeedDialItem'; export { SpeedDial, SpeedDialItem, SpeedDialItems, SpeedDialTrigger }; -export type { ItemProps as SpeedDialItemProps, SpeedDialProps }; + export type { ItemProps as SpeedDialItemProps, SpeedDialProps }; + diff --git a/services/localizations.ts b/services/localizations.ts index 1de6cea3a..c51d6fffc 100644 --- a/services/localizations.ts +++ b/services/localizations.ts @@ -3430,6 +3430,13 @@ export const localizations = { 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' + }, audioSegments: { english: 'Audio Segments', spanish: 'Pistas de Audio', diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 5c7d4b069..4b8006b4f 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -38,6 +38,7 @@ import { BookmarkPlusIcon, BrushCleaning, CheckCheck, + ChevronRight, CloudUpload, FlagIcon, InfoIcon, @@ -3400,51 +3401,12 @@ export default function BibleAssetsView() { )} - {/* Sticky Record Button Footer - only show for authenticated users */} - {!isPublished && currentUser && ( - - {isSelectionMode ? ( - setShowVerseAssignerDrawer(true)} - /> - ) : ( - - )} - - )} - {/* Hide SpeedDial in selection mode */} {!isSelectionMode && ( @@ -3458,6 +3420,7 @@ export default function BibleAssetsView() { icon={SettingsIcon} variant="outline" onPress={() => setShowSettingsModal(true)} + /> ) : !hasReported ? ( - + + )} + + {/* 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 && ( { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (USE_INSERTION_WHEEL) { + + // if (USE_INSERTION_WHEEL) { const maxIndex = allItems.length; // Can insert at 0..N (after last item) if (insertionIndex > maxIndex) { - debugLog( - `📍 Clamping insertion index from ${insertionIndex} to ${maxIndex}` - ); setInsertionIndex(maxIndex); } - } + // } }, [allItems.length, insertionIndex]); // Ref for LegendList to enable scrolling @@ -2485,6 +2482,32 @@ const BibleRecordingView = ({ ); }, [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 // ============================================================================ @@ -2841,6 +2864,76 @@ const BibleRecordingView = ({ // 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 */} @@ -2907,7 +3000,7 @@ const BibleRecordingView = ({ )} - + {/* {(isRecording || isVADRecording)? ( */} {(isVADLocked)? ( @@ -2916,7 +3009,7 @@ const BibleRecordingView = ({ : t('recording')} ) : ( - + {highlightedItemVerse ? `${t('recordTo')}: ${formatVerseRange(highlightedItemVerse)}` : `${t('noLabelSelected')}`} @@ -2925,8 +3018,8 @@ const BibleRecordingView = ({ {/* Scrollable list area - full height with padding for controls */} - {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */} - {USE_INSERTION_WHEEL ? ( + { } + {/* {USE_INSERTION_WHEEL ? ( */} // ArrayInsertionWheel mode - always show wheel (starts with initial verse pill) {wheelChildren} - ) : ( + {/* ) : ( // LegendList mode (legacy) assetsForLegendList.length > 0 && ( ) - )} + )} */} {/* Add verse button - floats above recording controls */} - {!isSelectionMode && + {/* {!isSelectionMode && + showAddVerseButton && + verseToAdd !== null && + !isVADRecording && + allowAddVerseRef.current && ( + + + + + + + )} */} + {/* {!isSelectionMode && showAddVerseButton && verseToAdd !== null && !isVADRecording && @@ -2989,7 +3110,7 @@ const BibleRecordingView = ({ - )} + )} */} {/* Bottom controls - absolutely positioned */} @@ -3000,6 +3121,9 @@ const BibleRecordingView = ({ onCancel={cancelSelection} onMerge={handleBatchMergeSelected} onDelete={handleBatchDeleteSelected} + allowSelectAll={true} + allSelected={allSelected} + onSelectAll={handleSelectAll} /> ) : ( diff --git a/views/new/recording/components/BibleSelectionControls.tsx b/views/new/recording/components/BibleSelectionControls.tsx index 52d082bfa..f94b909bc 100644 --- a/views/new/recording/components/BibleSelectionControls.tsx +++ b/views/new/recording/components/BibleSelectionControls.tsx @@ -40,7 +40,7 @@ export const BibleSelectionControls = React.memo(function SelectionControls({ 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 && ( + + )} diff --git a/services/localizations.ts b/services/localizations.ts index c51d6fffc..a78fb19be 100644 --- a/services/localizations.ts +++ b/services/localizations.ts @@ -3437,6 +3437,27 @@ export const localizations = { 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', diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index b1d418083..5fcf05334 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -34,6 +34,7 @@ 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, @@ -2596,109 +2597,52 @@ export default function BibleAssetsView() { [] ); - // Track currently playing asset based on audio position - React.useEffect(() => { - // If not playing at all, clear the highlight + // 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) { - setCurrentlyPlayingAssetId(null); - return; + return null; } - // If playing a single asset (not play-all mode), the currentAudioId IS the assetId - // Just keep the highlight for that asset (handlePlayAsset already sets it) + // Playing a single asset (not play-all mode) if (audioContext.currentAudioId !== PLAY_ALL_AUDIO_ID) { - // The currentAudioId is the assetId, ensure it's highlighted - setCurrentlyPlayingAssetId(audioContext.currentAudioId); - return; + return audioContext.currentAudioId; } - // Calculate which asset is playing based on cumulative position (play-all mode) - const checkCurrentAsset = () => { - const uris = uriOrderRef.current; - const durations = segmentDurationsRef.current; - - if (uris.length === 0) return; - - const position = audioContext.position; // Position in milliseconds - - // 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) { - console.log( - `⏸️ No duration available yet (position: ${position}ms, duration: ${duration}ms)` - ); - return; - } + // Play-all mode: Find asset by time range + const position = audioContext.position; + const ranges = assetTimeRangesRef.current; - // Fallback: use percentage-based calculation - const positionPercent = position / duration; - const uriIndex = Math.min( - Math.floor(positionPercent * uris.length), - uris.length - 1 - ); + if (ranges.length === 0) { + // Fallback: use first asset in order + return assetOrderRef.current[0] || null; + } - const currentUri = uris[uriIndex]; - if (currentUri) { - const assetId = assetUriMapRef.current.get(currentUri); - if (assetId) { - if (assetId !== currentlyPlayingAssetId) { - console.log( - `🎵 [Fallback] Highlighting asset ${assetId.slice(0, 8)} (segment ${uriIndex + 1}/${uris.length}, ${Math.round(positionPercent * 100)}%)` - ); - setCurrentlyPlayingAssetId(assetId); - } - } else { - console.warn(`⚠️ No asset ID found for URI at index ${uriIndex}`); - } - } - return; + // Find which range the current position falls into + for (const range of ranges) { + if (position >= range.startMs && position < range.endMs) { + return range.assetId; } + } - // 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 - // Use <= for the last segment to catch it even if position is slightly off - 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) { - if (assetId !== currentlyPlayingAssetId) { - console.log( - `🎵 Highlighting asset ${assetId.slice(0, 8)} (segment ${i + 1}/${uris.length}, position: ${Math.round(position)}ms in range [${Math.round(segmentStart)}-${Math.round(cumulativeDuration)}]ms)` - ); - setCurrentlyPlayingAssetId(assetId); - } - } else { - console.warn(`⚠️ No asset ID found for URI at index ${i}`); - } - } - break; - } - } - }; - - // Check immediately and then periodically while playing - checkCurrentAsset(); - const interval = setInterval(checkCurrentAsset, 200); // Check every 200ms - return () => clearInterval(interval); + // If position is beyond all ranges, return the last asset + return ranges[ranges.length - 1]?.assetId || null; }, [ audioContext.isPlaying, audioContext.currentAudioId, - audioContext.position, - audioContext.duration, - currentlyPlayingAssetId + 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 { @@ -2713,6 +2657,7 @@ export default function BibleAssetsView() { assetOrderRef.current = []; uriOrderRef.current = []; segmentDurationsRef.current = []; + assetTimeRangesRef.current = []; } else { if (assets.length === 0) { console.warn('⚠️ No assets to play'); @@ -2725,17 +2670,49 @@ export default function BibleAssetsView() { 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); - // Map each URI to its asset ID 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)` + ); } } @@ -2745,15 +2722,10 @@ export default function BibleAssetsView() { } console.log( - `▶️ Playing ${allUris.length} audio segments from ${assets.length} assets` + `▶️ Playing ${allUris.length} audio segments from ${assets.length} assets (total: ${Math.round(cumulativeTime)}ms)` ); - // Set the first asset as currently playing - // Note: Duration preloading is handled by AudioContext.playSoundSequence - if (assetOrderRef.current.length > 0) { - setCurrentlyPlayingAssetId(assetOrderRef.current[0] || null); - } - + // Start playing (AudioContext will handle sequence playback) await audioContext.playSoundSequence(allUris, PLAY_ALL_AUDIO_ID); } } catch (error) { @@ -3533,7 +3505,7 @@ export default function BibleAssetsView() { 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." - countdown={10} + confirmationString={selectedQuest?.name || 'DELETE'} /> {selectedQuest && ( Date: Fri, 23 Jan 2026 06:52:07 -0800 Subject: [PATCH 37/39] Lint update --- components/ArrayInsertionWheel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ArrayInsertionWheel.tsx b/components/ArrayInsertionWheel.tsx index c60bcd15a..609475d2e 100644 --- a/components/ArrayInsertionWheel.tsx +++ b/components/ArrayInsertionWheel.tsx @@ -119,7 +119,7 @@ function ArrayInsertionWheelInternal( ); const renderItem = React.useCallback( - ({ item }: { item: PickerItem }) => { + ({ item }: { item: PickerItem }): React.ReactElement => { const i = item.value; // Render actual items (not the final boundary) @@ -135,7 +135,7 @@ function ArrayInsertionWheelInternal( // 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 <>{boundaryComponent}; } return ( From 05f9857a55c1b74440e6f96fb1ffb8769d5ac15e Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Fri, 23 Jan 2026 17:33:30 -0800 Subject: [PATCH 38/39] Improving performance --- views/new/BibleAssetsView.tsx | 657 ++++++++++++++++------------------ 1 file changed, 313 insertions(+), 344 deletions(-) diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 5fcf05334..06d119bf4 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -872,9 +872,6 @@ export default function BibleAssetsView() { verseName }); - console.log( - `🎯 Selected separator for recording | verse: ${verse} | orderIndex: ${orderIndex} | verseName: "${verseName}"` - ); }, [selectedForRecording?.type, selectedForRecording?.separatorKey] ); @@ -902,9 +899,6 @@ export default function BibleAssetsView() { // Reset the name counter for this quest const counterKey = `bible_recording_counter_${currentQuestId}`; await AsyncStorage.removeItem(counterKey); - console.log( - `🔄 Name counter reset for quest ${currentQuestId.slice(0, 8)}` - ); setSelectedForRecording(null); void queryClient.invalidateQueries({ queryKey: ['assets'] }); @@ -1278,9 +1272,6 @@ export default function BibleAssetsView() { } if (separatorsToRemove.length > 0) { - console.log( - `🧹 Cleaning up ${separatorsToRemove.length} manual separator(s) that are now persisted in asset metadata` - ); setManualSeparators((prev) => prev.filter((sep) => !separatorsToRemove.includes(sep.key)) ); @@ -1355,10 +1346,6 @@ export default function BibleAssetsView() { if (assetsToUpdate.length > 0) { try { await batchUpdateAssetMetadata(assetsToUpdate); - console.log( - `✅ Updated ${assetsToUpdate.length} asset(s) below separator with new verse range ${newFrom}-${newTo} (with order_index)` - ); - // Invalidate queries to refresh the UI void queryClient.invalidateQueries({ queryKey: ['assets'] }); void refetch(); @@ -1707,10 +1694,6 @@ export default function BibleAssetsView() { await batchUpdateAssetMetadata(updates); - console.log( - `✅ Assigned verse ${from}-${to} to ${selectedAssets.length} asset(s)` - ); - // Close drawer and clear selection setShowVerseAssignerDrawer(false); cancelSelection(); @@ -1755,10 +1738,6 @@ export default function BibleAssetsView() { } } - console.log( - `📊 Unassigned (${verseBase}): last sequential = ${lastSequential}, moving ${selectedAssets.length} asset(s) starting at ${lastSequential + 1}` - ); - // Set metadata to null and assign order_index at end of unassigned list const updates: AssetUpdatePayload[] = selectedAssets.map( (asset, index) => ({ @@ -1770,8 +1749,6 @@ export default function BibleAssetsView() { await batchUpdateAssetMetadata(updates); - console.log(`✅ Removed labels from ${selectedAssets.length} asset(s)`); - // Close drawer and clear selection setShowVerseAssignerDrawer(false); cancelSelection(); @@ -1871,7 +1848,6 @@ export default function BibleAssetsView() { .orderBy(asc(assetTable.order_index)); if (assetsInVerse.length === 0) { - console.log(` ⏭️ Verse ${verse}: no assets found, skipping`); continue; } @@ -1903,17 +1879,12 @@ export default function BibleAssetsView() { console.log( ` ✅ Verse ${verse}: normalized ${updates.length} of ${assetsInVerse.length} asset(s)` ); - } else { - console.log( - ` ⏭️ Verse ${verse}: ${assetsInVerse.length} asset(s) already normalized` - ); - } + } } catch (error) { console.error(` ❌ Failed to normalize verse ${verse}:`, error); } } - console.log(`🔄 Normalization complete`); }, [currentQuestId] ); @@ -2943,6 +2914,87 @@ export default function BibleAssetsView() { } }; + // ============================================================================ + // 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 ( @@ -2995,105 +3047,6 @@ export default function BibleAssetsView() { // Note: queriedProjectData doesn't include name, so we only use currentProjectData const projectName = currentProjectData?.name || ''; - // ============================================================================ - // ORDER_INDEX CALCULATION - // Formula: order_index = (from * 1000 + sequential) * 1000 - // - 'from' is the verse number from the separator (999 for unassigned) - // - 'sequential' is the position within that verse group (1-based, starts at 1) - // - Final value is multiplied by 1000 to leave space for future insertions - // Example: verse 7, first asset → 7001000, second → 7002000, etc. - // This ensures assets are ordered by verse first, then by position within verse - // ============================================================================ - - const UNASSIGNED_VERSE_BASE = 999; // High value so unassigned assets appear at the end - - async function _handleSorting(params: { - indexToKey: string[]; - data: ListItem[]; - }) { - console.log('🔄 Sorting:'); - // 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; - } - - // Log asset change details - console.log( - `📝 "${item.content.name}" (${item.content.id.slice(0, 8)}...) | ` + - `metadata: ${metadataChanged ? `${JSON.stringify(currentMetadata)} → ${JSON.stringify(newMetadata)}` : '(unchanged)'} | ` + - `order_index: ${orderIndexChanged ? `${currentOrderIndex} → ${newOrderIndex}` : '(unchanged)'}` - ); - - updates.push(update); - } - } - } - - // Batch update all changed assets - if (updates.length > 0) { - try { - await batchUpdateAssetMetadata(updates); - console.log( - `✅ Updated ${updates.length} asset(s) (metadata + order_index)` - ); - - // 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); - } - } - } - return ( @@ -3346,7 +3299,7 @@ export default function BibleAssetsView() { rowGap={3} scrollableRef={scrollableRef} // required for auto scroll overDrag="vertical" - onDragEnd={(params) => void _handleSorting(params)} + onDragEnd={(params) => void handleSorting(params)} customHandle sortEnabled={!isSelectionMode} // Disable sorting in selection mode // autoScrollActivationOffset={75} @@ -3489,7 +3442,7 @@ export default function BibleAssetsView() { )} - {allowSettings && isOwner && ( + {allowSettings && isOwner && showSettingsModal && ( setShowSettingsModal(false)} @@ -3499,15 +3452,17 @@ export default function BibleAssetsView() { )} {/* Delete All Assets Drawer */} - 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'} - /> - {selectedQuest && ( + {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 && ( { - if (!open && !isOffloading) { - setShowOffloadDrawer(false); - verificationState.cancel(); - } - }} - onContinue={handleOffloadConfirm} - verificationState={verificationState} - isOffloading={isOffloading} - /> + {showOffloadDrawer && ( + { + if (!open && !isOffloading) { + setShowOffloadDrawer(false); + verificationState.cancel(); + } + }} + onContinue={handleOffloadConfirm} + verificationState={verificationState} + isOffloading={isOffloading} + /> + )} {/* Rename Asset Drawer */} - { - setShowRenameDrawer(open); - if (!open) { - setRenameAssetId(null); - } - }} - onSave={handleSaveRename} - /> + {showRenameDrawer && ( + { + setShowRenameDrawer(open); + if (!open) { + setRenameAssetId(null); + } + }} + onSave={handleSaveRename} + /> + )} {/* Batch Verse Assignment Drawer */} - { - 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} - /> - - + {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 && ( + {isPrivateProject && showPrivateAccessModal && ( { - 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 }) - } - /> - - - + {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 */} - { - 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 */} - { - if (!open) { - setAssetVerseSelectorState({ isOpen: false, assetId: null }); - } - }} - snapPoints={['40%']} - enableDynamicSizing={false} - > - - - Add Verse Label - - - { - if (assetVerseSelectorState.assetId) { - addVerseSeparator(from, to, assetVerseSelectorState.assetId); - } else { + {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); - setAssetVerseSelectorState({ isOpen: false, assetId: null }); - }} - onCancel={() => - setAssetVerseSelectorState({ isOpen: false, assetId: null }) - } - /> - - - + // Clear recording selection when any label is added + setSelectedForRecording(null); + setNewLabelSelectorState({ isOpen: false }); + }} + onCancel={() => setNewLabelSelectorState({ isOpen: false })} + /> + + + + )} - {/* Verse Range Selector Drawer for editing separator */} - { - if (!open) { - setEditSeparatorState({ isOpen: false, separatorKey: null }); - } - }} - snapPoints={['40%']} - enableDynamicSizing={false} - > - - - Edit Verse Label - - - {editSeparatorState.separatorKey && ( + {/* 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 + + - getMaxToForFromSeparator( - editSeparatorState.separatorKey!, - selectedFrom - ) - } - onApply={async (from, to) => { - if (editSeparatorState.separatorKey) { - await updateVerseSeparator( - editSeparatorState.separatorKey, - editSeparatorState.from, - editSeparatorState.to, - from, - to - ); + getMaxToForFrom={getMaxToForFrom} + onApply={(from, to) => { + if (assetVerseSelectorState.assetId) { + addVerseSeparator(from, to, assetVerseSelectorState.assetId); + } else { + addVerseSeparator(from, to); } - // Clear recording selection when any label is edited - // This ensures we don't have stale order_index references + // Clear recording selection when any label is added setSelectedForRecording(null); - setEditSeparatorState({ isOpen: false, separatorKey: null }); + setAssetVerseSelectorState({ isOpen: false, assetId: null }); }} onCancel={() => - setEditSeparatorState({ isOpen: false, separatorKey: null }) + 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 }) + } + /> + )} + + + + )} ); } From ba2147d53c317178e67c8530e1d7db12666c589c Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Fri, 23 Jan 2026 18:17:45 -0800 Subject: [PATCH 39/39] Improving performance --- components/ArrayInsertionWheel.tsx | 126 +++++++++------ views/new/BibleAssetsView.tsx | 2 +- .../components/BibleRecordingView.tsx | 146 ++++-------------- 3 files changed, 113 insertions(+), 161 deletions(-) diff --git a/components/ArrayInsertionWheel.tsx b/components/ArrayInsertionWheel.tsx index 609475d2e..01d867636 100644 --- a/components/ArrayInsertionWheel.tsx +++ b/components/ArrayInsertionWheel.tsx @@ -12,8 +12,7 @@ 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; @@ -23,9 +22,29 @@ interface ArrayInsertionWheelProps { boundaryComponent?: React.ReactNode; } -function ArrayInsertionWheelInternal( - { - children, +// 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; +} + +type ArrayInsertionWheelProps = + | ArrayInsertionWheelPropsEager + | ArrayInsertionWheelPropsLazy; + +function ArrayInsertionWheelInternal( + props: ArrayInsertionWheelProps, + ref: React.Ref +) { + const { value, onChange, rowHeight, @@ -33,28 +52,26 @@ function ArrayInsertionWheelInternal( topInset = 0, bottomInset = 0, boundaryComponent - }: ArrayInsertionWheelProps, - ref: React.Ref -) { - const itemCount = children.length + 1; // extra end boundary + } = 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(() => { @@ -69,7 +86,7 @@ function ArrayInsertionWheelInternal( } }, [value, clampedValue, itemCount]); - const data = React.useMemo[]>( + const pickerData = React.useMemo[]>( () => Array.from({ length: itemCount }, (_, i) => ({ value: i })), [itemCount] ); @@ -118,20 +135,44 @@ function ArrayInsertionWheelInternal( [stableClampedValue, itemCount, onChange] ); - const renderItem = React.useCallback( + 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) { @@ -166,7 +207,7 @@ function ArrayInsertionWheelInternal( ); }, - [children, rowHeight, boundaryComponent] + [isLazyMode, props, rowHeight, boundaryComponent] ); return ( @@ -180,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 }) => ( )} @@ -215,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/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 06d119bf4..4a46a0d49 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -3332,7 +3332,7 @@ export default function BibleAssetsView() { diff --git a/views/new/recording/components/BibleRecordingView.tsx b/views/new/recording/components/BibleRecordingView.tsx index 3fdc495b8..0e78f59d0 100644 --- a/views/new/recording/components/BibleRecordingView.tsx +++ b/views/new/recording/components/BibleRecordingView.tsx @@ -21,7 +21,6 @@ import { saveAudioLocally } from '@/utils/fileUtils'; import RNAlert from '@blazejkustra/react-native-alert'; -import type { LegendListRef } from '@legendapp/list'; import { toCompilableQuery } from '@powersync/drizzle-driver'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useQueryClient } from '@tanstack/react-query'; @@ -787,23 +786,14 @@ const BibleRecordingView = ({ // when items array reference changes but content is identical const itemsForWheel = React.useMemo(() => allItems, [allItems]); - // Assets only (for legacy LegendList) - const assetsForLegendList = React.useMemo(() => assets, [assets]); - // Clamp insertion index when item count changes React.useEffect(() => { - - // if (USE_INSERTION_WHEEL) { - const maxIndex = allItems.length; // Can insert at 0..N (after last item) - if (insertionIndex > maxIndex) { - setInsertionIndex(maxIndex); - } - // } + const maxIndex = allItems.length; // Can insert at 0..N (after last item) + if (insertionIndex > maxIndex) { + setInsertionIndex(maxIndex); + } }, [allItems.length, insertionIndex]); - // Ref for LegendList to enable scrolling - const listRef = React.useRef(null); - // Track item count to detect new insertions const previousItemCountRef = React.useRef(allItems.length); @@ -2685,87 +2675,13 @@ const BibleRecordingView = ({ return map; }, [itemsForWheel, createAssetCallbacks]); - // Memoized render function for LegendList - // OPTIMIZED: No audioContext.position dependency - progress now uses SharedValues! - // This eliminates 10 re-renders/second during audio playback - const renderAssetItem = React.useCallback( - ({ item, index }: { item: UIAsset; index: number }) => { - // 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); - const canMergeDown = - index < assets.length - 1 && assets[index + 1]?.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; - - return ( - { - if (isSelectionMode) { - stableToggleSelect(item.id); - } else { - void stableHandlePlayAsset(item.id); - } - }} - onLongPress={() => { - stableEnterSelection(item.id); - }} - onPlay={() => { - void stableHandlePlayAsset(item.id); - }} - onDelete={stableHandleDeleteLocalAsset} - onMerge={stableHandleMergeDownLocal} - onRename={stableHandleRenameAsset} - /> - ); - }, - [ - audioContext.isPlaying, - audioContext.currentAudioId, - currentlyPlayingAssetId, - // audioContext.position REMOVED - uses SharedValues now! - // audioContext.duration REMOVED - not needed for render - selectedAssetIds, - isSelectionMode, - assets, - stableHandlePlayAsset, - stableToggleSelect, - stableEnterSelection, - stableHandleDeleteLocalAsset, - stableHandleMergeDownLocal, - stableHandleRenameAsset - ] - ); - // Memoized children for ArrayInsertionWheel - // OPTIMIZED: No audioContext.position/duration dependencies - progress now uses SharedValues! - // This eliminates re-creating all children 10+ times per second during audio playback - const wheelChildren = React.useMemo(() => { - return itemsForWheel.map((item, index) => { + // 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 @@ -2817,7 +2733,7 @@ const BibleRecordingView = ({ // Fallback if callbacks not found (shouldn't happen, but defensive) if (!callbacks) { console.warn(`Missing callbacks for asset ${item.id}`); - return null; + return ; } return ( @@ -2840,23 +2756,21 @@ const BibleRecordingView = ({ onRename={stableHandleRenameAsset} /> ); - }); - }, [ - itemsForWheel, - formatVerseRange, - audioContext.isPlaying, - audioContext.currentAudioId, - currentlyPlayingAssetId, - // assetProgressSharedMap REMOVED - it's a ref, accessed directly in render - // audioContext.position REMOVED - uses SharedValues now! - // audioContext.duration REMOVED - not needed for render - selectedAssetIds, - isSelectionMode, - assetCallbacksMap, // OPTIMIZED: Map of stable callbacks per asset - stableHandleDeleteLocalAsset, - stableHandleMergeDownLocal, - stableHandleRenameAsset - ]); + }, + [ + 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 @@ -3022,7 +2936,7 @@ const BibleRecordingView = ({ {/* {USE_INSERTION_WHEEL ? ( */} // ArrayInsertionWheel mode - always show wheel (starts with initial verse pill) - ref={wheelRef} value={insertionIndex} onChange={(newIndex) => { @@ -3041,9 +2955,9 @@ const BibleRecordingView = ({ className="h-full flex-1" bottomInset={footerHeight} boundaryComponent={boundaryComponent} - > - {wheelChildren} - + data={itemsForWheel} + renderItem={renderWheelItem} + /> {/* ) : ( // LegendList mode (legacy)