diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index 678af2b..64b7e1c 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -459,7 +459,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = Nuvio; + PRODUCT_NAME = "Nuvio"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -490,8 +490,8 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = com.nuviohub.app; - PRODUCT_NAME = Nuvio; + PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app"; + PRODUCT_NAME = "Nuvio"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist index 5c4a13d..2e04327 100644 --- a/ios/Nuvio/Info.plist +++ b/ios/Nuvio/Info.plist @@ -1,99 +1,101 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Nuvio - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.2.7 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - nuvio - com.nuvio.app - - - - CFBundleURLSchemes - - exp+nuvio - - - - CFBundleVersion - 22 - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSBonjourServices - - _http._tcp - - NSLocalNetworkUsageDescription - Allow $(PRODUCT_NAME) to access your local network - RCTNewArchEnabled - - RCTRootViewBackgroundColor - 4278322180 - UIBackgroundModes - - audio - - UIFileSharingEnabled - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Dark - UIViewControllerBasedStatusBarAppearance - - - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nuvio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.7 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nuvio + com.nuvio.app + + + + CFBundleURLSchemes + + exp+nuvio + + + + CFBundleVersion + 22 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _http._tcp + + NSLocalNetworkUsageDescription + Allow $(PRODUCT_NAME) to access your local network + NSMicrophoneUsageDescription + This app does not require microphone access. + RCTNewArchEnabled + + RCTRootViewBackgroundColor + 4278322180 + UIBackgroundModes + + audio + + UIFileSharingEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + UIViewControllerBasedStatusBarAppearance + + + \ No newline at end of file diff --git a/ios/Nuvio/NuvioRelease.entitlements b/ios/Nuvio/NuvioRelease.entitlements index 0c67376..a0bc443 100644 --- a/ios/Nuvio/NuvioRelease.entitlements +++ b/ios/Nuvio/NuvioRelease.entitlements @@ -1,5 +1,10 @@ - - + + aps-environment + development + com.apple.developer.associated-domains + + + \ No newline at end of file diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index e80f2cd..a843e4c 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -43,8 +43,11 @@ import SpeedModal from './modals/SpeedModal'; import PlayerControls from './controls/PlayerControls'; import CustomSubtitles from './subtitles/CustomSubtitles'; import { SourcesModal } from './modals/SourcesModal'; +import { EpisodesModal } from './modals/EpisodesModal'; +import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; import VlcVideoPlayer, { VlcPlayerRef } from './VlcVideoPlayer'; import { stremioService } from '../../services/stremioService'; +import { Episode } from '../../types/metadata'; import { shouldUseKSPlayer } from '../../utils/playerSelection'; import axios from 'axios'; import * as Brightness from 'expo-brightness'; @@ -81,7 +84,8 @@ const AndroidVideoPlayer: React.FC = () => { episodeId, imdbId, availableStreams: passedAvailableStreams, - backdrop + backdrop, + groupedEpisodes } = route.params; // Opt-in flag to use VLC backend @@ -469,6 +473,9 @@ const AndroidVideoPlayer: React.FC = () => { const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); const [showSourcesModal, setShowSourcesModal] = useState(false); + const [showEpisodesModal, setShowEpisodesModal] = useState(false); + const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false); + const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState(null); const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {}); const [currentStreamUrl, setCurrentStreamUrl] = useState(uri); const [currentVideoType, setCurrentVideoType] = useState(videoType); @@ -620,7 +627,7 @@ const AndroidVideoPlayer: React.FC = () => { const shouldLoadMetadata = Boolean(id && type); const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) }); const { settings: appSettings } = useSettings(); - const { metadata, loading: metadataLoading, groupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} }; + const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} }; // Logo animation values const logoScaleAnim = useRef(new Animated.Value(0.8)).current; @@ -2984,6 +2991,56 @@ const AndroidVideoPlayer: React.FC = () => { }, 100); }; + const handleEpisodeSelect = (episode: Episode) => { + logger.log('[AndroidVideoPlayer] Episode selected:', episode.name); + setSelectedEpisodeForStreams(episode); + setShowEpisodesModal(false); + setShowEpisodeStreamsModal(true); + }; + + // Debug: Log when modal state changes + useEffect(() => { + if (showEpisodesModal) { + logger.log('[AndroidVideoPlayer] Episodes modal opened, groupedEpisodes:', groupedEpisodes); + logger.log('[AndroidVideoPlayer] type:', type, 'season:', season, 'episode:', episode); + } + }, [showEpisodesModal, groupedEpisodes, type]); + + const handleEpisodeStreamSelect = async (stream: any) => { + if (!selectedEpisodeForStreams) return; + + setShowEpisodeStreamsModal(false); + + const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); + const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; + const newStreamName = stream.name || stream.title || 'Unknown Stream'; + + setPaused(true); + + setTimeout(() => { + (navigation as any).replace('PlayerAndroid', { + uri: stream.url, + title: title, + episodeTitle: selectedEpisodeForStreams.name, + season: selectedEpisodeForStreams.season_number, + episode: selectedEpisodeForStreams.episode_number, + quality: newQuality, + year: year, + streamProvider: newProvider, + streamName: newStreamName, + headers: stream.headers || undefined, + forceVlc: false, + id, + type: 'series', + episodeId: selectedEpisodeForStreams.stremioId || `${id}:${selectedEpisodeForStreams.season_number}:${selectedEpisodeForStreams.episode_number}`, + imdbId: imdbId ?? undefined, + backdrop: backdrop || undefined, + availableStreams: {}, + groupedEpisodes: groupedEpisodes, + }); + }, 100); + }; + useEffect(() => { if (isVideoLoaded && initialPosition && !isInitialSeekComplete && duration > 0) { logger.log(`[AndroidVideoPlayer] Post-load initial seek to: ${initialPosition}s`); @@ -3377,6 +3434,7 @@ const AndroidVideoPlayer: React.FC = () => { setShowSpeedModal={setShowSpeedModal} isSubtitleModalOpen={showSubtitleModal} setShowSourcesModal={setShowSourcesModal} + setShowEpisodesModal={type === 'series' ? setShowEpisodesModal : undefined} onSliderValueChange={handleSliderValueChange} onSlidingStart={handleSlidingStart} onSlidingComplete={handleSlidingComplete} @@ -4070,6 +4128,27 @@ const AndroidVideoPlayer: React.FC = () => { currentStreamUrl={currentStreamUrl} onSelectStream={handleSelectStream} /> + + {type === 'series' && ( + <> + + + setShowEpisodeStreamsModal(false)} + onSelectStream={handleEpisodeStreamSelect} + metadata={metadata ? { id: metadata.id, name: metadata.name } : undefined} + /> + + )} {/* Error Modal */} {isMounted.current && ( diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 2150226..e80473c 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -44,6 +44,9 @@ import { SpeedModal } from './modals/SpeedModal'; import PlayerControls from './controls/PlayerControls'; import CustomSubtitles from './subtitles/CustomSubtitles'; import { SourcesModal } from './modals/SourcesModal'; +import { EpisodesModal } from './modals/EpisodesModal'; +import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; +import { Episode } from '../../types/metadata'; import axios from 'axios'; import { stremioService } from '../../services/stremioService'; import * as Brightness from 'expo-brightness'; @@ -53,12 +56,6 @@ const KSPlayerCore: React.FC = () => { const route = useRoute>(); const { uri, headers, streamProvider } = route.params as any; - console.log('[KSPlayerCore] Received navigation params:', { - uri, - headers, - headersKeys: headers ? Object.keys(headers) : [], - streamProvider - }); const navigation = useNavigation(); @@ -78,7 +75,8 @@ const KSPlayerCore: React.FC = () => { episodeId, imdbId, availableStreams: passedAvailableStreams, - backdrop + backdrop, + groupedEpisodes } = route.params; // Initialize Trakt autosync @@ -201,6 +199,9 @@ const KSPlayerCore: React.FC = () => { const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); const [showSourcesModal, setShowSourcesModal] = useState(false); + const [showEpisodesModal, setShowEpisodesModal] = useState(false); + const [showEpisodeStreamsModal, setShowEpisodeStreamsModal] = useState(false); + const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState(null); const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {}); // Playback speed controls required by PlayerControls const speedOptions = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5]; @@ -326,7 +327,7 @@ const KSPlayerCore: React.FC = () => { id: id || 'placeholder', type: type || 'movie' }); - const { metadata, loading: metadataLoading, groupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} }; + const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} }; const { settings } = useSettings(); // Logo animation values @@ -2368,6 +2369,55 @@ const KSPlayerCore: React.FC = () => { }, 100); }; + const handleEpisodeSelect = (episode: Episode) => { + logger.log('[KSPlayerCore] Episode selected:', episode.name); + setSelectedEpisodeForStreams(episode); + setShowEpisodesModal(false); + setShowEpisodeStreamsModal(true); + }; + + // Debug: Log when modal state changes + useEffect(() => { + if (showEpisodesModal) { + logger.log('[KSPlayerCore] Episodes modal opened, groupedEpisodes:', groupedEpisodes); + logger.log('[KSPlayerCore] type:', type, 'season:', season, 'episode:', episode); + } + }, [showEpisodesModal, groupedEpisodes, type]); + + const handleEpisodeStreamSelect = async (stream: any) => { + if (!selectedEpisodeForStreams) return; + + setShowEpisodeStreamsModal(false); + + const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); + const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; + const newStreamName = stream.name || stream.title || 'Unknown Stream'; + + setPaused(true); + + setTimeout(() => { + navigation.replace('PlayerIOS', { + uri: stream.url, + title: title, + episodeTitle: selectedEpisodeForStreams.name, + season: selectedEpisodeForStreams.season_number, + episode: selectedEpisodeForStreams.episode_number, + quality: newQuality, + year: year, + streamProvider: newProvider, + streamName: newStreamName, + headers: stream.headers || undefined, + id, + type: 'series', + episodeId: selectedEpisodeForStreams.stremioId || `${id}:${selectedEpisodeForStreams.season_number}:${selectedEpisodeForStreams.episode_number}`, + imdbId: imdbId ?? undefined, + backdrop: backdrop || undefined, + availableStreams: {}, + groupedEpisodes: groupedEpisodes, + }); + }, 100); + }; + useEffect(() => { if (isVideoLoaded && initialPosition && !isInitialSeekComplete && duration > 0) { logger.log(`[VideoPlayer] Post-load initial seek to: ${initialPosition}s`); @@ -2676,6 +2726,7 @@ const KSPlayerCore: React.FC = () => { setShowSpeedModal={setShowSpeedModal} isSubtitleModalOpen={showSubtitleModal} setShowSourcesModal={setShowSourcesModal} + setShowEpisodesModal={type === 'series' ? setShowEpisodesModal : undefined} onSliderValueChange={handleSliderValueChange} onSlidingStart={handleSlidingStart} onSlidingComplete={handleSlidingComplete} @@ -3370,6 +3421,27 @@ const KSPlayerCore: React.FC = () => { currentStreamUrl={currentStreamUrl} onSelectStream={handleSelectStream} /> + + {type === 'series' && ( + <> + + + setShowEpisodeStreamsModal(false)} + onSelectStream={handleEpisodeStreamSelect} + metadata={metadata ? { id: metadata.id, name: metadata.name } : undefined} + /> + + )} {/* Error Modal */} void; + currentTheme: any; + isCurrent?: boolean; +} + +export const EpisodeCard: React.FC = ({ + episode, + metadata, + tmdbEpisodeOverrides, + episodeProgress, + onPress, + currentTheme, + isCurrent = false, +}) => { + const { width } = Dimensions.get('window'); + const isTablet = width >= 768; + + // Get episode image + let episodeImage = EPISODE_PLACEHOLDER; + if (episode.still_path) { + if (episode.still_path.startsWith('http')) { + episodeImage = episode.still_path; + } else { + const { tmdbService } = require('../../../services/tmdbService'); + const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500'); + if (tmdbUrl) episodeImage = tmdbUrl; + } + } else if (metadata?.poster) { + episodeImage = metadata.poster; + } + + const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : ''; + const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : ''; + const episodeString = seasonNumber && episodeNumber ? `S${seasonNumber.padStart(2, '0')}E${episodeNumber.padStart(2, '0')}` : ''; + + // Get episode progress + const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`; + const tmdbOverride = tmdbEpisodeOverrides?.[`${metadata?.id}:${episode.season_number}:${episode.episode_number}`]; + const effectiveVote = (tmdbOverride?.vote_average ?? episode.vote_average) || 0; + const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime; + if (!episode.still_path && tmdbOverride?.still_path) { + const { tmdbService } = require('../../../services/tmdbService'); + const tmdbUrl = tmdbService.getImageUrl(tmdbOverride.still_path, 'w500'); + if (tmdbUrl) episodeImage = tmdbUrl; + } + const progress = episodeProgress?.[episodeId]; + const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; + const showProgress = progress && progressPercent < 85; + + const formatRuntime = (runtime: number) => { + if (!runtime) return null; + const hours = Math.floor(runtime / 60); + const minutes = runtime % 60; + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + }; + + return ( + + + + {isCurrent && ( + + + + )} + + {episodeString} + + {showProgress && ( + + + + )} + {progressPercent >= 85 && ( + + + + )} + {(!progress || progressPercent === 0) && ( + + )} + + + + + + {episode.name} + + + {effectiveVote > 0 && ( + + + + {effectiveVote.toFixed(1)} + + + )} + {effectiveRuntime && ( + + + + {formatRuntime(effectiveRuntime)} + + + )} + {episode.air_date && ( + + {formatDate(episode.air_date)} + + )} + + + + {episode.overview || 'No description available'} + + + + ); +}; + +const styles = StyleSheet.create({ + episodeCard: { + flexDirection: 'row', + borderRadius: 16, + marginBottom: 16, + overflow: 'hidden', + elevation: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.25, + shadowRadius: 8, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + height: 120, + }, + episodeImageContainer: { + position: 'relative', + width: 120, + height: 120, + }, + episodeImage: { + width: '100%', + height: '100%', + transform: [{ scale: 1.02 }], + }, + episodeNumberBadge: { + position: 'absolute', + bottom: 8, + right: 4, + backgroundColor: 'rgba(0,0,0,0.85)', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.2)', + zIndex: 1, + }, + episodeNumberText: { + color: '#fff', + fontSize: 11, + fontWeight: '600', + letterSpacing: 0.3, + }, + episodeInfo: { + flex: 1, + padding: 12, + justifyContent: 'center', + }, + episodeHeader: { + marginBottom: 4, + }, + episodeTitle: { + fontSize: 15, + fontWeight: '700', + letterSpacing: 0.3, + marginBottom: 2, + }, + episodeMetadata: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + ratingContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + tmdbLogo: { + width: 20, + height: 14, + }, + ratingText: { + fontSize: 13, + fontWeight: '700', + marginLeft: 4, + }, + runtimeContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + runtimeText: { + fontSize: 13, + fontWeight: '600', + marginLeft: 4, + }, + airDateText: { + fontSize: 12, + opacity: 0.8, + }, + episodeOverview: { + fontSize: 13, + lineHeight: 18, + }, + progressBarContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 3, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + progressBar: { + height: '100%', + }, + completedBadge: { + position: 'absolute', + top: 8, + left: 8, + width: 20, + height: 20, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.3)', + zIndex: 2, + }, + unwatchedBadge: { + position: 'absolute', + top: 8, + left: 8, + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + borderStyle: 'dashed', + opacity: 0.85, + }, + currentBadge: { + position: 'absolute', + top: 8, + left: 8, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(0, 0, 0, 0.85)', + alignItems: 'center', + justifyContent: 'center', + zIndex: 2, + borderWidth: 1.5, + borderColor: 'rgba(59, 130, 246, 0.3)', + }, +}); + diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index 8f22b0f..5fa245f 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -38,6 +38,7 @@ interface PlayerControlsProps { setShowSpeedModal: (show: boolean) => void; isSubtitleModalOpen?: boolean; setShowSourcesModal?: (show: boolean) => void; + setShowEpisodesModal?: (show: boolean) => void; // Slider-specific props onSliderValueChange: (value: number) => void; onSlidingStart: () => void; @@ -81,6 +82,7 @@ export const PlayerControls: React.FC = ({ setShowSpeedModal, isSubtitleModalOpen, setShowSourcesModal, + setShowEpisodesModal, onSliderValueChange, onSlidingStart, onSlidingComplete, @@ -587,6 +589,19 @@ export const PlayerControls: React.FC = ({ )} + + {/* Episodes Button */} + {setShowEpisodesModal && ( + setShowEpisodesModal(true)} + > + + + Episodes + + + )} diff --git a/src/components/player/modals/EpisodeStreamsModal.tsx b/src/components/player/modals/EpisodeStreamsModal.tsx new file mode 100644 index 0000000..ffe7259 --- /dev/null +++ b/src/components/player/modals/EpisodeStreamsModal.tsx @@ -0,0 +1,431 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import Animated, { + FadeIn, + FadeOut, + SlideInRight, + SlideOutRight, +} from 'react-native-reanimated'; +import { Episode } from '../../../types/metadata'; +import { Stream } from '../../../types/streams'; +import { stremioService } from '../../../services/stremioService'; +import { logger } from '../../../utils/logger'; + +interface EpisodeStreamsModalProps { + visible: boolean; + episode: Episode | null; + onClose: () => void; + onSelectStream: (stream: Stream) => void; + metadata?: { id?: string; name?: string }; +} + +const { width } = Dimensions.get('window'); +const MENU_WIDTH = Math.min(width * 0.85, 400); + +const QualityBadge = ({ quality }: { quality: string | null }) => { + if (!quality) return null; + + const qualityNum = parseInt(quality); + let color = '#8B5CF6'; + let label = `${quality}p`; + + if (qualityNum >= 2160) { + color = '#F59E0B'; + label = '4K'; + } else if (qualityNum >= 1080) { + color = '#EF4444'; + label = 'FHD'; + } else if (qualityNum >= 720) { + color = '#10B981'; + label = 'HD'; + } + + return ( + + + {label} + + + ); +}; + +export const EpisodeStreamsModal: React.FC = ({ + visible, + episode, + onClose, + onSelectStream, + metadata, +}) => { + const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: Stream[]; addonName: string } }>({}); + const [isLoading, setIsLoading] = useState(false); + const [hasErrors, setHasErrors] = useState([]); + + useEffect(() => { + if (visible && episode && metadata?.id) { + fetchStreams(); + } else { + setAvailableStreams({}); + setIsLoading(false); + setHasErrors([]); + } + }, [visible, episode, metadata?.id]); + + const fetchStreams = async () => { + if (!episode || !metadata?.id) return; + + setIsLoading(true); + setHasErrors([]); + setAvailableStreams({}); + + try { + const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`; + const allStreams: { [providerId: string]: { streams: Stream[]; addonName: string } } = {}; + let completedProviders = 0; + const expectedProviders = new Set(); + + const installedAddons = stremioService.getInstalledAddons(); + const streamAddons = installedAddons.filter((addon: any) => + addon.resources && addon.resources.includes('stream') + ); + + streamAddons.forEach((addon: any) => expectedProviders.add(addon.id)); + + await stremioService.getStreams('series', episodeId, (streams: any, addonId: any, addonName: any, error: any) => { + completedProviders++; + + if (error) { + logger.warn(`[EpisodeStreamsModal] Error from ${addonName}:`, error); + setHasErrors(prev => [...prev, `${addonName || addonId}: ${error.message || 'Unknown error'}`]); + return; + } + + if (streams && streams.length > 0) { + allStreams[addonId] = { + streams: streams, + addonName: addonName || addonId + }; + } + + if (completedProviders >= expectedProviders.size) { + setAvailableStreams(allStreams); + setIsLoading(false); + } + }); + + // Fallback timeout + setTimeout(() => { + if (Object.keys(allStreams).length === 0 && !isLoading) { + setHasErrors(prev => [...prev, 'Timeout: No providers responded']); + } + }, 8000); + + } catch (error) { + logger.error('[EpisodeStreamsModal] Error fetching streams:', error); + setHasErrors(prev => [...prev, `Failed to fetch streams: ${error}`]); + setIsLoading(false); + } + }; + + const getQualityFromTitle = (title?: string): string | null => { + if (!title) return null; + const match = title.match(/(\d+)p/); + return match ? match[1] : null; + }; + + const handleClose = () => { + onClose(); + }; + + if (!visible) return null; + + const sortedProviders = Object.entries(availableStreams); + + return ( + <> + {/* Backdrop */} + + + + + {/* Side Menu */} + + {/* Header */} + + + + {episode?.name || 'Select Stream'} + + {episode && ( + + S{episode.season_number}E{episode.episode_number} + + )} + + + + + + + + {isLoading && ( + + + + Finding available streams... + + + )} + + {!isLoading && sortedProviders.length > 0 && ( + sortedProviders.map(([providerId, providerData]) => ( + + + {providerData.addonName} ({providerData.streams.length}) + + + + {providerData.streams.map((stream, index) => { + const quality = getQualityFromTitle(stream.title) || stream.quality; + + return ( + onSelectStream(stream)} + activeOpacity={0.7} + > + + + + + {stream.title || stream.name || `Stream ${index + 1}`} + + {quality && } + + + {(stream.size || stream.lang) && ( + + {stream.size && ( + + + + {(stream.size / (1024 * 1024 * 1024)).toFixed(1)} GB + + + )} + {stream.lang && ( + + + + {stream.lang.toUpperCase()} + + + )} + + )} + + + + + + + + ); + })} + + + )) + )} + + {!isLoading && sortedProviders.length === 0 && hasErrors.length === 0 && ( + + + + No sources available + + + Try searching for different content + + + )} + + {!isLoading && hasErrors.length > 0 && ( + + + + + Errors occurred + + + {hasErrors.map((error, index) => ( + + {error} + + ))} + + )} + + + + ); +}; + diff --git a/src/components/player/modals/EpisodesModal.tsx b/src/components/player/modals/EpisodesModal.tsx new file mode 100644 index 0000000..e48918b --- /dev/null +++ b/src/components/player/modals/EpisodesModal.tsx @@ -0,0 +1,308 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import Animated, { + FadeIn, + FadeOut, + SlideInRight, + SlideOutRight, +} from 'react-native-reanimated'; +import { Episode } from '../../../types/metadata'; +import { EpisodeCard } from '../cards/EpisodeCard'; +import { storageService } from '../../../services/storageService'; +import { TraktService } from '../../../services/traktService'; +import { logger } from '../../../utils/logger'; + +interface EpisodesModalProps { + showEpisodesModal: boolean; + setShowEpisodesModal: (show: boolean) => void; + groupedEpisodes: { [seasonNumber: number]: Episode[] }; + currentEpisode?: { season: number; episode: number }; + metadata?: { poster?: string; id?: string }; + onSelectEpisode: (episode: Episode) => void; +} + +const { width } = Dimensions.get('window'); +const MENU_WIDTH = Math.min(width * 0.85, 400); + +export const EpisodesModal: React.FC = ({ + showEpisodesModal, + setShowEpisodesModal, + groupedEpisodes, + currentEpisode, + metadata, + onSelectEpisode, +}) => { + const [selectedSeason, setSelectedSeason] = useState(currentEpisode?.season || 1); + const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({}); + const [tmdbEpisodeOverrides, setTmdbEpisodeOverrides] = useState<{ [epKey: string]: { vote_average?: number; runtime?: number; still_path?: string } }>({}); + const [currentTheme, setCurrentTheme] = useState({ + colors: { + text: '#FFFFFF', + textMuted: 'rgba(255,255,255,0.6)', + mediumEmphasis: 'rgba(255,255,255,0.7)', + primary: '#3B82F6', + white: '#FFFFFF', + elevation2: 'rgba(255,255,255,0.05)' + } + }); + + useEffect(() => { + if (currentEpisode?.season) { + setSelectedSeason(currentEpisode.season); + } + }, [currentEpisode]); + + const loadEpisodesProgress = async () => { + if (!metadata?.id) return; + + const allProgress = await storageService.getAllWatchProgress(); + const progress: { [key: string]: { currentTime: number; duration: number; lastUpdated: number } } = {}; + + const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || []; + currentSeasonEpisodes.forEach(episode => { + const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`; + const key = `series:${metadata.id}:${episodeId}`; + if (allProgress[key]) { + progress[episodeId] = { + currentTime: allProgress[key].currentTime, + duration: allProgress[key].duration, + lastUpdated: allProgress[key].lastUpdated + }; + } + }); + + // Trakt watched-history integration + try { + const traktService = TraktService.getInstance(); + const isAuthed = await traktService.isAuthenticated(); + if (isAuthed && metadata?.id) { + const historyItems = await traktService.getWatchedEpisodesHistory(1, 400); + + historyItems.forEach(item => { + if (item.type !== 'episode') return; + + const showImdb = item.show?.ids?.imdb ? `tt${item.show.ids.imdb.replace(/^tt/, '')}` : null; + if (!showImdb || showImdb !== metadata.id) return; + + const season = item.episode?.season; + const epNum = item.episode?.number; + if (season === undefined || epNum === undefined) return; + + const episodeId = `${metadata.id}:${season}:${epNum}`; + const watchedAt = new Date(item.watched_at).getTime(); + + const traktProgressEntry = { + currentTime: 1, + duration: 1, + lastUpdated: watchedAt, + }; + + const existing = progress[episodeId]; + const existingPercent = existing ? (existing.currentTime / existing.duration) * 100 : 0; + + if (!existing || existingPercent < 85) { + progress[episodeId] = traktProgressEntry; + } + }); + } + } catch (err) { + logger.error('[EpisodesModal] Failed to merge Trakt history:', err); + } + + setEpisodeProgress(progress); + }; + + useEffect(() => { + loadEpisodesProgress(); + }, [selectedSeason, metadata?.id]); + + const handleClose = () => { + setShowEpisodesModal(false); + }; + + if (!showEpisodesModal) return null; + + const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b); + const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || []; + + const isEpisodeCurrent = (episode: Episode) => { + return currentEpisode && + episode.season_number === currentEpisode.season && + episode.episode_number === currentEpisode.episode; + }; + + return ( + <> + {/* Backdrop */} + + + + + {/* Side Menu */} + + {/* Header */} + + + Episodes + + + + + + + {/* Season Selector */} + + + {seasons.map((season) => ( + setSelectedSeason(season)} + activeOpacity={0.7} + > + + Season {season} + + + ))} + + + + {/* Episodes List */} + + {currentSeasonEpisodes.length > 0 ? ( + currentSeasonEpisodes.map((episode, index) => { + const isCurrent = isEpisodeCurrent(episode); + + return ( + + onSelectEpisode(episode)} + currentTheme={currentTheme} + isCurrent={isCurrent} + /> + + ); + }) + ) : ( + + + + No episodes available for Season {selectedSeason} + + + )} + + + + ); +}; + diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 63e2b17..4059285 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -114,6 +114,7 @@ export type RootStackParamList = { availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; backdrop?: string; videoType?: string; + groupedEpisodes?: { [seasonNumber: number]: any[] }; }; PlayerAndroid: { uri: string; @@ -134,6 +135,7 @@ export type RootStackParamList = { availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; backdrop?: string; videoType?: string; + groupedEpisodes?: { [seasonNumber: number]: any[] }; }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; Credits: { mediaId: string; mediaType: string }; diff --git a/src/services/localScraperService.ts b/src/services/localScraperService.ts index f9bf2d6..47683e5 100644 --- a/src/services/localScraperService.ts +++ b/src/services/localScraperService.ts @@ -1132,11 +1132,6 @@ class LocalScraperService { hasBody: !!axiosConfig.data }); const response = await axios(axiosConfig); - logger.log(`[Sandbox] Axios response received:`, { - status: response.status, - statusText: response.statusText, - dataType: typeof response.data - }); return { ok: response.status >= 200 && response.status < 300,