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,