Added the longpress menu for the library menu

This commit is contained in:
CrissZollo 2025-09-29 22:10:03 +02:00
parent 7fa4d20da0
commit 35ace0214a
4 changed files with 168 additions and 14 deletions

View file

@ -1,4 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from '@backpackapp-io/react-native-toast';
import { DeviceEventEmitter } from 'react-native';
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Animated } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
@ -74,8 +76,13 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
// Load watched state from AsyncStorage when item changes
useEffect(() => {
AsyncStorage.getItem(`watched_${item.id}`).then(val => setIsWatched(val === 'true'));
}, [item.id]);
const updateWatched = () => {
AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setIsWatched(val === 'true'));
};
updateWatched();
const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched);
return () => sub.remove();
}, [item.id, item.type]);
const [menuVisible, setMenuVisible] = useState(false);
const [isWatched, setIsWatched] = useState(false);
@ -114,14 +121,21 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
case 'library':
if (inLibrary) {
catalogService.removeFromLibrary(item.type, item.id);
toast('Removed from Library', { duration: 1200 });
} else {
catalogService.addToLibrary(item);
toast('Added to Library', { duration: 1200 });
}
break;
case 'watched': {
setIsWatched(prevWatched => {
const newWatched = !prevWatched;
AsyncStorage.setItem(`watched_${item.id}`, newWatched ? 'true' : 'false');
AsyncStorage.setItem(`watched:${item.type}:${item.id}`, newWatched ? 'true' : 'false');
toast(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', { duration: 1200 });
// Fire a custom event so other screens can update
setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged');
}, 100);
return newWatched;
});
setMenuVisible(false);

View file

@ -92,10 +92,10 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
// Robustly determine if the item is in the library (saved)
const isSaved = typeof isSavedProp === 'boolean' ? isSavedProp : !!item.inLibrary;
const isWatched = !!isWatchedProp;
const menuOptions = [
let menuOptions = [
{
icon: isSaved ? 'bookmark' : 'bookmark-border',
label: isSaved ? 'Remove from Library' : 'Add to Library',
icon: 'bookmark',
label: 'Remove from Library',
action: 'library'
},
{
@ -115,6 +115,11 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
}
];
// If used in LibraryScreen, only show 'Remove from Library'
if (isSavedProp === true) {
menuOptions = menuOptions.filter(opt => opt.action !== 'library' || opt.label === 'Remove from Library');
}
const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF';
return (

View file

@ -1337,4 +1337,17 @@ const styles = StyleSheet.create<any>({
},
});
export default React.memo(HomeScreen);
import { DeviceEventEmitter } from 'react-native';
const HomeScreenWithFocusSync = (props: any) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
DeviceEventEmitter.emit('watchedStatusChanged');
});
return () => unsubscribe();
}, [navigation]);
return <HomeScreen {...props} />;
};
export default React.memo(HomeScreenWithFocusSync);

View file

@ -1,4 +1,9 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { DeviceEventEmitter } from 'react-native';
import { Share } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { toast } from '@backpackapp-io/react-native-toast';
import DropUpMenu from '../components/home/DropUpMenu';
import {
View,
Text,
@ -40,6 +45,7 @@ interface LibraryItem extends StreamingContent {
imdbId?: string;
traktId: number;
images?: TraktImages;
watched?: boolean;
}
interface TraktDisplayItem {
@ -205,6 +211,9 @@ const LibraryScreen = () => {
const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies');
const [showTraktContent, setShowTraktContent] = useState(false);
const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null);
// DropUpMenu state
const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null);
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
@ -267,7 +276,22 @@ const LibraryScreen = () => {
setLoading(true);
try {
const items = await catalogService.getLibraryItems();
setLibraryItems(items as LibraryItem[]);
// Load watched status for each item from AsyncStorage
const updatedItems = await Promise.all(items.map(async (item) => {
// Map StreamingContent to LibraryItem shape
const libraryItem: LibraryItem = {
...item,
gradient: Array.isArray((item as any).gradient) ? (item as any).gradient : ['#222', '#444'],
traktId: typeof (item as any).traktId === 'number' ? (item as any).traktId : 0,
};
const key = `watched:${item.type}:${item.id}`;
const watched = await AsyncStorage.getItem(key);
return {
...libraryItem,
watched: watched === 'true'
};
}));
setLibraryItems(updatedItems);
} catch (error) {
logger.error('Failed to load library:', error);
} finally {
@ -278,14 +302,37 @@ const LibraryScreen = () => {
loadLibrary();
// Subscribe to library updates
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
setLibraryItems(items as LibraryItem[]);
const unsubscribe = catalogService.subscribeToLibraryUpdates(async (items) => {
// Sync watched status on update
const updatedItems = await Promise.all(items.map(async (item) => {
// Map StreamingContent to LibraryItem shape
const libraryItem: LibraryItem = {
...item,
gradient: Array.isArray((item as any).gradient) ? (item as any).gradient : ['#222', '#444'],
traktId: typeof (item as any).traktId === 'number' ? (item as any).traktId : 0,
};
const key = `watched:${item.type}:${item.id}`;
const watched = await AsyncStorage.getItem(key);
return {
...libraryItem,
watched: watched === 'true'
};
}));
setLibraryItems(updatedItems);
});
// Listen for watched status changes
const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', loadLibrary);
// Refresh when screen regains focus
const focusSub = navigation.addListener('focus', loadLibrary);
return () => {
unsubscribe();
watchedSub.remove();
focusSub();
};
}, []);
}, [navigation]);
const filteredItems = libraryItems.filter(item => {
if (filter === 'movies') return item.type === 'movie';
@ -348,17 +395,25 @@ const LibraryScreen = () => {
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
onLongPress={() => {
setSelectedItem(item);
setMenuVisible(true);
}}
activeOpacity={0.7}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
transition={300}
/>
{item.watched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
{item.progress !== undefined && item.progress < 1 && (
<View style={styles.progressBarContainer}>
<View
@ -370,7 +425,7 @@ const LibraryScreen = () => {
</View>
)}
</View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
{item.name}
</Text>
</View>
@ -932,6 +987,65 @@ const LibraryScreen = () => {
{showTraktContent ? renderTraktContent() : renderContent()}
</View>
</View>
{/* DropUpMenu integration */}
{selectedItem && (
<DropUpMenu
visible={menuVisible}
onClose={() => setMenuVisible(false)}
item={selectedItem}
isWatched={!!selectedItem.watched}
onOptionSelect={async (option) => {
if (!selectedItem) return;
if (option === 'share') {
let url = '';
if (selectedItem.imdbId) {
url = `https://www.imdb.com/title/${selectedItem.imdbId}/`;
} else if (selectedItem.traktId) {
url = `https://trakt.tv/${selectedItem.type === 'movie' ? 'movies' : 'shows'}/${selectedItem.traktId}`;
} else {
url = selectedItem.poster || '';
}
try {
await Share.share({
message: `${selectedItem.name}\n${url}`,
url,
title: selectedItem.name,
});
} catch (error) {
toast('Failed to share', { duration: 1200 });
}
} else if (option === 'library') {
try {
// Always remove from library in LibraryScreen
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
toast('Removed from Library', { duration: 1200 });
setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type)));
setMenuVisible(false);
} catch (error) {
toast('Failed to update Library', { duration: 1200 });
}
} else if (option === 'watched') {
try {
// Use AsyncStorage to store watched status by key
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
const newWatched = !selectedItem.watched;
await AsyncStorage.setItem(key, newWatched ? 'true' : 'false');
toast(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', { duration: 1200 });
// Instantly update local state
setLibraryItems(prev => prev.map(item =>
item.id === selectedItem.id && item.type === selectedItem.type
? { ...item, watched: newWatched }
: item
));
} catch (error) {
toast('Failed to update watched status', { duration: 1200 });
}
}
}}
isSaved={!!selectedItem.inLibrary}
/>
)}
</View>
);
};
@ -947,6 +1061,14 @@ const styles = StyleSheet.create({
right: 0,
zIndex: 1,
},
watchedIndicator: {
position: 'absolute',
top: 8,
right: 8,
borderRadius: 12,
padding: 2,
zIndex: 2,
},
contentContainer: {
flex: 1,
},