This commit is contained in:
tapframe 2025-07-28 17:40:37 +05:30
parent c21f279aa3
commit d605383720
4 changed files with 172 additions and 63 deletions

View file

@ -586,21 +586,7 @@ const createStyles = (colors: any) => StyleSheet.create({
}, },
}); });
// Cinemeta addon details
const cinemetaAddon: CommunityAddon = {
transportUrl: 'https://v3-cinemeta.strem.io/manifest.json',
manifest: {
id: 'com.linvo.cinemeta',
version: '3.0.13',
name: 'Cinemeta',
description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.',
logo: 'https://static.strem.io/addons/cinemeta.png',
types: ['movie', 'series'],
behaviorHints: {
configurable: false
}
} as ExtendedManifest,
};
const AddonsScreen = () => { const AddonsScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -671,16 +657,15 @@ const AddonsScreen = () => {
// Filter out addons without a manifest or transportUrl (basic validation) // Filter out addons without a manifest or transportUrl (basic validation)
let validAddons = response.data.filter(addon => addon.manifest && addon.transportUrl); let validAddons = response.data.filter(addon => addon.manifest && addon.transportUrl);
// Filter out Cinemeta if it's already in the community list to avoid duplication // Filter out Cinemeta since it's now pre-installed
validAddons = validAddons.filter(addon => addon.manifest.id !== 'com.linvo.cinemeta'); validAddons = validAddons.filter(addon => addon.manifest.id !== 'com.linvo.cinemeta');
// Add Cinemeta to the beginning of the list setCommunityAddons(validAddons);
setCommunityAddons([cinemetaAddon, ...validAddons]);
} catch (error) { } catch (error) {
logger.error('Failed to load community addons:', error); logger.error('Failed to load community addons:', error);
setCommunityError('Failed to load community addons. Please try again later.'); setCommunityError('Failed to load community addons. Please try again later.');
// Still show Cinemeta if the community list fails to load // Set empty array on error since Cinemeta is pre-installed
setCommunityAddons([cinemetaAddon]); setCommunityAddons([]);
} finally { } finally {
setCommunityLoading(false); setCommunityLoading(false);
} }
@ -746,6 +731,16 @@ const AddonsScreen = () => {
}; };
const handleRemoveAddon = (addon: ExtendedManifest) => { const handleRemoveAddon = (addon: ExtendedManifest) => {
// Check if this is a pre-installed addon
if (stremioService.isPreInstalledAddon(addon.id)) {
Alert.alert(
'Cannot Remove Addon',
`${addon.name} is a pre-installed addon and cannot be removed.`,
[{ text: 'OK', style: 'default' }]
);
return;
}
Alert.alert( Alert.alert(
'Uninstall Addon', 'Uninstall Addon',
`Are you sure you want to uninstall ${addon.name}?`, `Are you sure you want to uninstall ${addon.name}?`,
@ -900,6 +895,8 @@ const AddonsScreen = () => {
const logo = item.logo || null; const logo = item.logo || null;
// Check if addon is configurable // Check if addon is configurable
const isConfigurable = item.behaviorHints?.configurable === true; const isConfigurable = item.behaviorHints?.configurable === true;
// Check if addon is pre-installed
const isPreInstalled = stremioService.isPreInstalledAddon(item.id);
// Format the types into a simple category text // Format the types into a simple category text
const categoryText = types.length > 0 const categoryText = types.length > 0
@ -951,7 +948,14 @@ const AddonsScreen = () => {
</View> </View>
)} )}
<View style={styles.addonTitleContainer}> <View style={styles.addonTitleContainer}>
<Text style={styles.addonName}>{item.name}</Text> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}>
<Text style={styles.addonName}>{item.name}</Text>
{isPreInstalled && (
<View style={[styles.priorityBadge, { marginLeft: 8, backgroundColor: colors.success }]}>
<Text style={[styles.priorityText, { fontSize: 10 }]}>PRE-INSTALLED</Text>
</View>
)}
</View>
<View style={styles.addonMetaContainer}> <View style={styles.addonMetaContainer}>
<Text style={styles.addonVersion}>v{item.version || '1.0.0'}</Text> <Text style={styles.addonVersion}>v{item.version || '1.0.0'}</Text>
<Text style={styles.addonDot}></Text> <Text style={styles.addonDot}></Text>
@ -969,12 +973,14 @@ const AddonsScreen = () => {
<MaterialIcons name="settings" size={20} color={colors.primary} /> <MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity> </TouchableOpacity>
)} )}
<TouchableOpacity {!stremioService.isPreInstalledAddon(item.id) && (
style={styles.deleteButton} <TouchableOpacity
onPress={() => handleRemoveAddon(item)} style={styles.deleteButton}
> onPress={() => handleRemoveAddon(item)}
<MaterialIcons name="delete" size={20} color={colors.error} /> >
</TouchableOpacity> <MaterialIcons name="delete" size={20} color={colors.error} />
</TouchableOpacity>
)}
</> </>
) : ( ) : (
<View style={styles.priorityBadge}> <View style={styles.priorityBadge}>
@ -1410,4 +1416,4 @@ const AddonsScreen = () => {
); );
}; };
export default AddonsScreen; export default AddonsScreen;

View file

@ -187,12 +187,12 @@ const PulsingChip = memo(({ text, delay }: { text: string; delay: number }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]); const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]);
const pulseValue = useSharedValue(0.7); const pulseValue = useSharedValue(0.6);
useEffect(() => { useEffect(() => {
const startPulse = () => { const startPulse = () => {
pulseValue.value = withTiming(1, { duration: 800 }, () => { pulseValue.value = withTiming(1, { duration: 1200 }, () => {
pulseValue.value = withTiming(0.7, { duration: 800 }, () => { pulseValue.value = withTiming(0.6, { duration: 1200 }, () => {
runOnJS(startPulse)(); runOnJS(startPulse)();
}); });
}); });
@ -207,8 +207,7 @@ const PulsingChip = memo(({ text, delay }: { text: string; delay: number }) => {
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {
return { return {
opacity: pulseValue.value, opacity: pulseValue.value
transform: [{ scale: interpolate(pulseValue.value, [0.7, 1], [0.95, 1], Extrapolate.CLAMP) }]
}; };
}); });
@ -1722,36 +1721,34 @@ const createStyles = (colors: any) => StyleSheet.create({
}, },
activeScrapersContainer: { activeScrapersContainer: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 12, paddingVertical: 8,
backgroundColor: colors.elevation1, backgroundColor: 'transparent',
marginHorizontal: 16, marginHorizontal: 16,
marginBottom: 8, marginBottom: 4,
borderRadius: 8,
paddingVertical: 12,
}, },
activeScrapersTitle: { activeScrapersTitle: {
color: colors.primary, color: colors.mediumEmphasis,
fontSize: 13, fontSize: 12,
fontWeight: '600', fontWeight: '500',
marginBottom: 8, marginBottom: 6,
opacity: 0.8,
}, },
activeScrapersRow: { activeScrapersRow: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 6, gap: 4,
}, },
activeScraperChip: { activeScraperChip: {
backgroundColor: colors.surfaceVariant, backgroundColor: colors.elevation2,
paddingHorizontal: 10, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 3,
borderRadius: 12, borderRadius: 6,
borderWidth: 1, borderWidth: 0,
borderColor: colors.primary + '40',
}, },
activeScraperText: { activeScraperText: {
color: colors.highEmphasis, color: colors.mediumEmphasis,
fontSize: 12, fontSize: 11,
fontWeight: '500', fontWeight: '400',
}, },
}); });

View file

@ -53,6 +53,8 @@ class NotificationService {
private backgroundSyncInterval: NodeJS.Timeout | null = null; private backgroundSyncInterval: NodeJS.Timeout | null = null;
private librarySubscription: (() => void) | null = null; private librarySubscription: (() => void) | null = null;
private appStateSubscription: any = null; private appStateSubscription: any = null;
private lastSyncTime: number = 0;
private readonly MIN_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes minimum between syncs
private constructor() { private constructor() {
// Initialize notifications // Initialize notifications
@ -149,6 +151,16 @@ class NotificationService {
return null; return null;
} }
// Check if notification already exists for this episode
const existingNotification = this.scheduledNotifications.find(
notification => notification.seriesId === item.seriesId &&
notification.season === item.season &&
notification.episode === item.episode
);
if (existingNotification) {
return null; // Don't schedule duplicate notifications
}
const releaseDate = parseISO(item.releaseDate); const releaseDate = parseISO(item.releaseDate);
const now = new Date(); const now = new Date();
@ -162,9 +174,9 @@ class NotificationService {
const notificationTime = new Date(releaseDate); const notificationTime = new Date(releaseDate);
notificationTime.setHours(notificationTime.getHours() - this.settings.timeBeforeAiring); notificationTime.setHours(notificationTime.getHours() - this.settings.timeBeforeAiring);
// If notification time has already passed, set to now + 1 minute // If notification time has already passed, don't schedule the notification
if (notificationTime < now) { if (notificationTime < now) {
notificationTime.setTime(now.getTime() + 60000); return null;
} }
// Schedule the notification // Schedule the notification
@ -254,9 +266,17 @@ class NotificationService {
this.librarySubscription = catalogService.subscribeToLibraryUpdates(async (libraryItems) => { this.librarySubscription = catalogService.subscribeToLibraryUpdates(async (libraryItems) => {
if (!this.settings.enabled) return; if (!this.settings.enabled) return;
// Reduced logging verbosity const now = Date.now();
// logger.log('[NotificationService] Library updated, syncing notifications for', libraryItems.length, 'items'); const timeSinceLastSync = now - this.lastSyncTime;
await this.syncNotificationsForLibrary(libraryItems);
// Only sync if enough time has passed since last sync
if (timeSinceLastSync >= this.MIN_SYNC_INTERVAL) {
// Reduced logging verbosity
// logger.log('[NotificationService] Library updated, syncing notifications for', libraryItems.length, 'items');
await this.syncNotificationsForLibrary(libraryItems);
} else {
// logger.log(`[NotificationService] Library updated, but skipping sync (last sync ${Math.round(timeSinceLastSync / 1000)}s ago)`);
}
}); });
} catch (error) { } catch (error) {
logger.error('[NotificationService] Error setting up library integration:', error); logger.error('[NotificationService] Error setting up library integration:', error);
@ -284,10 +304,18 @@ class NotificationService {
private handleAppStateChange = async (nextAppState: AppStateStatus) => { private handleAppStateChange = async (nextAppState: AppStateStatus) => {
if (nextAppState === 'active' && this.settings.enabled) { if (nextAppState === 'active' && this.settings.enabled) {
// App came to foreground, sync notifications const now = Date.now();
// Reduced logging verbosity const timeSinceLastSync = now - this.lastSyncTime;
// logger.log('[NotificationService] App became active, syncing notifications');
await this.performBackgroundSync(); // Only sync if enough time has passed since last sync
if (timeSinceLastSync >= this.MIN_SYNC_INTERVAL) {
// App came to foreground, sync notifications
// Reduced logging verbosity
// logger.log('[NotificationService] App became active, syncing notifications');
await this.performBackgroundSync();
} else {
// logger.log(`[NotificationService] App became active, but skipping sync (last sync ${Math.round(timeSinceLastSync / 1000)}s ago)`);
}
} }
}; };
@ -312,6 +340,9 @@ class NotificationService {
// Perform comprehensive background sync including Trakt integration // Perform comprehensive background sync including Trakt integration
private async performBackgroundSync(): Promise<void> { private async performBackgroundSync(): Promise<void> {
try { try {
// Update last sync time at the start
this.lastSyncTime = Date.now();
// Reduced logging verbosity // Reduced logging verbosity
// logger.log('[NotificationService] Starting comprehensive background sync'); // logger.log('[NotificationService] Starting comprehensive background sync');
@ -467,7 +498,13 @@ class NotificationService {
if (!video.released) return false; if (!video.released) return false;
const releaseDate = parseISO(video.released); const releaseDate = parseISO(video.released);
return releaseDate > now && releaseDate < fourWeeksLater; return releaseDate > now && releaseDate < fourWeeksLater;
}); }).map(video => ({
id: video.id,
title: (video as any).title || (video as any).name || `Episode ${video.episode}`,
season: video.season || 0,
episode: video.episode || 0,
released: video.released,
}));
} }
// If no upcoming episodes from Stremio, try TMDB // If no upcoming episodes from Stremio, try TMDB

View file

@ -213,6 +213,51 @@ class StremioService {
} }
} }
// Ensure Cinemeta is always installed as a pre-installed addon
const cinemetaId = 'com.linvo.cinemeta';
if (!this.installedAddons.has(cinemetaId)) {
const cinemetaManifest: Manifest = {
id: cinemetaId,
name: 'Cinemeta',
version: '3.0.13',
description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.',
url: 'https://v3-cinemeta.strem.io',
originalUrl: 'https://v3-cinemeta.strem.io/manifest.json',
types: ['movie', 'series'],
catalogs: [
{
type: 'movie',
id: 'top',
name: 'Top Movies',
extraSupported: ['search', 'genre', 'skip']
},
{
type: 'series',
id: 'top',
name: 'Top Series',
extraSupported: ['search', 'genre', 'skip']
}
],
resources: [
{
name: 'catalog',
types: ['movie', 'series'],
idPrefixes: ['tt']
},
{
name: 'meta',
types: ['movie', 'series'],
idPrefixes: ['tt']
}
],
behaviorHints: {
configurable: false
}
};
this.installedAddons.set(cinemetaId, cinemetaManifest);
logger.log('✅ Cinemeta pre-installed as default addon');
}
// Load addon order if exists // Load addon order if exists
const storedOrder = await AsyncStorage.getItem(this.ADDON_ORDER_KEY); const storedOrder = await AsyncStorage.getItem(this.ADDON_ORDER_KEY);
if (storedOrder) { if (storedOrder) {
@ -221,13 +266,26 @@ class StremioService {
this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id)); this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id));
} }
// Ensure Cinemeta is first in the order
if (!this.addonOrder.includes(cinemetaId)) {
this.addonOrder.unshift(cinemetaId);
} else {
// Move Cinemeta to the front if it's not already there
const cinemetaIndex = this.addonOrder.indexOf(cinemetaId);
if (cinemetaIndex > 0) {
this.addonOrder.splice(cinemetaIndex, 1);
this.addonOrder.unshift(cinemetaId);
}
}
// Add any missing addons to the order // Add any missing addons to the order
const installedIds = Array.from(this.installedAddons.keys()); const installedIds = Array.from(this.installedAddons.keys());
const missingIds = installedIds.filter(id => !this.addonOrder.includes(id)); const missingIds = installedIds.filter(id => !this.addonOrder.includes(id));
this.addonOrder = [...this.addonOrder, ...missingIds]; this.addonOrder = [...this.addonOrder, ...missingIds];
// Ensure order is saved // Ensure order and addons are saved
await this.saveAddonOrder(); await this.saveAddonOrder();
await this.saveInstalledAddons();
this.initialized = true; this.initialized = true;
} catch (error) { } catch (error) {
@ -336,6 +394,12 @@ class StremioService {
} }
removeAddon(id: string): void { removeAddon(id: string): void {
// Prevent removal of Cinemeta as it's a pre-installed addon
if (id === 'com.linvo.cinemeta') {
logger.warn('❌ Cannot remove Cinemeta - it is a pre-installed addon');
return;
}
if (this.installedAddons.has(id)) { if (this.installedAddons.has(id)) {
this.installedAddons.delete(id); this.installedAddons.delete(id);
// Remove from order // Remove from order
@ -359,6 +423,11 @@ class StremioService {
return this.getInstalledAddons(); return this.getInstalledAddons();
} }
// Check if an addon is pre-installed and cannot be removed
isPreInstalledAddon(id: string): boolean {
return id === 'com.linvo.cinemeta';
}
private formatId(id: string): string { private formatId(id: string): string {
return id.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(); return id.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
} }