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.

This commit is contained in:
tapframe 2025-07-05 13:10:19 +05:30
parent bbc0a273fd
commit 11fcacc9a7
3 changed files with 319 additions and 4 deletions

View file

@ -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<ContinueWatchingRef>((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 ||

View file

@ -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<CommunityAddon[]>([]);
const [communityLoading, setCommunityLoading] = useState(true);
const [communityError, setCommunityError] = useState<string | null>(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<CommunityAddon[]>('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 (
<View style={styles.communityAddonItem}>
{logo ? (
<ExpoImage
source={{ uri: logo }}
style={styles.communityAddonIcon}
contentFit="contain"
/>
) : (
<View style={styles.communityAddonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.darkGray} />
</View>
)}
<View style={styles.communityAddonDetails}>
<Text style={styles.communityAddonName}>{manifest.name}</Text>
<Text style={styles.communityAddonDesc} numberOfLines={2}>{description}</Text>
<View style={styles.communityAddonMetaContainer}>
<Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text>
<Text style={styles.communityAddonDot}></Text>
<Text style={styles.communityAddonCategory}>{categoryText}</Text>
</View>
</View>
<View style={styles.addonActionButtons}>
{isConfigurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(manifest, transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(transportUrl)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
);
};
const StatsCard = ({ value, label }: { value: number; label: string }) => (
<View style={styles.statsCard}>
<Text style={styles.statsValue}>{value}</Text>
@ -1031,6 +1186,95 @@ const AddonsScreen = () => {
)}
</View>
</View>
{/* Separator */}
<View style={styles.sectionSeparator} />
{/* Community Addons Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>COMMUNITY ADDONS</Text>
<View style={styles.addonList}>
{communityLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : communityError ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="error-outline" size={32} color={colors.error} />
<Text style={styles.emptyText}>{communityError}</Text>
</View>
) : communityAddons.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="extension-off" size={32} color={colors.mediumGray} />
<Text style={styles.emptyText}>No community addons available</Text>
</View>
) : (
communityAddons.map((item, index) => (
<View
key={item.transportUrl}
style={{ marginBottom: index === communityAddons.length - 1 ? 32 : 16 }}
>
<View style={styles.addonItem}>
<View style={styles.addonHeader}>
{item.manifest.logo ? (
<ExpoImage
source={{ uri: item.manifest.logo }}
style={styles.addonIcon}
contentFit="contain"
/>
) : (
<View style={styles.addonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.mediumGray} />
</View>
)}
<View style={styles.addonTitleContainer}>
<Text style={styles.addonName}>{item.manifest.name}</Text>
<View style={styles.addonMetaContainer}>
<Text style={styles.addonVersion}>v{item.manifest.version || 'N/A'}</Text>
<Text style={styles.addonDot}></Text>
<Text style={styles.addonCategory}>
{item.manifest.types && item.manifest.types.length > 0
? item.manifest.types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'General'}
</Text>
</View>
</View>
<View style={styles.addonActions}>
{item.manifest.behaviorHints?.configurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(item.manifest, item.transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(item.transportUrl)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
<Text style={styles.addonDescription}>
{item.manifest.description
? (item.manifest.description.length > 100
? item.manifest.description.substring(0, 100) + '...'
: item.manifest.description)
: 'No description provided.'}
</Text>
</View>
</View>
))
)}
</View>
</View>
</ScrollView>
)}

View file

@ -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<boolean> {
try {
if (!this.accessToken) return false;
await this.apiRequest<null>(`/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<boolean> {
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