From 11fcacc9a7064bb3ac2fc7fbf2819918330f1380 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 5 Jul 2025 13:10:19 +0530 Subject: [PATCH] Add community addons feature to AddonsScreen, including loading state and error handling. Integrate TraktService to remove playback from the queue when content is deleted in ContinueWatchingSection. Enhance TraktService with methods for deleting playback items. --- .../home/ContinueWatchingSection.tsx | 13 + src/screens/AddonsScreen.tsx | 248 +++++++++++++++++- src/services/traktService.ts | 62 ++++- 3 files changed, 319 insertions(+), 4 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 7b2ef309..80735d29 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -22,6 +22,7 @@ import { useTheme } from '../../contexts/ThemeContext'; import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; import * as Haptics from 'expo-haptics'; +import { TraktService } from '../../services/traktService'; // Define interface for continue watching items interface ContinueWatchingItem extends StreamingContent { @@ -324,6 +325,18 @@ const ContinueWatchingSection = React.forwardRef((props, re : undefined ); + // Also remove from Trakt playback queue if authenticated + const traktService = TraktService.getInstance(); + const isAuthed = await traktService.isAuthenticated(); + if (isAuthed) { + await traktService.deletePlaybackForContent( + item.id, + item.type as 'movie' | 'series', + item.season, + item.episode + ); + } + // Update the list by filtering out the deleted item setContinueWatchingItems(prev => prev.filter(i => i.id !== item.id || diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 3d7f7252..39804a0c 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -462,6 +462,70 @@ const createStyles = (colors: any) => StyleSheet.create({ padding: 6, marginRight: 8, }, + communityAddonsList: { + paddingHorizontal: 20, + }, + communityAddonItem: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.card, + borderRadius: 8, + padding: 15, + marginBottom: 10, + }, + communityAddonIcon: { + width: 40, + height: 40, + borderRadius: 6, + marginRight: 15, + }, + communityAddonIconPlaceholder: { + width: 40, + height: 40, + borderRadius: 6, + marginRight: 15, + backgroundColor: colors.darkGray, + justifyContent: 'center', + alignItems: 'center', + }, + communityAddonDetails: { + flex: 1, + marginRight: 10, + }, + communityAddonName: { + fontSize: 16, + fontWeight: '600', + color: colors.white, + marginBottom: 3, + }, + communityAddonDesc: { + fontSize: 13, + color: colors.lightGray, + marginBottom: 5, + opacity: 0.9, + }, + communityAddonMetaContainer: { + flexDirection: 'row', + alignItems: 'center', + opacity: 0.8, + }, + communityAddonVersion: { + fontSize: 12, + color: colors.lightGray, + }, + communityAddonDot: { + fontSize: 12, + color: colors.lightGray, + marginHorizontal: 5, + }, + communityAddonCategory: { + fontSize: 12, + color: colors.lightGray, + flexShrink: 1, + }, + separator: { + height: 10, + }, sectionSeparator: { height: 1, backgroundColor: colors.border, @@ -496,7 +560,7 @@ const createStyles = (colors: any) => StyleSheet.create({ left: 0, right: 0, bottom: 0, - backgroundColor: 'black', + backgroundColor: 'rgba(0,0,0,0.4)', }, androidBlurContainer: { position: 'absolute', @@ -554,8 +618,14 @@ const AddonsScreen = () => { const colors = currentTheme.colors; const styles = createStyles(colors); + // State for community addons + const [communityAddons, setCommunityAddons] = useState([]); + const [communityLoading, setCommunityLoading] = useState(true); + const [communityError, setCommunityError] = useState(null); + useEffect(() => { loadAddons(); + loadCommunityAddons(); }, []); const loadAddons = async () => { @@ -592,10 +662,34 @@ const AddonsScreen = () => { } }; + // Function to load community addons + const loadCommunityAddons = async () => { + setCommunityLoading(true); + setCommunityError(null); + try { + const response = await axios.get('https://stremio-addons.com/catalog.json'); + // Filter out addons without a manifest or transportUrl (basic validation) + let validAddons = response.data.filter(addon => addon.manifest && addon.transportUrl); + + // Filter out Cinemeta if it's already in the community list to avoid duplication + validAddons = validAddons.filter(addon => addon.manifest.id !== 'com.linvo.cinemeta'); + + // Add Cinemeta to the beginning of the list + setCommunityAddons([cinemetaAddon, ...validAddons]); + } catch (error) { + logger.error('Failed to load community addons:', error); + setCommunityError('Failed to load community addons. Please try again later.'); + // Still show Cinemeta if the community list fails to load + setCommunityAddons([cinemetaAddon]); + } finally { + setCommunityLoading(false); + } + }; + const handleAddAddon = async (url?: string) => { const urlToInstall = url || addonUrl; if (!urlToInstall) { - Alert.alert('Error', 'Please enter an addon URL'); + Alert.alert('Error', 'Please enter an addon URL or select a community addon'); return; } @@ -634,6 +728,7 @@ const AddonsScreen = () => { const refreshAddons = async () => { loadAddons(); + loadCommunityAddons(); }; const moveAddonUp = (addon: ExtendedManifest) => { @@ -896,6 +991,66 @@ const AddonsScreen = () => { ); }; + // Function to render community addon items + const renderCommunityAddonItem = ({ item }: { item: CommunityAddon }) => { + const { manifest, transportUrl } = item; + const types = manifest.types || []; + const description = manifest.description || 'No description provided.'; + // @ts-ignore - logo might exist + const logo = manifest.logo || null; + const categoryText = types.length > 0 + ? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') + : 'General'; + // Check if addon is configurable + const isConfigurable = manifest.behaviorHints?.configurable === true; + + return ( + + {logo ? ( + + ) : ( + + + + )} + + {manifest.name} + {description} + + v{manifest.version || 'N/A'} + + {categoryText} + + + + {isConfigurable && ( + handleConfigureAddon(manifest, transportUrl)} + > + + + )} + handleAddAddon(transportUrl)} + disabled={installing} + > + {installing ? ( + + ) : ( + + )} + + + + ); + }; + const StatsCard = ({ value, label }: { value: number; label: string }) => ( {value} @@ -1031,6 +1186,95 @@ const AddonsScreen = () => { )} + + {/* Separator */} + + + {/* Community Addons Section */} + + COMMUNITY ADDONS + + {communityLoading ? ( + + + + ) : communityError ? ( + + + {communityError} + + ) : communityAddons.length === 0 ? ( + + + No community addons available + + ) : ( + communityAddons.map((item, index) => ( + + + + {item.manifest.logo ? ( + + ) : ( + + + + )} + + {item.manifest.name} + + v{item.manifest.version || 'N/A'} + + + {item.manifest.types && item.manifest.types.length > 0 + ? item.manifest.types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') + : 'General'} + + + + + {item.manifest.behaviorHints?.configurable && ( + handleConfigureAddon(item.manifest, item.transportUrl)} + > + + + )} + handleAddAddon(item.transportUrl)} + disabled={installing} + > + {installing ? ( + + ) : ( + + )} + + + + + + {item.manifest.description + ? (item.manifest.description.length > 100 + ? item.manifest.description.substring(0, 100) + '...' + : item.manifest.description) + : 'No description provided.'} + + + + )) + )} + + )} diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 4ed88161..49b161f9 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -675,8 +675,22 @@ export class TraktService { throw new Error(`API request failed: ${response.status}`); } - const responseData = await response.json() as T; - + // Handle "No Content" responses (204/205) which have no JSON body + if (response.status === 204 || response.status === 205) { + // Return null casted to expected type to satisfy caller's generic + return null as unknown as T; + } + + // Some endpoints (e.g., DELETE) may also return empty body with 200. Attempt safe parse. + let responseData: T; + try { + responseData = await response.json() as T; + } catch (parseError) { + // If body is empty, return null instead of throwing + logger.warn(`[TraktService] Empty JSON body for ${endpoint}, returning null`); + return null as unknown as T; + } + // Debug log successful scrobble responses if (endpoint.includes('/scrobble/')) { logger.log(`[TraktService] DEBUG API Success for ${endpoint}:`, responseData); @@ -1504,6 +1518,50 @@ export class TraktService { logger.error('[TraktService] Debug image cache failed:', error); } } + + /** + * Delete a playback progress entry on Trakt by its playback `id`. + * Returns true if the request succeeded (204). + */ + public async deletePlaybackItem(playbackId: number): Promise { + try { + if (!this.accessToken) return false; + await this.apiRequest(`/sync/playback/${playbackId}`, 'DELETE'); + return true; // trakt returns 204 no-content on success + } catch (error) { + logger.error('[TraktService] Failed to delete playback item:', error); + return false; + } + } + + /** + * Convenience helper: find a playback entry matching imdb id (and optional season/episode) and delete it. + */ + public async deletePlaybackForContent(imdbId: string, type: 'movie' | 'series', season?: number, episode?: number): Promise { + try { + if (!this.accessToken) return false; + const progressItems = await this.getPlaybackProgress(); + const target = progressItems.find(item => { + if (type === 'movie' && item.type === 'movie' && item.movie?.ids.imdb === imdbId) { + return true; + } + if (type === 'series' && item.type === 'episode' && item.show?.ids.imdb === imdbId) { + if (season !== undefined && episode !== undefined) { + return item.episode?.season === season && item.episode?.number === episode; + } + return true; // match any episode of the show if specific not provided + } + return false; + }); + if (target) { + return await this.deletePlaybackItem(target.id); + } + return false; + } catch (error) { + logger.error('[TraktService] Error deleting playback for content:', error); + return false; + } + } } // Export a singleton instance