mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-12 20:50:22 +00:00
Merge pull request #270 from AdityasahuX07/patch-7
Enhance AppleTVHero component functionality by Adding Play and Save to library Button.
This commit is contained in:
commit
52065a1462
1 changed files with 311 additions and 88 deletions
|
|
@ -39,6 +39,12 @@ import { useSettings } from '../../hooks/useSettings';
|
|||
import { useTrailer } from '../../contexts/TrailerContext';
|
||||
import TrailerService from '../../services/trailerService';
|
||||
import TrailerPlayer from '../video/TrailerPlayer';
|
||||
import { useLibrary } from '../../hooks/useLibrary';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useTraktContext } from '../../contexts/TraktContext';
|
||||
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||
import { useWatchProgress } from '../../hooks/useWatchProgress';
|
||||
import { streamCacheService } from '../../services/streamCacheService';
|
||||
|
||||
interface AppleTVHeroProps {
|
||||
featuredContent: StreamingContent | null;
|
||||
|
|
@ -144,6 +150,16 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const insets = useSafeAreaInsets();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer();
|
||||
const { toggleLibrary, isInLibrary: checkIsInLibrary } = useLibrary();
|
||||
const { showSaved, showTraktSaved, showRemoved, showTraktRemoved } = useToast();
|
||||
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
|
||||
|
||||
// Library and watch state
|
||||
const [inLibrary, setInLibrary] = useState(false);
|
||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||
const [isWatched, setIsWatched] = useState(false);
|
||||
const [playButtonText, setPlayButtonText] = useState('Play');
|
||||
const [type, setType] = useState<'movie' | 'series'>('movie');
|
||||
|
||||
// Create internal scrollY if not provided externally
|
||||
const internalScrollY = useSharedValue(0);
|
||||
|
|
@ -185,6 +201,18 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
|
||||
const currentItem = items[currentIndex] || null;
|
||||
|
||||
// Use watch progress hook
|
||||
const {
|
||||
watchProgress,
|
||||
getPlayButtonText: getProgressPlayButtonText,
|
||||
loadWatchProgress
|
||||
} = useWatchProgress(
|
||||
currentItem?.id || '',
|
||||
type,
|
||||
undefined,
|
||||
[] // Pass episodes if you have them for series
|
||||
);
|
||||
|
||||
// Animation values
|
||||
const dragProgress = useSharedValue(0);
|
||||
const dragDirection = useSharedValue(0); // -1 for left, 1 for right
|
||||
|
|
@ -196,6 +224,15 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const trailerMuted = settings?.trailerMuted ?? true;
|
||||
const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in
|
||||
|
||||
// Handler for trailer end
|
||||
const handleTrailerEnd = useCallback(() => {
|
||||
logger.info('[AppleTVHero] Trailer ended');
|
||||
setTrailerPlaying(false);
|
||||
// Fade back to thumbnail
|
||||
trailerOpacity.value = withTiming(0, { duration: 300 });
|
||||
thumbnailOpacity.value = withTiming(1, { duration: 300 });
|
||||
}, [setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
|
||||
|
||||
// Animated style for trailer container - 60% height with zoom
|
||||
const trailerContainerStyle = useAnimatedStyle(() => {
|
||||
// Faster fade out during drag - complete fade by 0.3 progress instead of 1.0
|
||||
|
|
@ -480,19 +517,196 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
logger.error('[AppleTVHero] Trailer playback error');
|
||||
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]);
|
||||
|
||||
// Handle trailer end
|
||||
const handleTrailerEnd = useCallback(() => {
|
||||
logger.info('[AppleTVHero] Trailer ended');
|
||||
setTrailerPlaying(false);
|
||||
// Update state when current item changes and load watch progress
|
||||
useEffect(() => {
|
||||
if (currentItem) {
|
||||
setType(currentItem.type as 'movie' | 'series');
|
||||
checkItemStatus(currentItem.id);
|
||||
loadWatchProgress();
|
||||
}
|
||||
}, [currentItem, loadWatchProgress]);
|
||||
|
||||
// Reset trailer state
|
||||
setTrailerReady(false);
|
||||
setTrailerPreloaded(false);
|
||||
// Update play button text and watched state when watch progress changes
|
||||
useEffect(() => {
|
||||
if (currentItem) {
|
||||
const buttonText = getProgressPlayButtonText();
|
||||
setPlayButtonText(buttonText);
|
||||
|
||||
// Smooth fade back to thumbnail
|
||||
trailerOpacity.value = withTiming(0, { duration: 500 });
|
||||
thumbnailOpacity.value = withTiming(1, { duration: 500 });
|
||||
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]);
|
||||
// Update watched state based on progress
|
||||
if (watchProgress) {
|
||||
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
|
||||
setIsWatched(progressPercent >= 85); // Consider watched if 85% or more completed
|
||||
} else {
|
||||
setIsWatched(false);
|
||||
}
|
||||
}
|
||||
}, [watchProgress, getProgressPlayButtonText, currentItem]);
|
||||
|
||||
// Function to check item status
|
||||
const checkItemStatus = useCallback(async (itemId: string) => {
|
||||
try {
|
||||
// Check if item is in library
|
||||
const libraryStatus = checkIsInLibrary(itemId);
|
||||
setInLibrary(libraryStatus);
|
||||
|
||||
// TODO: Check Trakt watchlist status if authenticated
|
||||
if (isTraktAuthenticated) {
|
||||
// await traktService.isInWatchlist(itemId);
|
||||
setIsInWatchlist(Math.random() > 0.5); // Replace with actual Trakt call
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AppleTVHero] Error checking item status:', error);
|
||||
}
|
||||
}, [checkIsInLibrary, isTraktAuthenticated]);
|
||||
|
||||
// Update the handleSaveAction function:
|
||||
const handleSaveAction = useCallback(async (e?: any) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!currentItem) return;
|
||||
|
||||
const wasInLibrary = inLibrary;
|
||||
const wasInWatchlist = isInWatchlist;
|
||||
|
||||
// Update local state immediately for responsiveness
|
||||
setInLibrary(!wasInLibrary);
|
||||
|
||||
try {
|
||||
// Toggle library using the useLibrary hook
|
||||
const success = await toggleLibrary(currentItem);
|
||||
|
||||
if (success) {
|
||||
logger.info('[AppleTVHero] Successfully toggled library:', currentItem.name);
|
||||
} else {
|
||||
logger.warn('[AppleTVHero] Library toggle returned false');
|
||||
}
|
||||
|
||||
// If authenticated with Trakt, also toggle Trakt watchlist
|
||||
if (isTraktAuthenticated) {
|
||||
setIsInWatchlist(!wasInWatchlist);
|
||||
|
||||
// TODO: Replace with your actual Trakt service call
|
||||
// await traktService.toggleWatchlist(currentItem.id, !wasInWatchlist);
|
||||
logger.info('[AppleTVHero] Toggled Trakt watchlist');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[AppleTVHero] Error toggling library:', error);
|
||||
// Revert state on error
|
||||
setInLibrary(wasInLibrary);
|
||||
if (isTraktAuthenticated) {
|
||||
setIsInWatchlist(wasInWatchlist);
|
||||
}
|
||||
}
|
||||
}, [currentItem, inLibrary, isInWatchlist, isTraktAuthenticated, toggleLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]);
|
||||
|
||||
// Play button handler - navigates to Streams screen with progress data if available
|
||||
const handlePlayAction = useCallback(async () => {
|
||||
logger.info('[AppleTVHero] Play button pressed for:', currentItem?.name);
|
||||
if (!currentItem) return;
|
||||
|
||||
// Stop any playing trailer
|
||||
try {
|
||||
setTrailerPlaying(false);
|
||||
} catch {}
|
||||
|
||||
// Check if we should resume based on watch progress
|
||||
const shouldResume = watchProgress &&
|
||||
watchProgress.currentTime > 0 &&
|
||||
(watchProgress.currentTime / watchProgress.duration) < 0.85;
|
||||
|
||||
logger.info('[AppleTVHero] Should resume:', shouldResume, watchProgress);
|
||||
|
||||
try {
|
||||
// Check if we have a cached stream for this content
|
||||
const episodeId = currentItem.type === 'series' && watchProgress?.episodeId
|
||||
? watchProgress.episodeId
|
||||
: undefined;
|
||||
|
||||
logger.info('[AppleTVHero] Looking for cached stream with episodeId:', episodeId);
|
||||
|
||||
const cachedStream = await streamCacheService.getCachedStream(currentItem.id, currentItem.type, episodeId);
|
||||
|
||||
if (cachedStream && cachedStream.stream?.url) {
|
||||
// We have a valid cached stream, navigate directly to player
|
||||
logger.info('[AppleTVHero] Using cached stream for:', currentItem.name);
|
||||
|
||||
// Determine the player route based on platform
|
||||
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
|
||||
|
||||
// Navigate directly to player with cached stream data AND RESUME DATA
|
||||
navigation.navigate(playerRoute as any, {
|
||||
uri: cachedStream.stream.url,
|
||||
title: cachedStream.metadata?.name || currentItem.name,
|
||||
episodeTitle: cachedStream.episodeTitle,
|
||||
season: cachedStream.season,
|
||||
episode: cachedStream.episode,
|
||||
quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined,
|
||||
year: cachedStream.metadata?.year || currentItem.year,
|
||||
streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name,
|
||||
streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream',
|
||||
headers: cachedStream.stream.headers || undefined,
|
||||
forceVlc: false,
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
episodeId: episodeId,
|
||||
imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || currentItem.imdb_id,
|
||||
backdrop: cachedStream.metadata?.backdrop || currentItem.banner,
|
||||
videoType: undefined, // Let player auto-detect
|
||||
// ADD RESUME DATA if we should resume
|
||||
...(shouldResume && watchProgress && {
|
||||
resumeTime: watchProgress.currentTime,
|
||||
duration: watchProgress.duration
|
||||
})
|
||||
} as any);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// No cached stream, navigate to Streams screen with resume data
|
||||
logger.info('[AppleTVHero] No cached stream, navigating to StreamsScreen for:', currentItem.name);
|
||||
|
||||
const navigationParams: any = {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
title: currentItem.name,
|
||||
metadata: {
|
||||
poster: currentItem.poster,
|
||||
banner: currentItem.banner,
|
||||
releaseInfo: currentItem.releaseInfo,
|
||||
genres: currentItem.genres
|
||||
}
|
||||
};
|
||||
|
||||
// Add resume data if we have progress that's not near completion
|
||||
if (shouldResume && watchProgress) {
|
||||
navigationParams.resumeTime = watchProgress.currentTime;
|
||||
navigationParams.duration = watchProgress.duration;
|
||||
navigationParams.episodeId = watchProgress.episodeId;
|
||||
logger.info('[AppleTVHero] Passing resume data to Streams:', watchProgress.currentTime, watchProgress.duration);
|
||||
}
|
||||
|
||||
navigation.navigate('Streams', navigationParams);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[AppleTVHero] Error handling play action:', error);
|
||||
// Fallback to StreamsScreen on any error
|
||||
navigation.navigate('Streams', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
title: currentItem.name,
|
||||
metadata: {
|
||||
poster: currentItem.poster,
|
||||
banner: currentItem.banner,
|
||||
releaseInfo: currentItem.releaseInfo,
|
||||
genres: currentItem.genres
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [currentItem, navigation, setTrailerPlaying, watchProgress]);
|
||||
|
||||
// Handle fullscreen toggle
|
||||
const handleFullscreenToggle = useCallback(async () => {
|
||||
|
|
@ -569,33 +783,6 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
);
|
||||
}, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
|
||||
|
||||
// Preload next and previous images for instant swiping
|
||||
useEffect(() => {
|
||||
if (items.length <= 1) return;
|
||||
|
||||
const prevIdx = (currentIndex - 1 + items.length) % items.length;
|
||||
const nextIdx = (currentIndex + 1) % items.length;
|
||||
|
||||
const prevItem = items[prevIdx];
|
||||
const nextItem = items[nextIdx];
|
||||
|
||||
const urlsToPreload: { uri: string }[] = [];
|
||||
|
||||
if (prevItem) {
|
||||
const url = prevItem.banner || prevItem.poster;
|
||||
if (url) urlsToPreload.push({ uri: url });
|
||||
}
|
||||
|
||||
if (nextItem) {
|
||||
const url = nextItem.banner || nextItem.poster;
|
||||
if (url) urlsToPreload.push({ uri: url });
|
||||
}
|
||||
|
||||
if (urlsToPreload.length > 0) {
|
||||
FastImage.preload(urlsToPreload);
|
||||
}
|
||||
}, [currentIndex, items]);
|
||||
|
||||
// Callback for updating interaction time
|
||||
const updateInteractionTime = useCallback(() => {
|
||||
lastInteractionRef.current = Date.now();
|
||||
|
|
@ -972,37 +1159,61 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
style={logoAnimatedStyle}
|
||||
>
|
||||
{currentItem.logo && !logoError[currentIndex] ? (
|
||||
<View
|
||||
style={[
|
||||
styles.logoContainer,
|
||||
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
|
||||
? { marginBottom: 4 } // Minimal spacing for small logos
|
||||
: { marginBottom: 8 } // Small spacing for normal logos
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: currentItem.logo }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
|
||||
onError={() => {
|
||||
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
|
||||
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
if (currentItem) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{currentItem.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.logoContainer,
|
||||
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
|
||||
? { marginBottom: 4 } // Minimal spacing for small logos
|
||||
: { marginBottom: 8 } // Small spacing for normal logos
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: currentItem.logo }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
|
||||
onError={() => {
|
||||
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
|
||||
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => {
|
||||
if (currentItem) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{currentItem.name}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Metadata Badge - Always Visible */}
|
||||
<View style={styles.metadataContainer}>
|
||||
|
|
@ -1020,21 +1231,33 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons - Always Visible */}
|
||||
{/* Action Buttons - Play and Save buttons */}
|
||||
<View style={styles.buttonsContainer}>
|
||||
{/* Info Button */}
|
||||
{/* Play Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.playButton}
|
||||
onPress={() => {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
style={[styles.playButton]}
|
||||
onPress={handlePlayAction}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="info-outline" size={28} color="#000" />
|
||||
<Text style={styles.playButtonText}>Info</Text>
|
||||
<MaterialIcons
|
||||
name={playButtonText === 'Resume' ? "replay" : "play-arrow"}
|
||||
size={24}
|
||||
color="#000"
|
||||
/>
|
||||
<Text style={styles.playButtonText}>{playButtonText}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Save Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.saveButton}
|
||||
onPress={handleSaveAction}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={inLibrary ? "bookmark" : "bookmark-outline"}
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
|
@ -1171,25 +1394,25 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#fff',
|
||||
paddingVertical: 14,
|
||||
paddingVertical: 11,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 24,
|
||||
borderRadius: 40,
|
||||
gap: 8,
|
||||
minWidth: 140,
|
||||
minWidth: 130,
|
||||
},
|
||||
playButtonText: {
|
||||
color: '#000',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
secondaryButton: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
saveButton: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 30,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
},
|
||||
paginationContainer: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue