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