parallel streams loading fixed

This commit is contained in:
Nayif Noushad 2025-04-13 21:34:37 +05:30
parent c6407db317
commit 975f0eb9ef
16 changed files with 666 additions and 732 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 1 MiB

BIN
assets/images/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 1 MiB

122
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "nuvio",
"version": "1.0.0",
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "^14.1.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/slider": "^4.5.6",
@ -23,6 +24,7 @@
"expo": "~52.0.43",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-intent-launcher": "~12.0.2",
"expo-linear-gradient": "~14.0.2",
"expo-notifications": "~0.29.14",
"expo-screen-orientation": "~8.0.4",
@ -38,7 +40,8 @@
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "^15.8.0",
"react-native-video": "^6.12.0"
"react-native-video": "^6.12.0",
"react-native-web": "~0.19.13"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@ -2650,6 +2653,15 @@
"node": ">= 10.0.0"
}
},
"node_modules/@expo/metro-runtime": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-4.0.1.tgz",
"integrity": "sha512-CRpbLvdJ1T42S+lrYa1iZp1KfDeBp4oeZOK3hdpiS5n0vR0nhD6sC1gGF0sTboCTp64tLteikz5Y3j53dvgOIw==",
"license": "MIT",
"peerDependencies": {
"react-native": "*"
}
},
"node_modules/@expo/osascript": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.1.6.tgz",
@ -5790,6 +5802,15 @@
"node": ">=8"
}
},
"node_modules/css-in-js-utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
"license": "MIT",
"dependencies": {
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-select": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
@ -6617,6 +6638,15 @@
}
}
},
"node_modules/expo-intent-launcher": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-12.0.2.tgz",
"integrity": "sha512-9JYyuuONE9AxoZgRyztcgfAaIIGvrUQAbFnYOPZcrHSBWhbF1203S3ho1Hk67jWhC72Um8+pJ+rXBxAp3ZZlxA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-keep-awake": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.3.tgz",
@ -6802,6 +6832,12 @@
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT"
},
"node_modules/fast-loops": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.4.tgz",
"integrity": "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg==",
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@ -7430,6 +7466,12 @@
"node": ">=10.17.0"
}
},
"node_modules/hyphenate-style-name": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -7537,6 +7579,16 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/inline-style-prefixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.4.tgz",
"integrity": "sha512-FwXmZC2zbeeS7NzGjJ6pAiqRhXR0ugUShSNb6GApMl6da0/XGc4MOJsoWAywia52EEWbXNSy0pzkwz/+Y+swSg==",
"license": "MIT",
"dependencies": {
"css-in-js-utils": "^3.1.0",
"fast-loops": "^1.1.3"
}
},
"node_modules/internal-ip": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz",
@ -10119,6 +10171,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@ -10361,6 +10419,30 @@
}
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/react-dom/node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/react-freeze": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
@ -10699,6 +10781,38 @@
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.19.13",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",
"integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1",
"fbjs": "^3.0.4",
"inline-style-prefixer": "^6.0.1",
"memoize-one": "^6.0.0",
"nullthrows": "^1.1.1",
"postcss-value-parser": "^4.2.0",
"styleq": "^0.1.3"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-native-web/node_modules/@react-native/normalize-colors": {
"version": "0.74.89",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz",
"integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==",
"license": "MIT"
},
"node_modules/react-native-web/node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.23.1.tgz",
@ -11765,6 +11879,12 @@
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
"license": "MIT"
},
"node_modules/styleq": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",

View file

@ -39,7 +39,10 @@
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "^15.8.0",
"react-native-video": "^6.12.0"
"react-native-video": "^6.12.0",
"expo-intent-launcher": "~12.0.2",
"react-native-web": "~0.19.13",
"@expo/metro-runtime": "~4.0.1"
},
"devDependencies": {
"@babel/core": "^7.25.2",

View file

@ -111,7 +111,68 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const [recommendations, setRecommendations] = useState<StreamingContent[]>([]);
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
const processStreamSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => {
const processStremioSource = async (type: string, id: string, isEpisode = false) => {
const sourceStartTime = Date.now();
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
const sourceName = 'stremio';
logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`);
try {
await stremioService.getStreams(type, id,
(streams, addonId, addonName, error) => {
const processTime = Date.now() - sourceStartTime;
if (error) {
logger.error(`❌ [${logPrefix}:${sourceName}] Error for addon ${addonName} (${addonId}):`, error);
// Optionally update state to show error for this specific addon?
// For now, just log the error.
} else if (streams && addonId && addonName) {
logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams from ${addonName} (${addonId}) after ${processTime}ms`);
if (streams.length > 0) {
const streamsWithAddon = streams.map(stream => ({
...stream,
name: stream.name || stream.title || 'Unnamed Stream',
addonId: addonId,
addonName: addonName
}));
const updateState = (prevState: GroupedStreams): GroupedStreams => {
logger.log(`🔄 [${logPrefix}:${sourceName}] Updating state for addon ${addonName} (${addonId})`);
return {
...prevState,
[addonId]: {
addonName: addonName,
streams: streamsWithAddon
}
};
};
if (isEpisode) {
setEpisodeStreams(updateState);
} else {
setGroupedStreams(updateState);
}
} else {
logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`);
}
} else {
// Handle case where callback provides null streams without error (e.g., empty results)
logger.log(`🏁 [${logPrefix}:${sourceName}] Finished fetching for addon ${addonName} (${addonId}) with no streams after ${processTime}ms`);
}
}
);
// The function now returns void, just await to let callbacks fire
logger.log(`🏁 [${logPrefix}:${sourceName}] Stremio fetching process initiated`);
} catch (error) {
// Catch errors from the initial call to getStreams (e.g., initialization errors)
logger.error(`❌ [${logPrefix}:${sourceName}] Initial call failed:`, error);
// Maybe update state to show a general Stremio error?
}
// Note: This function completes when getStreams returns, not when all callbacks have fired.
// Loading indicators should probably be managed based on callbacks completing.
};
const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => {
const sourceStartTime = Date.now();
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
@ -120,35 +181,26 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const result = await promise;
logger.log(`✅ [${logPrefix}:${sourceType}] Completed in ${Date.now() - sourceStartTime}ms`);
// If we have results, update immediately
if (Object.keys(result).length > 0) {
// Calculate total streams for logging
const totalStreams = Object.values(result).reduce((acc, group: any) => {
return acc + (group.streams?.length || 0);
}, 0);
const totalStreams = Object.values(result).reduce((acc, group: any) => acc + (group.streams?.length || 0), 0);
logger.log(`📦 [${logPrefix}:${sourceType}] Found ${totalStreams} streams`);
// Update state for this source
const updateState = (prevState: GroupedStreams) => {
logger.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`);
return { ...prevState, ...result };
};
if (isEpisode) {
setEpisodeStreams(prev => {
const newState = {...prev, ...result};
console.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`);
return newState;
});
setEpisodeStreams(updateState);
} else {
setGroupedStreams(prev => {
const newState = {...prev, ...result};
console.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`);
return newState;
});
setGroupedStreams(updateState);
}
} else {
console.log(`⚠️ [${logPrefix}:${sourceType}] No streams found`);
logger.log(`⚠️ [${logPrefix}:${sourceType}] No streams found`);
}
return result;
} catch (error) {
console.error(`❌ [${logPrefix}:${sourceType}] Error:`, error);
logger.error(`❌ [${logPrefix}:${sourceType}] Error:`, error);
return {};
}
};
@ -346,43 +398,17 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.log(' [loadStreams] Using ID as TMDB ID:', tmdbId);
}
console.log('🔄 [loadStreams] Starting parallel stream requests');
console.log('🔄 [loadStreams] Starting stream requests');
// Create an array to store all fetching promises
const fetchPromises = [];
// Start Stremio request
const stremioPromise = processStreamSource('stremio', (async () => {
const newGroupedStreams: GroupedStreams = {};
try {
const responses = await stremioService.getStreams(type, id);
responses.forEach(response => {
const addonId = response.addon;
if (addonId && response.streams.length > 0) {
const streamsWithAddon = response.streams.map(stream => ({
...stream,
name: stream.name || stream.title || 'Unnamed Stream',
addonId: response.addon,
addonName: response.addonName
}));
newGroupedStreams[addonId] = {
addonName: response.addonName,
streams: streamsWithAddon
};
}
});
return newGroupedStreams;
} catch (error) {
console.error('❌ [loadStreams:stremio] Error fetching Stremio streams:', error);
return {};
}
})(), false);
fetchPromises.push(stremioPromise);
// Start Stremio request using the new callback method
// We don't push this promise anymore, as results are handled by callback
processStremioSource(type, id, false);
// Start Source 1 request if we have a TMDB ID
if (tmdbId) {
const source1Promise = processStreamSource('source1', (async () => {
const source1Promise = processExternalSource('source1', (async () => {
try {
const streams = await fetchExternalStreams(
`https://nice-month-production.up.railway.app/embedsu/${tmdbId}`,
@ -408,7 +434,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Start Source 2 request if we have a TMDB ID
if (tmdbId) {
const source2Promise = processStreamSource('source2', (async () => {
const source2Promise = processExternalSource('source2', (async () => {
try {
const streams = await fetchExternalStreams(
`https://vidsrc-api-js-phz6.onrender.com/embedsu/${tmdbId}`,
@ -432,12 +458,12 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
fetchPromises.push(source2Promise);
}
// Wait for all promises to complete - but we already showed results as they arrived
// Wait only for external promises now
const results = await Promise.allSettled(fetchPromises);
const totalTime = Date.now() - startTime;
console.log(`✅ [loadStreams] All requests completed in ${totalTime}ms`);
console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`);
const sourceTypes = ['stremio', 'source1', 'source2'];
const sourceTypes = ['source1', 'source2']; // Removed 'stremio'
results.forEach((result, index) => {
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
console.log(`📊 [loadStreams:${source}] Status: ${result.status}`);
@ -447,18 +473,19 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
});
console.log('🧮 [loadStreams] Summary:');
console.log(' Total time:', totalTime + 'ms');
console.log(' Total time for external sources:', totalTime + 'ms');
// Log the final states
console.log('📦 [loadStreams] Final streams count:',
// Log the final states - this might not include all Stremio addons yet
console.log('📦 [loadStreams] Current combined streams count:',
Object.keys(groupedStreams).length > 0 ?
Object.values(groupedStreams).reduce((acc, group: any) => acc + group.streams.length, 0) :
0
);
// Cache the final streams state
// Cache the final streams state - Note: This might be incomplete if Stremio addons are slow
setGroupedStreams(prev => {
cacheService.setStreams(id, type, prev);
// We might want to reconsider when exactly to cache or mark loading as fully complete
// cacheService.setStreams(id, type, prev); // Maybe cache incrementally in callback?
setPreloadedStreams(prev);
return prev;
});
@ -467,9 +494,11 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.error('❌ [loadStreams] Failed to load streams:', error);
setError('Failed to load streams');
} finally {
// Loading is now complete when external sources finish, Stremio updates happen independently.
// We need a better way to track overall completion if we want a final 'FINISHED' log.
const endTime = Date.now() - startTime;
console.log(`🏁 [loadStreams] FINISHED in ${endTime}ms`);
setLoadingStreams(false);
console.log(`🏁 [loadStreams] External sources FINISHED in ${endTime}ms`);
setLoadingStreams(false); // Mark loading=false, but Stremio might still be working
}
};
@ -500,43 +529,17 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const episodeQuery = `?s=${season}&e=${episode}`;
console.log(` [loadEpisodeStreams] Episode query: ${episodeQuery}`);
console.log('🔄 [loadEpisodeStreams] Starting parallel stream requests');
console.log('🔄 [loadEpisodeStreams] Starting stream requests');
// Create an array to store all fetching promises
const fetchPromises = [];
// Start Stremio request
const stremioPromise = processStreamSource('stremio', (async () => {
const newGroupedStreams: GroupedStreams = {};
try {
const responses = await stremioService.getStreams('series', episodeId);
responses.forEach(response => {
const addonId = response.addon;
if (addonId && response.streams.length > 0) {
const streamsWithAddon = response.streams.map(stream => ({
...stream,
name: stream.name || stream.title || 'Unnamed Stream',
addonId: response.addon,
addonName: response.addonName
}));
newGroupedStreams[addonId] = {
addonName: response.addonName,
streams: streamsWithAddon
};
}
});
return newGroupedStreams;
} catch (error) {
console.error('❌ [loadEpisodeStreams:stremio] Error fetching Stremio streams:', error);
return {};
}
})(), true);
fetchPromises.push(stremioPromise);
// Start Stremio request using the new callback method
// We don't push this promise anymore
processStremioSource('series', episodeId, true);
// Start Source 1 request if we have a TMDB ID
if (tmdbId) {
const source1Promise = processStreamSource('source1', (async () => {
const source1Promise = processExternalSource('source1', (async () => {
try {
const streams = await fetchExternalStreams(
`https://nice-month-production.up.railway.app/embedsu/${tmdbId}${episodeQuery}`,
@ -563,7 +566,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Start Source 2 request if we have a TMDB ID
if (tmdbId) {
const source2Promise = processStreamSource('source2', (async () => {
const source2Promise = processExternalSource('source2', (async () => {
try {
const streams = await fetchExternalStreams(
`https://vidsrc-api-js-phz6.onrender.com/embedsu/${tmdbId}${episodeQuery}`,
@ -588,12 +591,12 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
fetchPromises.push(source2Promise);
}
// Wait for all promises to complete - but we already showed results as they arrived
// Wait only for external promises now
const results = await Promise.allSettled(fetchPromises);
const totalTime = Date.now() - startTime;
console.log(`✅ [loadEpisodeStreams] All requests completed in ${totalTime}ms`);
console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`);
const sourceTypes = ['stremio', 'source1', 'source2'];
const sourceTypes = ['source1', 'source2']; // Removed 'stremio'
results.forEach((result, index) => {
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`);
@ -603,18 +606,18 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
});
console.log('🧮 [loadEpisodeStreams] Summary:');
console.log(' Total time:', totalTime + 'ms');
console.log(' Total time for external sources:', totalTime + 'ms');
// Log the final states
console.log('📦 [loadEpisodeStreams] Final streams count:',
// Log the final states - might not include all Stremio addons yet
console.log('📦 [loadEpisodeStreams] Current combined streams count:',
Object.keys(episodeStreams).length > 0 ?
Object.values(episodeStreams).reduce((acc, group: any) => acc + group.streams.length, 0) :
Object.values(episodeStreams).reduce((acc, group: any) => acc + group.streams.length, 0) :
0
);
// Cache the final streams state
// Cache the final streams state - Might be incomplete
setEpisodeStreams(prev => {
// Cache episode streams
// Cache episode streams - maybe incrementally?
setPreloadedEpisodeStreams(currentPreloaded => ({
...currentPreloaded,
[episodeId]: prev
@ -626,9 +629,10 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error);
setError('Failed to load episode streams');
} finally {
const totalTime = Date.now() - startTime;
console.log(`🏁 [loadEpisodeStreams] FINISHED in ${totalTime}ms`);
setLoadingEpisodeStreams(false);
// Loading is now complete when external sources finish
const endTime = Date.now() - startTime;
console.log(`🏁 [loadEpisodeStreams] External sources FINISHED in ${endTime}ms`);
setLoadingEpisodeStreams(false); // Mark loading=false, but Stremio might still be working
}
};

View file

@ -11,8 +11,7 @@ import {
RefreshControl,
Dimensions,
} from 'react-native';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Meta, stremioService } from '../services/stremioService';
@ -25,9 +24,20 @@ type CatalogScreenProps = {
navigation: StackNavigationProp<RootStackParamList, 'Catalog'>;
};
// Consistent spacing variables
const SPACING = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24,
};
// Screen dimensions and grid layout
const { width } = Dimensions.get('window');
const NUM_COLUMNS = 3;
const ITEM_WIDTH = width / NUM_COLUMNS - 20;
const ITEM_MARGIN = SPACING.sm;
const ITEM_WIDTH = (width - (SPACING.md * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS;
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const { addonId, type, id, name, genreFilter } = route.params;
@ -172,58 +182,85 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<TouchableOpacity
style={styles.item}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
activeOpacity={0.7}
>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image' }}
style={styles.poster}
contentFit="cover"
transition={200}
/>
<Text
style={styles.title}
numberOfLines={2}
>
{item.name}
</Text>
{item.releaseInfo && (
<View style={styles.itemContent}>
<Text
style={styles.releaseInfo}
style={styles.title}
numberOfLines={2}
>
{item.releaseInfo}
{item.name}
</Text>
)}
{item.releaseInfo && (
<Text style={styles.releaseInfo}>
{item.releaseInfo}
</Text>
)}
</View>
</TouchableOpacity>
);
}, [navigation]);
const renderEmptyState = () => (
<View style={styles.centered}>
<Text style={styles.emptyText}>
No content found for the selected genre
</Text>
<TouchableOpacity
style={styles.button}
onPress={handleRefresh}
>
<Text style={styles.buttonText}>Try Again</Text>
</TouchableOpacity>
</View>
);
const renderErrorState = () => (
<View style={styles.centered}>
<Text style={styles.errorText}>
{error}
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => loadItems(1)}
>
<Text style={styles.buttonText}>Retry</Text>
</TouchableOpacity>
</View>
);
const renderLoadingState = () => (
<View style={styles.centered}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
if (loading && items.length === 0) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} />
{renderLoadingState()}
</SafeAreaView>
);
}
if (error && items.length === 0) {
return (
<View style={[styles.container, styles.centered]}>
<Text style={{ color: colors.white }}>
{error}
</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => loadItems(1)}
>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} />
{renderErrorState()}
</SafeAreaView>
);
}
return (
<SafeAreaView style={[
styles.container,
{ backgroundColor: colors.darkBackground }
]}>
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} />
{items.length > 0 ? (
<FlatList
@ -249,20 +286,9 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
) : null
}
contentContainerStyle={styles.list}
columnWrapperStyle={styles.columnWrapper}
/>
) : (
<View style={styles.centered}>
<Text style={{ color: colors.white, fontSize: 16, marginBottom: 10 }}>
No content found for the selected genre
</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={handleRefresh}
>
<Text style={styles.retryText}>Try Again</Text>
</TouchableOpacity>
</View>
)}
) : renderEmptyState()}
</SafeAreaView>
);
};
@ -272,59 +298,73 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: colors.darkBackground,
},
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
headerTitle: {
fontSize: 18,
fontWeight: 'bold',
color: colors.white,
},
list: {
padding: 10,
padding: SPACING.md,
},
columnWrapper: {
justifyContent: 'space-between',
},
item: {
width: ITEM_WIDTH,
margin: 5,
marginBottom: SPACING.md,
borderRadius: 8,
overflow: 'hidden',
},
poster: {
width: '100%',
aspectRatio: 2/3,
borderRadius: 4,
borderRadius: 8,
backgroundColor: colors.transparentLight,
},
itemContent: {
padding: SPACING.xs,
},
title: {
marginTop: 5,
marginTop: SPACING.xs,
fontSize: 14,
fontWeight: '500',
fontWeight: '600',
color: colors.white,
lineHeight: 18,
},
releaseInfo: {
fontSize: 12,
marginTop: 2,
marginTop: SPACING.xs,
color: colors.lightGray,
},
footer: {
padding: 20,
padding: SPACING.lg,
alignItems: 'center',
},
retryButton: {
marginTop: 15,
padding: 10,
button: {
marginTop: SPACING.md,
paddingVertical: SPACING.md,
paddingHorizontal: SPACING.xl,
backgroundColor: colors.primary,
borderRadius: 5,
borderRadius: 8,
elevation: 2,
},
retryText: {
buttonText: {
color: colors.white,
fontWeight: '500',
fontWeight: '600',
fontSize: 16,
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.darkBackground,
padding: SPACING.xl,
},
emptyText: {
color: colors.white,
fontSize: 16,
textAlign: 'center',
marginBottom: SPACING.md,
},
errorText: {
color: colors.white,
fontSize: 16,
textAlign: 'center',
marginBottom: SPACING.md,
},
});

View file

@ -770,7 +770,12 @@ const HomeScreen = () => {
<View style={styles.catalogHeader}>
<View style={styles.titleContainer}>
<Text style={styles.catalogTitle}>{item.name}</Text>
<View style={styles.titleUnderline} />
<LinearGradient
colors={[colors.primary, colors.secondary]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.titleUnderline}
/>
</View>
<TouchableOpacity
onPress={() =>
@ -1041,9 +1046,8 @@ const styles = StyleSheet.create<any>({
position: 'absolute',
bottom: -4,
left: 0,
width: 40,
width: 60,
height: 3,
backgroundColor: colors.primary,
borderRadius: 1.5,
},
seeAllButton: {

View file

@ -1123,22 +1123,17 @@ const styles = StyleSheet.create({
ratingContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.elevation3,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
imdbLogo: {
width: 40,
height: 20,
marginRight: 6,
width: 35,
height: 18,
marginRight: 4,
},
ratingText: {
color: '#fff',
color: colors.text,
fontWeight: '700',
fontSize: 13,
fontSize: 15,
letterSpacing: 0.3,
},
descriptionContainer: {
marginBottom: 16,

View file

@ -1,357 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import {
View,
StyleSheet,
TouchableOpacity,
Text,
ActivityIndicator,
StatusBar,
Dimensions,
Platform,
SafeAreaView
} from 'react-native';
import Video from 'react-native-video';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
interface PlayerParams {
id: string;
type: string;
title?: string;
poster?: string;
stream?: string;
}
const PlayerScreen = () => {
const route = useRoute<RouteProp<Record<string, PlayerParams>, string>>();
const navigation = useNavigation();
const { id, type, title, poster, stream } = route.params;
const [isPlaying, setIsPlaying] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [controlsVisible, setControlsVisible] = useState(true);
// Use any for now to fix the type error
const videoRef = useRef<any>(null);
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Auto-hide controls after a delay
useEffect(() => {
if (controlsVisible) {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
controlsTimeoutRef.current = setTimeout(() => {
setControlsVisible(false);
}, 3000);
}
return () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
};
}, [controlsVisible]);
// Cleanup on unmount
useEffect(() => {
return () => setIsPlaying(false);
}, []);
const toggleControls = () => {
setControlsVisible(!controlsVisible);
};
const togglePlayPause = () => {
setIsPlaying(!isPlaying);
};
const seekTo = (time: number) => {
if (videoRef.current) {
videoRef.current.seek(time);
}
};
const formatTime = (seconds: number): string => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
let result = '';
if (h > 0) {
result += `${h}:${m < 10 ? '0' : ''}`;
}
result += `${m}:${s < 10 ? '0' : ''}${s}`;
return result;
};
const handleLoad = (data: any) => {
setDuration(data.duration);
setIsLoading(false);
};
const handleProgress = (data: any) => {
setCurrentTime(data.currentTime);
};
const handleEnd = () => {
setIsPlaying(false);
setCurrentTime(duration);
};
const handleError = (err: any) => {
setError(err.error?.errorString || 'Failed to load video');
setIsLoading(false);
};
const GoBackButton = () => (
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="arrow-back" size={24} color="#FFFFFF" />
</TouchableOpacity>
);
const Controls = () => (
<View style={styles.controlsContainer}>
<View style={styles.controlsHeader}>
<GoBackButton />
<Text style={styles.videoTitle}>{title || 'Video Player'}</Text>
</View>
<View style={styles.controlsCenter}>
<TouchableOpacity
style={styles.playPauseButton}
onPress={togglePlayPause}
>
<MaterialIcons
name={isPlaying ? 'pause' : 'play-arrow'}
size={48}
color="#FFFFFF"
/>
</TouchableOpacity>
</View>
<View style={styles.controlsBottom}>
<Text style={styles.timeText}>{formatTime(currentTime)}</Text>
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{ width: `${(currentTime / duration) * 100}%` }
]}
/>
<View
style={[
styles.progressKnob,
{ left: `${(currentTime / duration) * 100}%` }
]}
/>
</View>
<Text style={styles.timeText}>{formatTime(duration)}</Text>
</View>
</View>
);
if (!stream) {
return (
<SafeAreaView style={styles.container}>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" />
<GoBackButton />
<View style={styles.errorContainer}>
<MaterialIcons name="error-outline" size={64} color="#E50914" />
<Text style={styles.errorText}>No stream URL provided</Text>
<TouchableOpacity
style={styles.errorButton}
onPress={() => navigation.goBack()}
>
<Text style={styles.errorButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<View style={styles.container}>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" />
<TouchableOpacity
style={styles.videoContainer}
activeOpacity={1}
onPress={toggleControls}
>
<Video
ref={videoRef}
source={{ uri: stream }}
style={styles.video}
resizeMode="contain"
poster={poster}
paused={!isPlaying}
onLoad={handleLoad}
onProgress={handleProgress}
onEnd={handleEnd}
onError={handleError}
repeat={false}
playInBackground={false}
playWhenInactive={false}
ignoreSilentSwitch="ignore"
fullscreen={false}
progressUpdateInterval={500}
/>
{isLoading && (
<View style={styles.loaderContainer}>
<ActivityIndicator size="large" color="#E50914" />
</View>
)}
{error && (
<View style={styles.errorContainer}>
<MaterialIcons name="error-outline" size={64} color="#E50914" />
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity
style={styles.errorButton}
onPress={() => navigation.goBack()}
>
<Text style={styles.errorButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
)}
{controlsVisible && !error && <Controls />}
</TouchableOpacity>
</View>
);
};
const { width, height } = Dimensions.get('window');
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000000',
},
videoContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
video: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
},
controlsContainer: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'space-between',
},
controlsHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
paddingTop: Platform.OS === 'ios' ? 50 : 40,
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
videoTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: 'bold',
flex: 1,
},
controlsCenter: {
alignItems: 'center',
justifyContent: 'center',
},
playPauseButton: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
controlsBottom: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
paddingBottom: Platform.OS === 'ios' ? 40 : 16,
},
timeText: {
color: '#FFFFFF',
fontSize: 14,
width: 50,
},
progressBarContainer: {
flex: 1,
height: 4,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
marginHorizontal: 8,
borderRadius: 2,
position: 'relative',
},
progressBar: {
height: '100%',
backgroundColor: '#E50914',
borderRadius: 2,
},
progressKnob: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#E50914',
position: 'absolute',
top: -4,
marginLeft: -6,
},
loaderContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
errorContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.9)',
padding: 20,
},
errorText: {
color: '#FFFFFF',
fontSize: 16,
textAlign: 'center',
marginTop: 16,
marginBottom: 20,
},
errorButton: {
backgroundColor: '#E50914',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
},
errorButtonText: {
color: '#FFFFFF',
fontWeight: 'bold',
fontSize: 16,
},
});
export default PlayerScreen;

View file

@ -20,6 +20,7 @@ import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { colors } from '../styles/colors';
import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings';
import { RootStackParamList } from '../navigation/AppNavigator';
import { stremioService } from '../services/stremioService';
const { width } = Dimensions.get('window');
@ -223,6 +224,66 @@ const SettingsScreen: React.FC = () => {
)}
onPress={() => navigation.navigate('Addons')}
/>
<SettingItem
title="Check TMDB Addon"
description="Verify TMDB Embed Streams addon installation"
icon="bug-report"
isDarkMode={isDarkMode}
renderControl={() => (
<View style={[styles.actionButton, { backgroundColor: colors.primary }]}>
<Text style={styles.actionButtonText}>Check</Text>
</View>
)}
onPress={() => {
// Check if the addon is installed
const installedAddons = stremioService.getInstalledAddons();
const tmdbAddon = installedAddons.find(addon => addon.id === 'org.tmdbembedapi');
if (tmdbAddon) {
// Addon is installed, check its configuration
Alert.alert(
'TMDB Embed Streams Addon',
`Addon is installed:\n\nName: ${tmdbAddon.name}\nID: ${tmdbAddon.id}\nURL: ${tmdbAddon.url}\n\nResources: ${JSON.stringify(tmdbAddon.resources)}\n\nTypes: ${JSON.stringify(tmdbAddon.types)}`,
[
{
text: 'Reinstall',
onPress: async () => {
try {
// Remove and reinstall the addon
stremioService.removeAddon('org.tmdbembedapi');
await stremioService.installAddon('https://http-addon-production.up.railway.app/manifest.json');
Alert.alert('Success', 'Addon was reinstalled successfully');
} catch (error) {
Alert.alert('Error', `Failed to reinstall addon: ${error}`);
}
}
},
{ text: 'Close', style: 'cancel' }
]
);
} else {
// Addon is not installed, offer to install it
Alert.alert(
'TMDB Embed Streams Addon',
'Addon is not installed. Would you like to install it now?',
[
{
text: 'Install',
onPress: async () => {
try {
await stremioService.installAddon('https://http-addon-production.up.railway.app/manifest.json');
Alert.alert('Success', 'Addon was installed successfully');
} catch (error) {
Alert.alert('Error', `Failed to install addon: ${error}`);
}
}
},
{ text: 'Cancel', style: 'cancel' }
]
);
}
}}
/>
<SettingItem
title="Reset All Settings"
description="Restore default settings"

View file

@ -69,25 +69,6 @@ const StreamCard = memo(({ stream, onPress, index, torrentProgress, isLoading, s
const displayTitle = stream.name || stream.title || 'Unnamed Stream';
const displayAddonName = stream.title || '';
const entering = useMemo(() =>
FadeInDown
.delay(50 + Math.min(index, 10) * 30)
.springify()
.damping(15)
.mass(0.9)
, [index]);
const handlePress = useCallback(() => {
logger.log('StreamCard pressed:', {
isTorrent,
isDebrid,
hasProgress: !!torrentProgress,
url: stream.url,
behaviorHints: stream.behaviorHints
});
onPress();
}, [isTorrent, isDebrid, torrentProgress, stream.url, stream.behaviorHints, onPress]);
// Only disable if it's a torrent that's not debrid and not currently downloading
const isDisabled = isTorrent && !isDebrid && !torrentProgress && !stream.behaviorHints?.notWebReady;
@ -95,107 +76,109 @@ const StreamCard = memo(({ stream, onPress, index, torrentProgress, isLoading, s
const isDownloading = !!torrentProgress && isTorrent;
return (
<Animated.View entering={entering}>
<TouchableOpacity
style={[
styles.streamCard,
isDisabled && styles.streamCardDisabled,
isLoading && styles.streamCardLoading
]}
onPress={handlePress}
disabled={isDisabled || isLoading}
>
<View style={styles.streamDetails}>
<View style={styles.streamNameRow}>
<View style={styles.streamTitleContainer}>
<Text style={styles.streamName}>
{displayTitle}
<TouchableOpacity
style={[
styles.streamCard,
isDisabled && styles.streamCardDisabled,
isLoading && styles.streamCardLoading
]}
onPress={onPress}
disabled={isDisabled || isLoading}
activeOpacity={0.7}
>
<View style={styles.streamDetails}>
<View style={styles.streamNameRow}>
<View style={styles.streamTitleContainer}>
<Text style={styles.streamName}>
{displayTitle}
</Text>
{displayAddonName && displayAddonName !== displayTitle && (
<Text style={styles.streamAddonName}>
{displayAddonName}
</Text>
{displayAddonName && displayAddonName !== displayTitle && (
<Text style={styles.streamAddonName}>
{displayAddonName}
</Text>
)}
</View>
{/* Show loading indicator if stream is loading */}
{isLoading && (
<View style={styles.loadingIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.loadingText}>
{statusMessage || "Loading..."}
</Text>
</View>
)}
{/* Show download indicator for active downloads */}
{isDownloading && (
<View style={styles.downloadingIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.downloadingText}>Downloading...</Text>
</View>
)}
</View>
<View style={styles.streamMetaRow}>
{quality && quality >= "720" && (
<QualityBadge type="HD" />
)}
{isDolby && (
<QualityBadge type="VISION" />
)}
{size && (
<View style={[styles.chip, { backgroundColor: colors.darkGray }]}>
<Text style={styles.chipText}>{size}</Text>
</View>
)}
{isTorrent && !isDebrid && (
<View style={[styles.chip, { backgroundColor: colors.error }]}>
<Text style={styles.chipText}>TORRENT</Text>
</View>
)}
{isDebrid && (
<View style={[styles.chip, { backgroundColor: colors.success }]}>
<Text style={styles.chipText}>DEBRID</Text>
</View>
)}
</View>
{/* Render progress bar if there's progress */}
{torrentProgress && (
<View style={styles.progressContainer}>
<View
style={[
styles.progressBar,
{ width: `${torrentProgress.bufferProgress}%` }
]}
/>
<Text style={styles.progressText}>
{`${Math.round(torrentProgress.bufferProgress)}% • ${Math.round(torrentProgress.downloadSpeed / 1024)} KB/s • ${torrentProgress.seeds} seeds`}
{/* Show loading indicator if stream is loading */}
{isLoading && (
<View style={styles.loadingIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.loadingText}>
{statusMessage || "Loading..."}
</Text>
</View>
)}
{/* Show download indicator for active downloads */}
{isDownloading && (
<View style={styles.downloadingIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.downloadingText}>Downloading...</Text>
</View>
)}
</View>
<View style={styles.streamAction}>
<MaterialIcons
name="play-arrow"
size={24}
color={isDisabled ? colors.textMuted : colors.primary}
/>
<View style={styles.streamMetaRow}>
{quality && quality >= "720" && (
<QualityBadge type="HD" />
)}
{isDolby && (
<QualityBadge type="VISION" />
)}
{size && (
<View style={[styles.chip, { backgroundColor: colors.darkGray }]}>
<Text style={styles.chipText}>{size}</Text>
</View>
)}
{isTorrent && !isDebrid && (
<View style={[styles.chip, { backgroundColor: colors.error }]}>
<Text style={styles.chipText}>TORRENT</Text>
</View>
)}
{isDebrid && (
<View style={[styles.chip, { backgroundColor: colors.success }]}>
<Text style={styles.chipText}>DEBRID</Text>
</View>
)}
</View>
</TouchableOpacity>
</Animated.View>
{/* Render progress bar if there's progress */}
{torrentProgress && (
<View style={styles.progressContainer}>
<View
style={[
styles.progressBar,
{ width: `${torrentProgress.bufferProgress}%` }
]}
/>
<Text style={styles.progressText}>
{`${Math.round(torrentProgress.bufferProgress)}% • ${Math.round(torrentProgress.downloadSpeed / 1024)} KB/s • ${torrentProgress.seeds} seeds`}
</Text>
</View>
)}
</View>
<View style={styles.streamAction}>
<MaterialIcons
name="play-arrow"
size={24}
color={isDisabled ? colors.textMuted : colors.primary}
/>
</View>
</TouchableOpacity>
);
}, (prevProps, nextProps) => {
// Custom comparison to prevent unnecessary re-renders
return prevProps.stream.url === nextProps.stream.url &&
prevProps.index === nextProps.index &&
prevProps.torrentProgress?.bufferProgress === nextProps.torrentProgress?.bufferProgress;
// Simplified memo comparison that won't interfere with onPress
return (
prevProps.stream.url === nextProps.stream.url &&
prevProps.isLoading === nextProps.isLoading &&
prevProps.torrentProgress?.bufferProgress === nextProps.torrentProgress?.bufferProgress &&
prevProps.statusMessage === nextProps.statusMessage
);
});
const QualityTag = React.memo(({ text, color }: { text: string; color: string }) => (
@ -260,6 +243,26 @@ export const StreamsScreen = () => {
const { id, type, episodeId } = route.params;
const { settings } = useSettings();
// Log the stream details and installed addons for debugging
useEffect(() => {
// Log installed addons
const installedAddons = stremioService.getInstalledAddons();
console.log('📦 [StreamsScreen] INSTALLED ADDONS:', installedAddons.map(addon => ({
id: addon.id,
name: addon.name,
version: addon.version,
resources: addon.resources,
types: addon.types
})));
// Log request details
console.log('🎬 [StreamsScreen] REQUEST DETAILS:', {
id,
type,
episodeId: episodeId || 'none'
});
}, [id, type, episodeId]);
// Add timing logs
const [loadStartTime, setLoadStartTime] = useState(0);
const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({});
@ -278,6 +281,20 @@ export const StreamsScreen = () => {
groupedEpisodes,
} = useMetadata({ id, type });
// Log stream results when they arrive
useEffect(() => {
const streams = type === 'series' ? episodeStreams : groupedStreams;
console.log('🔍 [StreamsScreen] STREAM RESULTS:', {
totalProviders: Object.keys(streams).length,
providers: Object.keys(streams),
streamCounts: Object.entries(streams).map(([provider, data]) => ({
provider,
addonName: data.addonName,
streams: data.streams.length
}))
});
}, [episodeStreams, groupedStreams, type]);
const [selectedProvider, setSelectedProvider] = React.useState('all');
const [availableProviders, setAvailableProviders] = React.useState<Set<string>>(new Set());
@ -555,18 +572,17 @@ export const StreamsScreen = () => {
if (settings.useExternalPlayer) {
logger.log('Using external player for URL:', stream.url);
// Use VideoPlayerService to launch external player
try {
const videoPlayerService = VideoPlayerService;
await videoPlayerService.playVideo(stream.url, {
useExternalPlayer: true,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
episodeNumber: type === 'series' ? `S${currentEpisode?.season_number}E${currentEpisode?.episode_number}` : undefined,
releaseDate: metadata?.year?.toString(),
});
} catch (externalPlayerError) {
logger.error('External player error:', externalPlayerError);
// Fallback to built-in player if external player fails
const videoPlayerService = VideoPlayerService;
const launched = await videoPlayerService.playVideo(stream.url, {
useExternalPlayer: true,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
episodeNumber: type === 'series' ? `S${currentEpisode?.season_number}E${currentEpisode?.episode_number}` : undefined,
releaseDate: metadata?.year?.toString(),
});
if (!launched) {
logger.log('External player launch failed, falling back to built-in player');
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
@ -695,18 +711,17 @@ export const StreamsScreen = () => {
if (settings.useExternalPlayer) {
logger.log('[StreamsScreen] Using external player for torrent video path:', videoPath);
// Use VideoPlayerService to launch external player
try {
const videoPlayerService = VideoPlayerService;
await videoPlayerService.playVideo(`file://${videoPath}`, {
useExternalPlayer: true,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
episodeNumber: type === 'series' ? `S${currentEpisode?.season_number}E${currentEpisode?.episode_number}` : undefined,
releaseDate: metadata?.year?.toString(),
});
} catch (externalPlayerError) {
logger.error('[StreamsScreen] External player error:', externalPlayerError);
// Fallback to built-in player if external player fails
const videoPlayerService = VideoPlayerService;
const launched = await videoPlayerService.playVideo(`file://${videoPath}`, {
useExternalPlayer: true,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
episodeNumber: type === 'series' ? `S${currentEpisode?.season_number}E${currentEpisode?.episode_number}` : undefined,
releaseDate: metadata?.year?.toString(),
});
if (!launched) {
logger.log('[StreamsScreen] External player launch failed, falling back to built-in player');
navigation.navigate('Player', {
uri: `file://${videoPath}`,
title: metadata?.name || '',
@ -869,6 +884,7 @@ export const StreamsScreen = () => {
return (
<StreamCard
key={`${stream.url}-${index}`}
stream={stream}
onPress={() => handleStreamPress(stream)}
index={index}
@ -881,7 +897,7 @@ export const StreamsScreen = () => {
const renderSectionHeader = useCallback(({ section }: { section: { title: string } }) => (
<Animated.View
entering={FadeInDown.delay(150).springify()}
entering={FadeIn.duration(300)}
>
<Text style={styles.streamGroupTitle}>{section.title}</Text>
</Animated.View>
@ -1060,7 +1076,7 @@ const styles = StyleSheet.create({
top: 0,
left: 0,
right: 0,
zIndex: 1,
zIndex: 2,
},
backButton: {
flexDirection: 'row',
@ -1078,6 +1094,7 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: colors.darkBackground,
paddingTop: 20,
zIndex: 0,
},
streamsMainContentMovie: {
paddingTop: Platform.OS === 'android' ? 90 : 100,
@ -1113,6 +1130,7 @@ const styles = StyleSheet.create({
streamsContent: {
flex: 1,
width: '100%',
zIndex: 1,
},
streamsContainer: {
paddingHorizontal: 16,
@ -1142,6 +1160,7 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
width: '100%',
zIndex: 1,
},
streamCardDisabled: {
backgroundColor: colors.elevation2,

View file

@ -48,6 +48,11 @@ export interface StreamResponse {
addonName: string;
}
// Modify the callback signature to include addon ID
interface StreamCallback {
(streams: Stream[] | null, addonId: string | null, addonName: string | null, error: Error | null): void;
}
interface CatalogFilter {
title: string;
value: any;
@ -446,95 +451,112 @@ class StremioService {
}
}
async getStreams(type: string, id: string, callback?: (streams: Stream[] | null, addonName: string | null, error: Error | null) => void): Promise<StreamResponse[]> {
// Modify getStreams to use the new callback signature and rely on callbacks for results
async getStreams(type: string, id: string, callback?: StreamCallback): Promise<void> {
await this.ensureInitialized();
const addons = this.getInstalledAddons();
logger.log('Installed addons:', addons.map(a => ({ id: a.id, url: a.url })));
logger.log('📌 [getStreams] Installed addons:', addons.map(a => ({ id: a.id, name: a.name, url: a.url })));
const streamResponses: StreamResponse[] = [];
// Check specifically for TMDB Embed addon
const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi');
if (tmdbEmbed) {
logger.log('🔍 [getStreams] Found TMDB Embed Streams addon:', {
id: tmdbEmbed.id,
name: tmdbEmbed.name,
url: tmdbEmbed.url,
resources: tmdbEmbed.resources,
types: tmdbEmbed.types
});
} else {
logger.log('⚠️ [getStreams] TMDB Embed Streams addon not found among installed addons');
}
// Find addons that provide streams and sort them by installation order
const streamAddons = addons
.filter(addon => {
if (!addon.resources) {
logger.log(`Addon ${addon.id} has no resources`);
logger.log(`⚠️ [getStreams] Addon ${addon.id} has no resources`);
return false;
}
// Log the detailed resources structure for debugging
// logger.log(`📋 [getStreams] Checking addon ${addon.id} resources:`, JSON.stringify(addon.resources)); // Verbose, uncomment if needed
// Check if the addon has a stream resource for this type
const hasStreamResource = addon.resources.some(
resource => resource.name === 'stream' && resource.types.includes(type)
resource => {
const result = resource.name === 'stream' && resource.types && resource.types.includes(type);
// logger.log(`🔎 [getStreams] Addon ${addon.id} resource ${resource.name}: supports ${type}? ${result}`); // Verbose
return result;
}
);
if (!hasStreamResource) {
logger.log(`Addon ${addon.id} does not support streaming ${type}`);
// logger.log(`❌ [getStreams] Addon ${addon.id} does not support streaming ${type}`); // Verbose
} else {
// logger.log(`✅ [getStreams] Addon ${addon.id} supports streaming ${type}`); // Verbose
}
return hasStreamResource;
});
logger.log('Stream capable addons:', streamAddons.map(a => a.id));
logger.log('📊 [getStreams] Stream capable addons:', streamAddons.map(a => a.id));
if (streamAddons.length === 0) {
logger.warn('No addons found that can provide streams');
return [];
logger.warn('⚠️ [getStreams] No addons found that can provide streams');
// Optionally call callback with an empty result or specific status?
// For now, just return if no addons.
return;
}
// Create a map to store promises for each addon
const addonPromises = new Map<string, Promise<void>>();
// Process each addon
for (const addon of streamAddons) {
const promise = (async () => {
// Process each addon and call the callback individually
streamAddons.forEach(addon => {
// Use an IIFE to create scope for async operation inside forEach
(async () => {
try {
if (!addon.url) {
logger.warn(`Addon ${addon.id} has no URL`);
logger.warn(`⚠️ [getStreams] Addon ${addon.id} has no URL`);
if (callback) callback(null, addon.id, addon.name, new Error('Addon has no URL'));
return;
}
const baseUrl = this.getAddonBaseURL(addon.url);
const url = `${baseUrl}/stream/${type}/${id}.json`;
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
const response = await this.retryRequest(async () => {
return await axios.get(url);
});
let processedStreams: Stream[] = [];
if (response.data && response.data.streams) {
const processedStreams = this.processStreams(response.data.streams, addon);
if (processedStreams.length > 0) {
streamResponses.push({
addon: addon.id,
addonName: addon.name,
streams: processedStreams
});
}
logger.log(`✅ [getStreams] Got ${response.data.streams.length} streams from ${addon.name} (${addon.id})`);
processedStreams = this.processStreams(response.data.streams, addon);
logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id})`);
} else {
logger.log(`⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id})`);
}
if (callback) {
callback(response.data?.streams || null, addon.name, null);
// Call callback with processed streams (can be empty array)
callback(processedStreams, addon.id, addon.name, null);
}
} catch (error) {
logger.error(`Failed to get streams from ${addon.name}:`, error);
logger.error(`❌ [getStreams] Failed to get streams from ${addon.name} (${addon.id}):`, error);
if (callback) {
callback(null, addon.name, error as Error);
// Call callback with error
callback(null, addon.id, addon.name, error as Error);
}
}
})();
addonPromises.set(addon.id, promise);
}
// Wait for all promises to complete
await Promise.all(addonPromises.values());
// Sort stream responses to maintain installed addon order
streamResponses.sort((a, b) => {
const indexA = streamAddons.findIndex(addon => addon.id === a.addon);
const indexB = streamAddons.findIndex(addon => addon.id === b.addon);
return indexA - indexB;
})(); // Immediately invoke the async function
});
return streamResponses;
// No longer waiting here, callbacks handle results asynchronously
// Removed: await Promise.all(addonPromises.values());
// No longer returning aggregated results
// Removed: return streamResponses;
}
private async fetchStreamsFromAddon(addon: Manifest, type: string, id: string): Promise<StreamResponse | null> {

View file

@ -1,7 +1,6 @@
import { NativeModules } from 'react-native';
import { useSettings } from '../hooks/useSettings';
const { VideoPlayerModule } = NativeModules;
import { Platform } from 'react-native';
import * as IntentLauncher from 'expo-intent-launcher';
import { logger } from '../utils/logger';
interface VideoPlayerOptions {
useExternalPlayer: boolean;
@ -16,11 +15,35 @@ interface VideoPlayerOptions {
}
export const VideoPlayerService = {
playVideo: (url: string, options?: Partial<VideoPlayerOptions>): Promise<boolean> => {
if (options) {
return VideoPlayerModule.playVideo(url, options);
} else {
return VideoPlayerModule.playVideo(url);
playVideo: async (url: string, options?: Partial<VideoPlayerOptions>): Promise<boolean> => {
if (!options?.useExternalPlayer || Platform.OS !== 'android') {
return false;
}
try {
// Create a title that includes all relevant metadata
const fullTitle = [
options.title,
options.episodeNumber,
options.episodeTitle,
options.releaseDate
].filter(Boolean).join(' - ');
// Launch the intent to play the video
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: url,
flags: 1, // FLAG_ACTIVITY_NEW_TASK
type: 'video/*',
extra: {
'android.intent.extra.TITLE': fullTitle,
'position': 0, // Start from beginning
},
});
return true;
} catch (error) {
logger.error('Failed to launch external player:', error);
return false;
}
}
};

View file

@ -1,7 +1,7 @@
// Color palette for the app following Material Design 3
export const colors = {
// Primary colors
primary: '#00A4A4', // Brighter, more vibrant teal
primary: '#2d9cdb', // Brighter, more vibrant teal
secondary: '#FF6B6B', // Coral color that complements teal
// Background colors - Deep dark theme