mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
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:
parent
bbc0a273fd
commit
11fcacc9a7
3 changed files with 319 additions and 4 deletions
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue