Enhance HeroSection and watch progress management with Trakt integration

This update improves the HeroSection component by adding functionality to determine if content has been watched based on Trakt progress. The ActionButtons now reflect the watched state with updated styles and indicators. Additionally, the useWatchProgress hook has been modified to always show current episode progress, ensuring accurate display of watched status. The useTraktIntegration hook has also been enhanced to fetch and merge watched movies, improving synchronization with Trakt.
This commit is contained in:
tapframe 2025-06-19 23:26:11 +05:30
parent cdec184c14
commit 671861c207
3 changed files with 277 additions and 99 deletions

View file

@ -77,7 +77,9 @@ const ActionButtons = React.memo(({
id,
navigation,
playButtonText,
animatedStyle
animatedStyle,
isWatched,
watchProgress
}: {
handleShowStreams: () => void;
toggleLibrary: () => void;
@ -87,6 +89,8 @@ const ActionButtons = React.memo(({
navigation: any;
playButtonText: string;
animatedStyle: any;
isWatched: boolean;
watchProgress: any;
}) => {
const { currentTheme } = useTheme();
@ -122,19 +126,48 @@ const ActionButtons = React.memo(({
}
}, [id, navigation]);
// Determine play button style and text based on watched status
const playButtonStyle = useMemo(() => {
if (isWatched) {
return [styles.actionButton, styles.playButton, styles.watchedPlayButton];
}
return [styles.actionButton, styles.playButton];
}, [isWatched]);
const playButtonTextStyle = useMemo(() => {
if (isWatched) {
return [styles.playButtonText, styles.watchedPlayButtonText];
}
return styles.playButtonText;
}, [isWatched]);
const finalPlayButtonText = useMemo(() => {
if (isWatched) {
return 'Watch Again';
}
return playButtonText;
}, [isWatched, playButtonText]);
return (
<Animated.View style={[styles.actionButtons, animatedStyle]}>
<TouchableOpacity
style={[styles.actionButton, styles.playButton]}
style={playButtonStyle}
onPress={handleShowStreams}
activeOpacity={0.85}
>
<MaterialIcons
name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"}
name={isWatched ? "replay" : (playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow")}
size={24}
color="#000"
color={isWatched ? "#fff" : "#000"}
/>
<Text style={styles.playButtonText}>{playButtonText}</Text>
<Text style={playButtonTextStyle}>{finalPlayButtonText}</Text>
{/* Subtle watched indicator in play button */}
{isWatched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check" size={12} color="#fff" />
</View>
)}
</TouchableOpacity>
<TouchableOpacity
@ -199,12 +232,13 @@ const ActionButtons = React.memo(({
);
});
// Enhanced WatchProgress Component with Trakt integration
// Enhanced WatchProgress Component with Trakt integration and watched status
const WatchProgressDisplay = React.memo(({
watchProgress,
type,
getEpisodeDetails,
animatedStyle,
isWatched
}: {
watchProgress: {
currentTime: number;
@ -217,15 +251,68 @@ const WatchProgressDisplay = React.memo(({
type: 'movie' | 'series';
getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null;
animatedStyle: any;
isWatched: boolean;
}) => {
const { currentTheme } = useTheme();
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext();
// Handle manual Trakt sync
const handleTraktSync = useMemo(() => async () => {
if (isTraktAuthenticated && forceSyncTraktProgress) {
logger.log('[HeroSection] Manual Trakt sync requested');
try {
const success = await forceSyncTraktProgress();
logger.log(`[HeroSection] Manual Trakt sync ${success ? 'successful' : 'failed'}`);
} catch (error) {
logger.error('[HeroSection] Manual Trakt sync error:', error);
}
}
}, [isTraktAuthenticated, forceSyncTraktProgress]);
// Memoized progress calculation with Trakt integration
const progressData = useMemo(() => {
// If content is fully watched, show watched status instead of progress
if (isWatched) {
let episodeInfo = '';
if (type === 'series' && watchProgress?.episodeId) {
const details = getEpisodeDetails(watchProgress.episodeId);
if (details) {
episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`;
}
}
const watchedDate = watchProgress?.lastUpdated
? new Date(watchProgress.lastUpdated).toLocaleDateString()
: new Date().toLocaleDateString();
// Determine if watched via Trakt or local
const watchedViaTrakt = isTraktAuthenticated &&
watchProgress?.traktProgress !== undefined &&
watchProgress.traktProgress >= 95;
return {
progressPercent: 100,
formattedTime: watchedDate,
episodeInfo,
displayText: watchedViaTrakt ? 'Watched on Trakt' : 'Watched',
syncStatus: isTraktAuthenticated && watchProgress?.traktSynced ? '' : '', // Clean look for watched
isTraktSynced: watchProgress?.traktSynced && isTraktAuthenticated,
isWatched: true
};
}
if (!watchProgress || watchProgress.duration === 0) return null;
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
// Determine which progress to show - prioritize Trakt if available and authenticated
let progressPercent;
let isUsingTraktProgress = false;
if (isTraktAuthenticated && watchProgress.traktProgress !== undefined) {
progressPercent = watchProgress.traktProgress;
isUsingTraktProgress = true;
} else {
progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
}
const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString();
let episodeInfo = '';
@ -242,7 +329,12 @@ const WatchProgressDisplay = React.memo(({
// Show Trakt sync status if user is authenticated
if (isTraktAuthenticated) {
if (watchProgress.traktSynced) {
if (isUsingTraktProgress) {
syncStatus = ' • Using Trakt progress';
if (watchProgress.traktSynced) {
syncStatus = ' • Synced with Trakt';
}
} else if (watchProgress.traktSynced) {
syncStatus = ' • Synced with Trakt';
// If we have specific Trakt progress that differs from local, mention it
if (watchProgress.traktProgress !== undefined &&
@ -260,9 +352,10 @@ const WatchProgressDisplay = React.memo(({
episodeInfo,
displayText,
syncStatus,
isTraktSynced: watchProgress.traktSynced && isTraktAuthenticated
isTraktSynced: watchProgress.traktSynced && isTraktAuthenticated,
isWatched: false
};
}, [watchProgress, type, getEpisodeDetails, isTraktAuthenticated]);
}, [watchProgress, type, getEpisodeDetails, isTraktAuthenticated, isWatched]);
if (!progressData) return null;
@ -274,14 +367,26 @@ const WatchProgressDisplay = React.memo(({
styles.watchProgressFill,
{
width: `${progressData.progressPercent}%`,
backgroundColor: progressData.isTraktSynced
? '#E50914' // Netflix red for Trakt synced content
: currentTheme.colors.primary
backgroundColor: progressData.isWatched
? '#666' // Subtle gray for completed
: progressData.isTraktSynced
? '#E50914' // Netflix red for Trakt synced content
: currentTheme.colors.primary
}
]}
/>
{/* Trakt sync indicator */}
{progressData.isTraktSynced && (
{/* Subtle watched indicator */}
{progressData.isWatched && (
<View style={styles.watchedProgressIndicator}>
<MaterialIcons
name="check"
size={6}
color="rgba(255,255,255,0.8)"
/>
</View>
)}
{/* Trakt sync indicator for non-watched content */}
{progressData.isTraktSynced && !progressData.isWatched && (
<View style={styles.traktSyncIndicator}>
<MaterialIcons
name="sync"
@ -291,10 +396,30 @@ const WatchProgressDisplay = React.memo(({
</View>
)}
</View>
<Text style={[styles.watchProgressText, { color: currentTheme.colors.textMuted }]}>
{progressData.displayText}{progressData.episodeInfo} Last watched on {progressData.formattedTime}
{progressData.syncStatus}
</Text>
<View style={styles.watchProgressTextContainer}>
<Text style={[styles.watchProgressText, {
color: progressData.isWatched ? 'rgba(255,255,255,0.6)' : currentTheme.colors.textMuted,
fontSize: progressData.isWatched ? 10 : 11
}]}>
{progressData.displayText}{progressData.episodeInfo} Last watched on {progressData.formattedTime}
{progressData.syncStatus}
</Text>
{/* Manual Trakt sync button */}
{isTraktAuthenticated && forceSyncTraktProgress && (
<TouchableOpacity
style={styles.traktSyncButton}
onPress={handleTraktSync}
activeOpacity={0.7}
>
<MaterialIcons
name="refresh"
size={14}
color={currentTheme.colors.textMuted}
/>
</TouchableOpacity>
)}
</View>
</Animated.View>
);
});
@ -452,6 +577,25 @@ const HeroSection: React.FC<HeroSectionProps> = ({
// Memoized play button text
const playButtonText = useMemo(() => getPlayButtonText(), [getPlayButtonText]);
// Calculate if content is watched (>=95% progress) - check both local and Trakt progress
const isWatched = useMemo(() => {
if (!watchProgress) return false;
// Check Trakt progress first if available and user is authenticated
if (isTraktAuthenticated && watchProgress.traktProgress !== undefined) {
const traktWatched = watchProgress.traktProgress >= 95;
logger.log(`[HeroSection] Trakt authenticated: ${isTraktAuthenticated}, Trakt progress: ${watchProgress.traktProgress}%, Watched: ${traktWatched}`);
return traktWatched;
}
// Fall back to local progress
if (watchProgress.duration === 0) return false;
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
const localWatched = progressPercent >= 95;
logger.log(`[HeroSection] Local progress: ${progressPercent.toFixed(1)}%, Watched: ${localWatched}`);
return localWatched;
}, [watchProgress, isTraktAuthenticated]);
return (
<Animated.View style={[styles.heroSection, heroAnimatedStyle]}>
{/* Optimized Background */}
@ -521,6 +665,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({
type={type}
getEpisodeDetails={getEpisodeDetails}
animatedStyle={watchProgressAnimatedStyle}
isWatched={isWatched}
/>
{/* Optimized Genres */}
@ -540,6 +685,8 @@ const HeroSection: React.FC<HeroSectionProps> = ({
navigation={navigation}
playButtonText={playButtonText}
animatedStyle={buttonsAnimatedStyle}
isWatched={isWatched}
watchProgress={watchProgress}
/>
</View>
</LinearGradient>
@ -623,6 +770,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
width: '100%',
position: 'relative',
},
actionButton: {
flexDirection: 'row',
@ -697,11 +845,32 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
watchedProgressIndicator: {
position: 'absolute',
right: 2,
top: -1,
bottom: -1,
width: 10,
alignItems: 'center',
justifyContent: 'center',
},
watchProgressTextContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
watchProgressText: {
fontSize: 11,
textAlign: 'center',
opacity: 0.85,
letterSpacing: 0.1
letterSpacing: 0.1,
flex: 1,
},
traktSyncButton: {
padding: 4,
borderRadius: 12,
backgroundColor: 'rgba(255,255,255,0.1)',
},
blurBackground: {
position: 'absolute',
@ -737,6 +906,33 @@ const styles = StyleSheet.create({
borderRadius: 25,
backgroundColor: 'rgba(255,255,255,0.15)',
},
watchedIndicator: {
position: 'absolute',
top: 4,
right: 4,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 8,
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
},
watchedPlayButton: {
backgroundColor: '#1e1e1e',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 4,
},
watchedPlayButtonText: {
color: '#fff',
fontWeight: '700',
marginLeft: 6,
fontSize: 15,
},
});
export default React.memo(HeroSection);

View file

@ -281,15 +281,16 @@ export function useTraktIntegration() {
}
try {
logger.log('[useTraktIntegration] Fetching Trakt playback progress...');
const traktProgress = await getTraktPlaybackProgress();
logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} Trakt progress items`);
// Fetch both playback progress and recently watched movies
logger.log('[useTraktIntegration] Fetching Trakt playback progress and watched movies...');
const [traktProgress, watchedMovies] = await Promise.all([
getTraktPlaybackProgress(),
traktService.getWatchedMovies()
]);
if (traktProgress.length === 0) {
logger.log('[useTraktIntegration] No Trakt progress found - user may not have any content in progress');
return true; // Not an error, just no data
}
logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} Trakt progress items, ${watchedMovies.length} watched movies`);
// Process playback progress (in-progress items)
for (const item of traktProgress) {
try {
let id: string;
@ -299,14 +300,14 @@ export function useTraktIntegration() {
if (item.type === 'movie' && item.movie) {
id = item.movie.ids.imdb;
type = 'movie';
logger.log(`[useTraktIntegration] Processing Trakt movie: ${item.movie.title} (${id}) - ${item.progress}%`);
logger.log(`[useTraktIntegration] Processing Trakt movie progress: ${item.movie.title} (${id}) - ${item.progress}%`);
} else if (item.type === 'episode' && item.show && item.episode) {
id = item.show.ids.imdb;
type = 'series';
episodeId = `${id}:${item.episode.season}:${item.episode.number}`;
logger.log(`[useTraktIntegration] Processing Trakt episode: ${item.show.title} S${item.episode.season}E${item.episode.number} (${id}) - ${item.progress}%`);
logger.log(`[useTraktIntegration] Processing Trakt episode progress: ${item.show.title} S${item.episode.season}E${item.episode.number} (${id}) - ${item.progress}%`);
} else {
logger.warn(`[useTraktIntegration] Skipping invalid Trakt item:`, item);
logger.warn(`[useTraktIntegration] Skipping invalid Trakt progress item:`, item);
continue;
}
@ -323,7 +324,27 @@ export function useTraktIntegration() {
}
}
logger.log(`[useTraktIntegration] Successfully merged ${traktProgress.length} Trakt progress entries`);
// Process watched movies (100% completed)
for (const movie of watchedMovies) {
try {
if (movie.movie?.ids?.imdb) {
const id = movie.movie.ids.imdb;
const watchedAt = movie.last_watched_at;
logger.log(`[useTraktIntegration] Processing watched movie: ${movie.movie.title} (${id}) - 100% watched on ${watchedAt}`);
await storageService.mergeWithTraktProgress(
id,
'movie',
100, // 100% progress for watched items
watchedAt
);
}
} catch (error) {
logger.error('[useTraktIntegration] Error merging watched movie:', error);
}
}
logger.log(`[useTraktIntegration] Successfully merged ${traktProgress.length} progress items + ${watchedMovies.length} watched movies`);
return true;
} catch (error) {
logger.error('[useTraktIntegration] Error fetching and merging Trakt progress:', error);
@ -362,6 +383,24 @@ export function useTraktIntegration() {
}
}, [isAuthenticated, fetchAndMergeTraktProgress]);
// Periodic sync - check for updates every 2 minutes when authenticated
useEffect(() => {
if (!isAuthenticated) return;
const intervalId = setInterval(() => {
logger.log('[useTraktIntegration] Periodic Trakt sync check');
fetchAndMergeTraktProgress().then((success) => {
if (success) {
logger.log('[useTraktIntegration] Periodic sync completed successfully');
}
}).catch(error => {
logger.error('[useTraktIntegration] Periodic sync failed:', error);
});
}, 2 * 60 * 1000); // 2 minutes
return () => clearInterval(intervalId);
}, [isAuthenticated, fetchAndMergeTraktProgress]);
// Trigger sync when auth status is manually refreshed (for login scenarios)
useEffect(() => {
if (isAuthenticated) {

View file

@ -91,62 +91,8 @@ export const useWatchProgress = (
if (episodeId) {
const progress = await storageService.getWatchProgress(id, type, episodeId);
if (progress) {
const progressPercent = (progress.currentTime / progress.duration) * 100;
// If current episode is finished (≥95%), try to find next unwatched episode
if (progressPercent >= 95) {
const currentEpNum = getEpisodeNumber(episodeId);
if (currentEpNum && episodes.length > 0) {
// Find the next episode
const nextEpisode = episodes.find(ep => {
// First check in same season
if (ep.season_number === currentEpNum.season && ep.episode_number > currentEpNum.episode) {
const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`;
const epProgress = seriesProgresses.find(p => p.episodeId === epId);
if (!epProgress) return true;
const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100;
return percent < 95;
}
// Then check next seasons
if (ep.season_number > currentEpNum.season) {
const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`;
const epProgress = seriesProgresses.find(p => p.episodeId === epId);
if (!epProgress) return true;
const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100;
return percent < 95;
}
return false;
});
if (nextEpisode) {
const nextEpisodeId = nextEpisode.stremioId ||
`${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`;
const nextProgress = await storageService.getWatchProgress(id, type, nextEpisodeId);
if (nextProgress) {
setWatchProgress({
...nextProgress,
episodeId: nextEpisodeId,
traktSynced: nextProgress.traktSynced,
traktProgress: nextProgress.traktProgress
});
} else {
setWatchProgress({
currentTime: 0,
duration: 0,
lastUpdated: Date.now(),
episodeId: nextEpisodeId,
traktSynced: false
});
}
return;
}
}
// If no next episode found or current episode is finished, show no progress
setWatchProgress(null);
return;
}
// If current episode is not finished, show its progress
// Always show the current episode progress when viewing it specifically
// This allows HeroSection to properly display watched state
setWatchProgress({
...progress,
episodeId,
@ -194,17 +140,14 @@ export const useWatchProgress = (
// For movies
const progress = await storageService.getWatchProgress(id, type, episodeId);
if (progress && progress.currentTime > 0) {
const progressPercent = (progress.currentTime / progress.duration) * 100;
if (progressPercent >= 95) {
setWatchProgress(null);
} else {
setWatchProgress({
...progress,
episodeId,
traktSynced: progress.traktSynced,
traktProgress: progress.traktProgress
});
}
// Always show progress data, even if watched (≥95%)
// The HeroSection will handle the "watched" state display
setWatchProgress({
...progress,
episodeId,
traktSynced: progress.traktSynced,
traktProgress: progress.traktProgress
});
} else {
setWatchProgress(null);
}