Refactor App structure to include GenreProvider; update dependencies in package.json and package-lock.json; enhance UI components with animations and improved styles; implement TMDB API key management; optimize metadata loading and caching; add new settings for catalog management.

This commit is contained in:
Nayif Noushad 2025-04-17 23:36:01 +05:30
parent e5bcc23ba6
commit 1948abc922
39 changed files with 6234 additions and 1822 deletions

27
App.tsx
View file

@ -20,6 +20,7 @@ import AppNavigator, {
} from './src/navigation/AppNavigator'; } from './src/navigation/AppNavigator';
import 'react-native-reanimated'; import 'react-native-reanimated';
import { CatalogProvider } from './src/contexts/CatalogContext'; import { CatalogProvider } from './src/contexts/CatalogContext';
import { GenreProvider } from './src/contexts/GenreContext';
function App(): React.JSX.Element { function App(): React.JSX.Element {
// Always use dark mode // Always use dark mode
@ -27,18 +28,20 @@ function App(): React.JSX.Element {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<CatalogProvider> <GenreProvider>
<PaperProvider theme={CustomDarkTheme}> <CatalogProvider>
<NavigationContainer theme={CustomNavigationDarkTheme}> <PaperProvider theme={CustomDarkTheme}>
<View style={[styles.container, { backgroundColor: '#000000' }]}> <NavigationContainer theme={CustomNavigationDarkTheme}>
<StatusBar <View style={[styles.container, { backgroundColor: '#000000' }]}>
style="light" <StatusBar
/> style="light"
<AppNavigator /> />
</View> <AppNavigator />
</NavigationContainer> </View>
</PaperProvider> </NavigationContainer>
</CatalogProvider> </PaperProvider>
</CatalogProvider>
</GenreProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88">
<circle cx="44" cy="44" r="41.6" fill="#001B36" stroke="#FFCC00" stroke-width="4.6"/>
<path d="M28 32h6v3.4c2.2-2.6 4.8-4 7.8-4 1.6 0 3 .3 4.2 1 1.2.7 2.1 1.7 2.9 3 1.1-1.3 2.3-2.3 3.6-3 1.3-.7 2.6-1 4.1-1 1.8 0 3.4.4 4.7 1.1 1.3.7 2.2 1.8 2.9 3.3.5 1.1.7 2.8.7 5.2v16h-6.6V42c0-2.5-.2-4.1-.7-4.8-.6-.9-1.6-1.4-2.8-1.4-.9 0-1.8.3-2.6.8-.8.6-1.4 1.4-1.8 2.5-.4 1.1-.5 2.8-.5 5.2v12h-6.6V42c0-2.4-.1-4-.4-4.7-.2-.7-.6-1.2-1.1-1.6-.5-.3-1.2-.5-2-.5-1 0-1.9.3-2.8.8-.8.5-1.4 1.3-1.8 2.4-.4 1-.5 2.8-.5 5.2v12.2h-6.6V32z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 644 B

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3396">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer1" fill="#f93208">
<path id="path3412" d="m20.154 40.829c-28.149 27.622-13.657 61.011-5.734 71.931 35.254 41.954 92.792 25.339 111.89-5.9071 4.7608-8.2027 22.554-53.467-23.976-78.009z"/>
<path id="path3471" d="m39.613 39.265 4.7778-8.8607 28.406-5.0384 11.119 9.2082z"/>
</g>
<g id="layer2">
<path id="path3437" d="m39.436 8.5696 8.9682-5.2826 6.7569 15.479c3.7925-6.3226 13.79-16.316 24.939-4.6684-4.7281 1.2636-7.5161 3.8553-7.7397 8.4768 15.145-4.1697 31.343 3.2127 33.539 9.0911-10.951-4.314-27.695 10.377-41.771 2.334 0.009 15.045-12.617 16.636-19.902 17.076 2.077-4.996 5.591-9.994 1.474-14.987-7.618 8.171-13.874 10.668-33.17 4.668 4.876-1.679 14.843-11.39 24.448-11.425-6.775-2.467-12.29-2.087-17.814-1.475 2.917-3.961 12.149-15.197 28.625-8.476z" fill="#02902e"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="500px" height="500px" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.2 (67145) - http://www.bohemiancoding.com/sketch -->
<title>letterboxd-decal-dots-neg-rgb</title>
<desc>Created with Sketch.</desc>
<defs>
<rect id="path-1" x="0" y="0" width="129.847328" height="141.389313"></rect>
<rect id="path-3" x="0" y="0" width="129.847328" height="141.389313"></rect>
</defs>
<g id="letterboxd-decal-dots-neg-rgb" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<circle id="Circle" fill="#FFFFFF" cx="250" cy="250" r="250"></circle>
<g id="dots-pos" transform="translate(61.000000, 180.000000)">
<g id="Dots">
<ellipse id="Green" fill="#00E054" cx="189" cy="69.9732824" rx="70.0786517" ry="69.9732824"></ellipse>
<g id="Blue" transform="translate(248.152672, 0.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Mask"></g>
<ellipse fill="#40BCF4" mask="url(#mask-2)" cx="59.7686766" cy="69.9732824" rx="70.0786517" ry="69.9732824"></ellipse>
</g>
<g id="Orange">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<g id="Mask"></g>
<ellipse fill="#FF8000" mask="url(#mask-4)" cx="70.0786517" cy="69.9732824" rx="70.0786517" ry="69.9732824"></ellipse>
</g>
<path d="M129.539326,107.022244 C122.810493,96.2781677 118.921348,83.5792213 118.921348,69.9732824 C118.921348,56.3673435 122.810493,43.6683972 129.539326,32.9243209 C136.268159,43.6683972 140.157303,56.3673435 140.157303,69.9732824 C140.157303,83.5792213 136.268159,96.2781677 129.539326,107.022244 Z" id="Overlap" fill="#556677"></path>
<path d="M248.460674,32.9243209 C255.189507,43.6683972 259.078652,56.3673435 259.078652,69.9732824 C259.078652,83.5792213 255.189507,96.2781677 248.460674,107.022244 C241.731841,96.2781677 237.842697,83.5792213 237.842697,69.9732824 C237.842697,56.3673435 241.731841,43.6683972 248.460674,32.9243209 Z" id="Overlap" fill="#556677"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1 @@
<svg enable-background="new 0 0 144.8 144.8" viewBox="0 0 144.8 144.8" xmlns="http://www.w3.org/2000/svg"><path d="m29.5 111.8c10.6 11.6 25.9 18.8 42.9 18.8 8.7 0 16.9-1.9 24.3-5.3l-40.4-40.3z" fill="#ed2224"/><path d="m56.1 60.6-30.6 30.5-4.1-4.1 32.2-32.2 37.6-37.6c-5.9-2-12.2-3.1-18.8-3.1-32.2 0-58.3 26.1-58.3 58.3 0 13.1 4.3 25.2 11.7 35l30.5-30.5 2.1 2 43.7 43.7c.9-.5 1.7-1 2.5-1.6l-48.3-48.3-29.3 29.3-4.1-4.1 33.4-33.4 2.1 2 51 50.9c.8-.6 1.5-1.3 2.2-1.9l-55-55z" fill="#ed2224"/><path d="m115.7 111.4c9.3-10.3 15-24 15-39 0-23.4-13.8-43.5-33.6-52.8l-36.7 36.6zm-41.2-44.6-4.1-4.1 28.9-28.9 4.1 4.1zm27.4-39.7-33.3 33.3-4.1-4.1 33.3-33.3z" fill="#ed1c24"/><path d="m72.4 144.8c-39.9 0-72.4-32.5-72.4-72.4s32.5-72.4 72.4-72.4 72.4 32.5 72.4 72.4-32.5 72.4-72.4 72.4zm0-137.5c-35.9 0-65.1 29.2-65.1 65.1s29.2 65.1 65.1 65.1 65.1-29.2 65.1-65.1-29.2-65.1-65.1-65.1z" fill="#ed2224"/></svg>

After

Width:  |  Height:  |  Size: 896 B

52
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~4.0.1", "@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.2",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1", "@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6", "@react-native-community/slider": "^4.5.6",
@ -45,7 +46,7 @@
"react-native-reanimated": "~3.16.1", "react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
"react-native-svg": "^15.8.0", "react-native-svg": "^15.11.2",
"react-native-video": "^6.12.0", "react-native-video": "^6.12.0",
"react-native-web": "~0.19.13", "react-native-web": "~0.19.13",
"subsrt": "^1.1.1" "subsrt": "^1.1.1"
@ -2881,6 +2882,45 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/@gorhom/bottom-sheet": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.1.2.tgz",
"integrity": "sha512-5np8oL2krqAsVKLRE4YmtkZkyZeFiitoki72bEpVhZb8SRTNuAEeSbP3noq5srKpcRsboCr7uI+xmMyrWUd9kw==",
"license": "MIT",
"dependencies": {
"@gorhom/portal": "1.0.14",
"invariant": "^2.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-native": "*",
"react": "*",
"react-native": "*",
"react-native-gesture-handler": ">=2.16.1",
"react-native-reanimated": ">=3.16.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-native": {
"optional": true
}
}
},
"node_modules/@gorhom/portal": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz",
"integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==",
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@ide/backoff": { "node_modules/@ide/backoff": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
@ -4466,7 +4506,7 @@
"version": "0.72.8", "version": "0.72.8",
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz", "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz",
"integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==", "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@react-native/virtualized-lists": "^0.72.4", "@react-native/virtualized-lists": "^0.72.4",
@ -4487,7 +4527,7 @@
"version": "0.72.8", "version": "0.72.8",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz",
"integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"invariant": "^2.2.4", "invariant": "^2.2.4",
@ -10731,9 +10771,9 @@
} }
}, },
"node_modules/react-native-svg": { "node_modules/react-native-svg": {
"version": "15.8.0", "version": "15.11.2",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.8.0.tgz", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.11.2.tgz",
"integrity": "sha512-KHJzKpgOjwj1qeZzsBjxNdoIgv2zNCO9fVcoq2TEhTRsVV5DGTZ9JzUZwybd7q4giT/H3RdtqC3u44dWdO0Ffw==", "integrity": "sha512-+YfF72IbWQUKzCIydlijV1fLuBsQNGMT6Da2kFlo1sh+LE3BIm/2Q7AR1zAAR6L0BFLi1WaQPLfFUC9bNZpOmw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"css-select": "^5.1.0", "css-select": "^5.1.0",

View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~4.0.1", "@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.2",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1", "@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6", "@react-native-community/slider": "^4.5.6",
@ -46,7 +47,7 @@
"react-native-reanimated": "~3.16.1", "react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
"react-native-svg": "^15.8.0", "react-native-svg": "^15.11.2",
"react-native-video": "^6.12.0", "react-native-video": "^6.12.0",
"react-native-web": "~0.19.13", "react-native-web": "~0.19.13",
"subsrt": "^1.1.1" "subsrt": "^1.1.1"

83
plan.md Normal file
View file

@ -0,0 +1,83 @@
# HomeScreen Analysis and Improvement Plan
This document outlines the analysis of the `HomeScreen.tsx` component and suggests potential improvements.
## Analysis
**Strengths:**
1. **Component Structure:** Good use of breaking down UI into smaller, reusable components (`ContentItem`, `DropUpMenu`, `SkeletonCatalog`, `SkeletonFeatured`, `ThisWeekSection`, `ContinueWatchingSection`).
2. **Performance Optimizations:**
* Uses `FlatList` for horizontal catalogs with optimizations (`initialNumToRender`, `maxToRenderPerBatch`, `windowSize`, `removeClippedSubviews`, `getItemLayout`).
* Uses `expo-image` for optimized image loading, caching, and prefetching (`ExpoImage.prefetch`). Includes loading/error states per image.
* Leverages `useCallback` to memoize event handlers and functions.
* Uses `react-native-reanimated` and `react-native-gesture-handler` for performant animations/gestures.
* Parallel initial data loading (`Promise.all`).
* Uses `AbortController` to cancel stale fetch requests.
3. **User Experience:**
* Skeleton loaders (`SkeletonFeatured`, `SkeletonCatalog`).
* Pull-to-refresh (`RefreshControl`).
* Interactive `DropUpMenu` with smooth animations and gesture dismissal.
* Haptics feedback (`Haptics.impactAsync`).
* Reactive library status updates (`catalogService.subscribeToLibraryUpdates`).
* Screen focus events refresh "Continue Watching".
* Graceful handling of empty catalog states.
4. **Code Quality:**
* Uses TypeScript with interfaces.
* Separation of concerns via services (`catalogService`, `tmdbService`, `storageService`, `logger`).
* Basic error handling and logging.
## Areas for Potential Improvement & Suggestions
1. **Component Complexity (`HomeScreen`):**
* The main component is large and manages significant state/effects.
* **Suggestion:** Extract data fetching and related state into custom hooks (e.g., `useFeaturedContent`, `useHomeCatalogs`) to simplify `HomeScreen`.
* *Example Hook Structure:*
```typescript
// hooks/useHomeCatalogs.ts
function useHomeCatalogs() {
const [catalogs, setCatalogs] = useState<CatalogContent[]>([]);
const [loading, setLoading] = useState(true);
// ... fetch logic from loadCatalogs ...
return { catalogs, loading, reloadCatalogs: loadCatalogs };
}
```
2. **Outer `FlatList` for Catalogs:**
* Using `FlatList` with `scrollEnabled={false}` disables its virtualization benefits.
* **Suggestion:** If the number of catalogs can grow large, this might impact performance. For a small, fixed number of catalogs, rendering directly in the `ScrollView` using `.map()` might be simpler. If virtualization is needed for many catalogs, revisit the structure (potentially enabling scroll on the outer `FlatList`, which can be complex with nested scrolling).
3. **Hardcoded Values:**
* `GENRE_MAP`: TMDB genres can change.
* **Suggestion:** Fetch genre lists from the TMDB API (`/genre/movie/list`, `/genre/tv/list`) periodically and cache them (e.g., in context or async storage).
* `SAMPLE_CATEGORIES`: Ensure replacement if dynamic categories are needed.
4. **Image Preloading Strategy:**
* `preloadImages` currently tries to preload posters, banners, and logos for *all* fetched featured items.
* **Suggestion:** If the trending list is long, this is bandwidth-intensive. Consider preloading only for the *initially selected* `featuredContent` or the first few items in the `allFeaturedContent` array to optimize resource usage.
5. **Error Handling & Retries:**
* The `maxRetries` variable is defined but not used.
* **Suggestion:** Implement retry logic (e.g., with exponential backoff) in `catch` blocks for `loadCatalogs` and `loadFeaturedContent`, or remove the unused variable. Enhance user feedback on errors beyond console logs (e.g., Toast messages).
6. **Type Safety (`StyleSheet.create<any>`):**
* Styles use `StyleSheet.create<any>`.
* **Suggestion:** Define a specific interface for styles using `ViewStyle`, `TextStyle`, `ImageStyle` from `react-native` for better type safety and autocompletion.
```typescript
import { ViewStyle, TextStyle, ImageStyle } from 'react-native';
interface Styles {
container: ViewStyle;
// ... other styles
}
const styles = StyleSheet.create<Styles>({ ... });
```
7. **Featured Content Interaction:**
* The "Info" button fetches `stremioId` asynchronously.
* **Suggestion:** Add a loading indicator (e.g., disable button + `ActivityIndicator`) during the `getStremioId` call for better UX feedback.
8. **Featured Content Rotation:**
* Auto-rotation is fixed at 15 seconds.
* **Suggestion (Minor UX):** Consider adding visual indicators (e.g., dots) for featured items, allow manual swiping, and pause the auto-rotation timer on user interaction.

View file

@ -53,10 +53,10 @@ export const CastSection: React.FC<CastSectionProps> = ({
onPress={() => onSelectCastMember(member)} onPress={() => onSelectCastMember(member)}
> >
<View style={styles.castImageContainer}> <View style={styles.castImageContainer}>
{member.profile_path && tmdbService.getImageUrl(member.profile_path, 'w185') ? ( {member.profile_path ? (
<Image <Image
source={{ source={{
uri: tmdbService.getImageUrl(member.profile_path, 'w185')! uri: `https://image.tmdb.org/t/p/w185${member.profile_path}`
}} }}
style={styles.castImage} style={styles.castImage}
contentFit="cover" contentFit="cover"

View file

@ -0,0 +1,314 @@
import React, { useEffect, useState, useRef } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, Image, Animated } from 'react-native';
import { colors } from '../../styles/colors';
import { useMDBListRatings } from '../../hooks/useMDBListRatings';
import { logger } from '../../utils/logger';
import { MaterialIcons } from '@expo/vector-icons';
import { FontAwesome } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { isMDBListEnabled, RATING_PROVIDERS_STORAGE_KEY } from '../../screens/MDBListSettingsScreen';
// Import SVG icons
import LetterboxdIcon from '../../../assets/rating-icons/letterboxd.svg';
import MetacriticIcon from '../../../assets/rating-icons/Metacritic.png';
import RottenTomatoesIcon from '../../../assets/rating-icons/RottenTomatoes.svg';
import TMDBIcon from '../../../assets/rating-icons/tmdb.svg';
import TraktIcon from '../../../assets/rating-icons/trakt.svg';
import AudienceScoreIcon from '../../../assets/rating-icons/audienscore.png';
export const RATING_PROVIDERS = {
imdb: {
name: 'IMDb',
color: '#F5C518',
},
tmdb: {
name: 'TMDB',
color: '#01B4E4',
},
trakt: {
name: 'Trakt',
color: '#ED1C24',
},
letterboxd: {
name: 'Letterboxd',
color: '#00E054',
},
tomatoes: {
name: 'Rotten Tomatoes',
color: '#FA320A',
},
audience: {
name: 'Audience Score',
color: '#FA320A',
},
metacritic: {
name: 'Metacritic',
color: '#FFCC33',
}
} as const;
interface RatingsSectionProps {
imdbId: string;
type: 'movie' | 'show';
}
export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type }) => {
const { ratings, loading, error } = useMDBListRatings(imdbId, type);
const [enabledProviders, setEnabledProviders] = useState<Record<string, boolean>>({});
const [isMDBEnabled, setIsMDBEnabled] = useState(true);
const fadeAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
loadProviderSettings();
checkMDBListEnabled();
}, []);
const checkMDBListEnabled = async () => {
try {
const enabled = await isMDBListEnabled();
setIsMDBEnabled(enabled);
logger.log('[RatingsSection] MDBList enabled:', enabled);
} catch (error) {
logger.error('[RatingsSection] Failed to check if MDBList is enabled:', error);
setIsMDBEnabled(true); // Default to enabled
}
};
const loadProviderSettings = async () => {
try {
const savedSettings = await AsyncStorage.getItem(RATING_PROVIDERS_STORAGE_KEY);
if (savedSettings) {
setEnabledProviders(JSON.parse(savedSettings));
} else {
// Default all providers to enabled
const defaultSettings = Object.keys(RATING_PROVIDERS).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as Record<string, boolean>);
setEnabledProviders(defaultSettings);
}
} catch (error) {
logger.error('[RatingsSection] Failed to load provider settings:', error);
}
};
useEffect(() => {
logger.log(`[RatingsSection] Mounted for ${type}:`, imdbId);
return () => {
logger.log(`[RatingsSection] Unmounted for ${type}:`, imdbId);
};
}, [imdbId, type]);
useEffect(() => {
if (error) {
logger.error('[RatingsSection] Error state:', error);
}
}, [error]);
useEffect(() => {
if (ratings) {
logger.log('[RatingsSection] Received ratings:', ratings);
}
}, [ratings]);
useEffect(() => {
if (ratings && Object.keys(ratings).length > 0) {
// Start fade-in animation when ratings are loaded
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
}
}, [ratings, fadeAnim]);
// If MDBList is disabled, don't show anything
if (!isMDBEnabled) {
logger.log('[RatingsSection] MDBList is disabled, not showing ratings');
return null;
}
if (loading) {
logger.log('[RatingsSection] Loading state');
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
);
}
if (error || !ratings || Object.keys(ratings).length === 0) {
logger.log('[RatingsSection] No ratings to display');
return null;
}
logger.log('[RatingsSection] Rendering ratings:', Object.keys(ratings).length);
// Define the order and icons/colors for the ratings
const ratingConfig = {
imdb: {
icon: require('../../../assets/rating-icons/imdb.png'),
isImage: true,
color: '#F5C518',
prefix: '',
suffix: '',
transform: (value: number) => value.toFixed(1)
},
tmdb: {
icon: TMDBIcon,
isImage: false,
color: '#01B4E4',
prefix: '',
suffix: '',
transform: (value: number) => value.toFixed(0)
},
trakt: {
icon: TraktIcon,
isImage: false,
color: '#ED1C24',
prefix: '',
suffix: '',
transform: (value: number) => value.toFixed(0)
},
letterboxd: {
icon: LetterboxdIcon,
isImage: false,
color: '#00E054',
prefix: '',
suffix: '',
transform: (value: number) => value.toFixed(1)
},
tomatoes: {
icon: RottenTomatoesIcon,
isImage: false,
color: '#FA320A',
prefix: '',
suffix: '%',
transform: (value: number) => Math.round(value).toString()
},
audience: {
icon: AudienceScoreIcon,
isImage: true,
color: '#FA320A',
prefix: '',
suffix: '%',
transform: (value: number) => Math.round(value).toString()
},
metacritic: {
icon: MetacriticIcon,
isImage: true,
color: '#FFCC33',
prefix: '',
suffix: '',
transform: (value: number) => Math.round(value).toString()
}
};
// Priority: IMDB, TMDB, Tomatoes, Metacritic
const priorityOrder = ['imdb', 'tmdb', 'tomatoes', 'metacritic', 'trakt', 'letterboxd', 'audience'];
const displayRatings = priorityOrder
.filter(source =>
source in ratings &&
ratings[source as keyof typeof ratings] !== undefined &&
(enabledProviders[source] ?? true) // Show by default if setting not found
)
.map(source => [source, ratings[source as keyof typeof ratings]!]);
return (
<Animated.View
style={[
styles.container,
{
opacity: fadeAnim,
transform: [{
translateY: fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [10, 0],
}),
}],
},
]}
>
{displayRatings.map(([source, value]) => {
const config = ratingConfig[source as keyof typeof ratingConfig];
const numericValue = typeof value === 'string' ? parseFloat(value) : value;
const displayValue = config.transform(numericValue);
// Get a short display name for the rating source
const getSourceLabel = (src: string): string => {
switch(src) {
case 'imdb': return 'IMDb';
case 'tmdb': return 'TMDB';
case 'tomatoes': return 'RT';
case 'audience': return 'Aud';
case 'metacritic': return 'Meta';
case 'letterboxd': return 'LBXD';
case 'trakt': return 'Trakt';
default: return src;
}
};
return (
<View key={source} style={styles.ratingItem}>
{config.isImage ? (
<Image
source={config.icon}
style={styles.ratingIcon}
resizeMode="contain"
/>
) : (
<config.icon
width={16}
height={16}
style={styles.ratingIcon}
/>
)}
<Text style={[styles.ratingValue, {color: config.color}]}>
{displayValue}{config.suffix}
</Text>
</View>
);
})}
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
marginBottom: 16,
paddingHorizontal: 12,
gap: 4,
},
loadingContainer: {
alignItems: 'center',
justifyContent: 'center',
height: 40,
marginVertical: 16,
},
ratingItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.4)',
paddingVertical: 3,
paddingHorizontal: 4,
borderRadius: 4,
},
ratingIcon: {
width: 16,
height: 16,
marginRight: 3,
alignSelf: 'center',
},
ratingValue: {
fontSize: 13,
fontWeight: 'bold',
},
ratingLabel: {
fontSize: 11,
opacity: 0.9,
},
});

View file

@ -7,6 +7,7 @@ import { Episode } from '../../types/metadata';
import { tmdbService } from '../../services/tmdbService'; import { tmdbService } from '../../services/tmdbService';
import { storageService } from '../../services/storageService'; import { storageService } from '../../services/storageService';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import Animated, { FadeIn } from 'react-native-reanimated';
interface SeriesContentProps { interface SeriesContentProps {
episodes: Episode[]; episodes: Episode[];
@ -246,27 +247,49 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{renderSeasonSelector()} <Animated.View
entering={FadeIn.duration(500).delay(100)}
<Text style={styles.sectionTitle}>
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
</Text>
<ScrollView
style={styles.episodeList}
contentContainerStyle={[
styles.episodeListContent,
isTablet && styles.episodeListContentTablet
]}
> >
{isTablet ? ( {renderSeasonSelector()}
<View style={styles.episodeGrid}> </Animated.View>
{episodes.map(episode => renderEpisodeCard(episode))}
</View> <Animated.View
) : ( entering={FadeIn.duration(500).delay(200)}
episodes.map(episode => renderEpisodeCard(episode)) >
)} <Text style={styles.sectionTitle}>
</ScrollView> {episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
</Text>
<ScrollView
style={styles.episodeList}
contentContainerStyle={[
styles.episodeListContent,
isTablet && styles.episodeListContentTablet
]}
>
{isTablet ? (
<View style={styles.episodeGrid}>
{episodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
>
{renderEpisodeCard(episode)}
</Animated.View>
))}
</View>
) : (
episodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
>
{renderEpisodeCard(episode)}
</Animated.View>
))
)}
</ScrollView>
</Animated.View>
</View> </View>
); );
}; };

View file

@ -0,0 +1,80 @@
import React, { createContext, useState, useEffect, useContext, ReactNode, useMemo } from 'react';
import { tmdbService } from '../services/tmdbService';
import { logger } from '../utils/logger';
// Define the shape of the genre map and context value
export type GenreMap = { [key: number]: string };
interface GenreContextType {
genreMap: GenreMap;
loadingGenres: boolean;
}
// Create the context with a default value
const GenreContext = createContext<GenreContextType>({
genreMap: {},
loadingGenres: true,
});
// Custom hook to use the GenreContext
export const useGenres = () => useContext(GenreContext);
// Define props for the provider
interface GenreProviderProps {
children: ReactNode;
}
// Create the provider component
export const GenreProvider: React.FC<GenreProviderProps> = ({ children }) => {
const [genreMap, setGenreMap] = useState<GenreMap>({});
const [loadingGenres, setLoadingGenres] = useState(true);
useEffect(() => {
const fetchAndSetGenres = async () => {
setLoadingGenres(true);
try {
// Fetch both movie and TV genres in parallel
const [movieGenres, tvGenres] = await Promise.all([
tmdbService.getMovieGenres(),
tmdbService.getTvGenres(),
]);
// Combine genres into a single map, TV genres overwrite movie genres in case of ID collision (unlikely but possible)
const combinedMap: GenreMap = {};
movieGenres.forEach(genre => {
combinedMap[genre.id] = genre.name;
});
tvGenres.forEach(genre => {
combinedMap[genre.id] = genre.name;
});
setGenreMap(combinedMap);
logger.info('Successfully fetched and combined genres.');
} catch (error) {
logger.error('Failed to fetch genres for GenreProvider:', error);
// Keep the genreMap empty or potentially set some default?
setGenreMap({});
} finally {
setLoadingGenres(false);
}
};
fetchAndSetGenres();
// Add logic here for periodic refetching or caching if needed
// For now, it fetches only once on mount
}, []); // Empty dependency array ensures this runs only once on mount
// Memoize the context value to prevent unnecessary re-renders
const value = useMemo(() => ({
genreMap,
loadingGenres,
}), [genreMap, loadingGenres]);
return (
<GenreContext.Provider value={value}>
{children}
</GenreContext.Provider>
);
};

View file

@ -0,0 +1,227 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { StreamingContent, catalogService } from '../services/catalogService';
import { tmdbService } from '../services/tmdbService';
import { logger } from '../utils/logger';
import * as Haptics from 'expo-haptics';
import { useGenres } from '../contexts/GenreContext';
import { useSettings, settingsEmitter } from './useSettings';
export function useFeaturedContent() {
const [featuredContent, setFeaturedContent] = useState<StreamingContent | null>(null);
const [allFeaturedContent, setAllFeaturedContent] = useState<StreamingContent[]>([]);
const [isSaved, setIsSaved] = useState(false);
const [loading, setLoading] = useState(true);
const currentIndexRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
const { settings } = useSettings();
const [contentSource, setContentSource] = useState<'tmdb' | 'catalogs'>(settings.featuredContentSource);
const [selectedCatalogs, setSelectedCatalogs] = useState<string[]>(settings.selectedHeroCatalogs || []);
const { genreMap, loadingGenres } = useGenres();
// Update local state when settings change
useEffect(() => {
setContentSource(settings.featuredContentSource);
setSelectedCatalogs(settings.selectedHeroCatalogs || []);
}, [settings]);
const cleanup = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const loadFeaturedContent = useCallback(async () => {
setLoading(true);
cleanup();
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
try {
let formattedContent: StreamingContent[] = [];
if (contentSource === 'tmdb') {
// Load from TMDB trending
const trendingResults = await tmdbService.getTrending('movie', 'day');
if (signal.aborted) return;
if (trendingResults.length > 0) {
// First convert items to StreamingContent objects
const preFormattedContent = trendingResults
.filter(item => item.title || item.name)
.map(item => {
const yearString = (item.release_date || item.first_air_date)?.substring(0, 4);
return {
id: `tmdb:${item.id}`,
type: 'movie',
name: item.title || item.name || 'Unknown Title',
poster: tmdbService.getImageUrl(item.poster_path) || '',
banner: tmdbService.getImageUrl(item.backdrop_path) || '',
logo: undefined, // Will be populated below
description: item.overview || '',
year: yearString ? parseInt(yearString, 10) : undefined,
genres: item.genre_ids.map(id =>
loadingGenres ? '...' : (genreMap[id] || `ID:${id}`)
),
inLibrary: false,
};
});
// Then fetch logos for each item
formattedContent = await Promise.all(
preFormattedContent.map(async (item) => {
try {
if (item.id.startsWith('tmdb:')) {
const tmdbId = item.id.split(':')[1];
const logoUrl = await tmdbService.getContentLogo('movie', tmdbId);
if (logoUrl) {
return {
...item,
logo: logoUrl
};
}
}
return item;
} catch (error) {
logger.error(`Failed to fetch logo for ${item.name}:`, error);
return item;
}
})
);
}
} else {
// Load from installed catalogs
const catalogs = await catalogService.getHomeCatalogs();
if (signal.aborted) return;
// Filter catalogs based on user selection if any catalogs are selected
const filteredCatalogs = selectedCatalogs && selectedCatalogs.length > 0
? catalogs.filter(catalog => {
const catalogId = `${catalog.addon}:${catalog.type}:${catalog.id}`;
console.log(`Checking catalog: ${catalogId}, selected: ${selectedCatalogs.includes(catalogId)}`);
return selectedCatalogs.includes(catalogId);
})
: catalogs; // Use all catalogs if none specifically selected
console.log(`Original catalogs: ${catalogs.length}, Filtered catalogs: ${filteredCatalogs.length}`);
// Flatten all catalog items into a single array, filter out items without posters
const allItems = filteredCatalogs.flatMap(catalog => catalog.items)
.filter(item => item.poster)
.filter((item, index, self) =>
// Remove duplicates based on ID
index === self.findIndex(t => t.id === item.id)
);
// Sort by popular, newest, etc. (possibly enhanced later)
formattedContent = allItems.sort(() => Math.random() - 0.5).slice(0, 10);
}
if (signal.aborted) return;
setAllFeaturedContent(formattedContent);
if (formattedContent.length > 0) {
setFeaturedContent(formattedContent[0]);
currentIndexRef.current = 0;
} else {
setFeaturedContent(null);
}
} catch (error) {
if (signal.aborted) {
logger.info('Featured content fetch aborted');
} else {
logger.error('Failed to load featured content:', error);
}
setFeaturedContent(null);
setAllFeaturedContent([]);
} finally {
if (!signal.aborted) {
setLoading(false);
}
}
}, [cleanup, genreMap, loadingGenres, contentSource, selectedCatalogs]);
// Load featured content initially and when content source changes
useEffect(() => {
// Force a full refresh to get updated logos
if (contentSource === 'tmdb') {
setAllFeaturedContent([]);
setFeaturedContent(null);
}
loadFeaturedContent();
}, [loadFeaturedContent, contentSource, selectedCatalogs]);
useEffect(() => {
if (featuredContent) {
let isMounted = true;
const checkLibrary = async () => {
const items = await catalogService.getLibraryItems();
if (isMounted) {
setIsSaved(items.some(item => item.id === featuredContent.id));
}
};
checkLibrary();
return () => { isMounted = false; };
}
}, [featuredContent]);
useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
if (featuredContent) {
setIsSaved(items.some(item => item.id === featuredContent.id));
}
});
return () => unsubscribe();
}, [featuredContent]);
useEffect(() => {
if (allFeaturedContent.length <= 1) return;
const rotateContent = () => {
currentIndexRef.current = (currentIndexRef.current + 1) % allFeaturedContent.length;
if (allFeaturedContent[currentIndexRef.current]) {
setFeaturedContent(allFeaturedContent[currentIndexRef.current]);
}
};
const intervalId = setInterval(rotateContent, 15000);
return () => clearInterval(intervalId);
}, [allFeaturedContent]);
useEffect(() => {
return () => cleanup();
}, [cleanup]);
const handleSaveToLibrary = useCallback(async () => {
if (!featuredContent) return;
try {
const currentSavedStatus = isSaved;
setIsSaved(!currentSavedStatus);
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (currentSavedStatus) {
await catalogService.removeFromLibrary(featuredContent.type, featuredContent.id);
} else {
const itemToAdd = { ...featuredContent, inLibrary: true };
await catalogService.addToLibrary(itemToAdd);
}
} catch (error) {
logger.error('Error updating library:', error);
setIsSaved(prev => !prev);
}
}, [featuredContent, isSaved]);
return {
featuredContent,
loading,
isSaved,
handleSaveToLibrary,
refreshFeatured: loadFeaturedContent
};
}

View file

@ -0,0 +1,87 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { CatalogContent, catalogService } from '../services/catalogService';
import { logger } from '../utils/logger';
import { useCatalogContext } from '../contexts/CatalogContext';
export function useHomeCatalogs() {
const [catalogs, setCatalogs] = useState<CatalogContent[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const { lastUpdate } = useCatalogContext();
const cleanup = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const loadCatalogs = useCallback(async (isRefresh = false) => {
if (!isRefresh) {
setLoading(true);
} else {
setRefreshing(true);
}
cleanup();
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
try {
const homeCatalogs = await catalogService.getHomeCatalogs();
if (signal.aborted) return;
if (!homeCatalogs?.length) {
logger.warn('No home catalogs found.');
setCatalogs([]); // Ensure catalogs is empty if none found
return;
}
const uniqueCatalogsMap = new Map();
homeCatalogs.forEach(catalog => {
const contentKey = catalog.items.map(item => item.id).sort().join(',');
if (!uniqueCatalogsMap.has(contentKey)) {
uniqueCatalogsMap.set(contentKey, catalog);
}
});
if (signal.aborted) return;
const uniqueCatalogs = Array.from(uniqueCatalogsMap.values());
setCatalogs(uniqueCatalogs);
} catch (error) {
if (signal.aborted) {
logger.info('Catalog fetch aborted');
} else {
logger.error('Error in loadCatalogs:', error);
}
setCatalogs([]); // Clear catalogs on error
} finally {
if (!signal.aborted) {
setLoading(false);
setRefreshing(false);
}
}
}, [cleanup]);
// Initial load and reload on lastUpdate change
useEffect(() => {
loadCatalogs();
}, [loadCatalogs, lastUpdate]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
const refreshCatalogs = useCallback(() => {
return loadCatalogs(true);
}, [loadCatalogs]);
return { catalogs, loading, refreshing, refreshCatalogs };
}

View file

@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import { mdblistService, MDBListRatings } from '../services/mdblistService';
import { logger } from '../utils/logger';
import { isMDBListEnabled } from '../screens/MDBListSettingsScreen';
export const useMDBListRatings = (imdbId: string, mediaType: 'movie' | 'show') => {
const [ratings, setRatings] = useState<MDBListRatings | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchRatings = async () => {
if (!imdbId) {
logger.warn('[useMDBListRatings] No IMDB ID provided');
return;
}
// Check if MDBList is enabled before proceeding
const enabled = await isMDBListEnabled();
if (!enabled) {
logger.log('[useMDBListRatings] MDBList is disabled, not fetching ratings');
setRatings(null);
setLoading(false);
return;
}
logger.log(`[useMDBListRatings] Starting to fetch ratings for ${mediaType}:`, imdbId);
setLoading(true);
setError(null);
try {
const data = await mdblistService.getRatings(imdbId, mediaType);
logger.log('[useMDBListRatings] Received ratings:', data);
setRatings(data);
} catch (err) {
const errorMessage = 'Failed to fetch ratings';
logger.error('[useMDBListRatings] Error:', err);
setError(errorMessage);
} finally {
setLoading(false);
logger.log('[useMDBListRatings] Finished fetching ratings');
}
};
fetchRatings();
}, [imdbId, mediaType]);
return { ratings, loading, error };
};

View file

@ -206,8 +206,34 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
}; };
const loadCast = async () => { const loadCast = async () => {
setLoadingCast(true);
try { try {
setLoadingCast(true); // Handle TMDB IDs
let metadataId = id;
let metadataType = type;
if (id.startsWith('tmdb:')) {
const extractedTmdbId = id.split(':')[1];
logger.log('[loadCast] Using extracted TMDB ID:', extractedTmdbId);
// For TMDB IDs, we'll use the TMDB API directly
const castData = await tmdbService.getCredits(parseInt(extractedTmdbId), type);
if (castData && castData.cast) {
const formattedCast = castData.cast.map((actor: any) => ({
id: actor.id,
name: actor.name,
character: actor.character,
profile_path: actor.profile_path
}));
setCast(formattedCast);
setLoadingCast(false);
return formattedCast;
}
setLoadingCast(false);
return [];
}
// Continue with the existing logic for non-TMDB IDs
const cachedCast = cacheService.getCast(id, type); const cachedCast = cacheService.getCast(id, type);
if (cachedCast) { if (cachedCast) {
setCast(cachedCast); setCast(cachedCast);
@ -277,12 +303,172 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
return; return;
} }
// Handle TMDB-specific IDs
let actualId = id;
if (id.startsWith('tmdb:')) {
const tmdbId = id.split(':')[1];
// For TMDB IDs, we need to handle metadata differently
if (type === 'movie') {
logger.log('Fetching movie details from TMDB for:', tmdbId);
const movieDetails = await tmdbService.getMovieDetails(tmdbId);
if (movieDetails) {
const imdbId = movieDetails.imdb_id || movieDetails.external_ids?.imdb_id;
if (imdbId) {
// Use the imdbId for compatibility with the rest of the app
actualId = imdbId;
// Also store the TMDB ID for later use
setTmdbId(parseInt(tmdbId));
} else {
// If no IMDb ID, directly call loadTMDBMovie (create this function if needed)
const formattedMovie: StreamingContent = {
id: `tmdb:${tmdbId}`,
type: 'movie',
name: movieDetails.title,
poster: tmdbService.getImageUrl(movieDetails.poster_path) || '',
banner: tmdbService.getImageUrl(movieDetails.backdrop_path) || '',
description: movieDetails.overview || '',
year: movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4)) : undefined,
genres: movieDetails.genres?.map((g: { name: string }) => g.name) || [],
inLibrary: false,
};
// Fetch credits to get director and crew information
try {
const credits = await tmdbService.getCredits(parseInt(tmdbId), 'movie');
if (credits && credits.crew) {
// Extract directors
const directors = credits.crew
.filter((person: any) => person.job === 'Director')
.map((person: any) => person.name);
// Extract creators/writers
const writers = credits.crew
.filter((person: any) => ['Writer', 'Screenplay'].includes(person.job))
.map((person: any) => person.name);
// Add to formatted movie
if (directors.length > 0) {
(formattedMovie as any).directors = directors;
(formattedMovie as StreamingContent & { director: string }).director = directors.join(', ');
}
if (writers.length > 0) {
(formattedMovie as any).creators = writers;
(formattedMovie as StreamingContent & { writer: string }).writer = writers.join(', ');
}
}
} catch (error) {
logger.error('Failed to fetch credits for movie:', error);
}
// Fetch movie logo from TMDB
try {
const logoUrl = await tmdbService.getMovieImages(tmdbId);
if (logoUrl) {
formattedMovie.logo = logoUrl;
logger.log(`Successfully fetched logo for movie ${tmdbId} from TMDB`);
}
} catch (error) {
logger.error('Failed to fetch logo from TMDB:', error);
// Continue with execution, logo is optional
}
setMetadata(formattedMovie);
cacheService.setMetadata(id, type, formattedMovie);
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
setInLibrary(isInLib);
setLoading(false);
return;
}
}
} else if (type === 'series') {
// Handle TV shows with TMDB IDs
logger.log('Fetching TV show details from TMDB for:', tmdbId);
try {
const showDetails = await tmdbService.getTVShowDetails(parseInt(tmdbId));
if (showDetails) {
// Get external IDs to check for IMDb ID
const externalIds = await tmdbService.getShowExternalIds(parseInt(tmdbId));
const imdbId = externalIds?.imdb_id;
if (imdbId) {
// Use the imdbId for compatibility with the rest of the app
actualId = imdbId;
// Also store the TMDB ID for later use
setTmdbId(parseInt(tmdbId));
} else {
// If no IMDb ID, create formatted show from TMDB data
const formattedShow: StreamingContent = {
id: `tmdb:${tmdbId}`,
type: 'series',
name: showDetails.name,
poster: tmdbService.getImageUrl(showDetails.poster_path) || '',
banner: tmdbService.getImageUrl(showDetails.backdrop_path) || '',
description: showDetails.overview || '',
year: showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4)) : undefined,
genres: showDetails.genres?.map((g: { name: string }) => g.name) || [],
inLibrary: false,
};
// Fetch credits to get creators
try {
const credits = await tmdbService.getCredits(parseInt(tmdbId), 'series');
if (credits && credits.crew) {
// Extract creators
const creators = credits.crew
.filter((person: any) =>
person.job === 'Creator' ||
person.job === 'Series Creator' ||
person.department === 'Production' ||
person.job === 'Executive Producer'
)
.map((person: any) => person.name);
if (creators.length > 0) {
(formattedShow as any).creators = creators.slice(0, 3);
}
}
} catch (error) {
logger.error('Failed to fetch credits for TV show:', error);
}
// Fetch TV show logo from TMDB
try {
const logoUrl = await tmdbService.getTvShowImages(tmdbId);
if (logoUrl) {
formattedShow.logo = logoUrl;
logger.log(`Successfully fetched logo for TV show ${tmdbId} from TMDB`);
}
} catch (error) {
logger.error('Failed to fetch logo from TMDB:', error);
// Continue with execution, logo is optional
}
setMetadata(formattedShow);
cacheService.setMetadata(id, type, formattedShow);
// Load series data (episodes)
setTmdbId(parseInt(tmdbId));
loadSeriesData().catch(console.error);
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
setInLibrary(isInLib);
setLoading(false);
return;
}
}
} catch (error) {
logger.error('Failed to fetch TV show details from TMDB:', error);
}
}
}
// Load all data in parallel // Load all data in parallel
const [content, castData] = await Promise.allSettled([ const [content, castData] = await Promise.allSettled([
// Load content with timeout and retry // Load content with timeout and retry
withRetry(async () => { withRetry(async () => {
const result = await withTimeout( const result = await withTimeout(
catalogService.getContentDetails(type, id), catalogService.getContentDetails(type, actualId),
API_TIMEOUT API_TIMEOUT
); );
return result; return result;
@ -298,6 +484,41 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
setInLibrary(isInLib); setInLibrary(isInLib);
cacheService.setMetadata(id, type, content.value); cacheService.setMetadata(id, type, content.value);
// Fetch and add logo from TMDB
let finalMetadata = { ...content.value };
try {
// Get TMDB ID if not already set
const contentTmdbId = await tmdbService.extractTMDBIdFromStremioId(id);
if (contentTmdbId) {
// Determine content type for TMDB API (movie or tv)
const tmdbType = type === 'series' ? 'tv' : 'movie';
// Fetch logo from TMDB
const logoUrl = await tmdbService.getContentLogo(tmdbType, contentTmdbId);
if (logoUrl) {
// Update metadata with logo
finalMetadata.logo = logoUrl;
logger.log(`[useMetadata] Successfully fetched and set logo from TMDB for ${id}`);
} else {
// If TMDB has no logo, ensure logo property is null/undefined
finalMetadata.logo = undefined;
logger.log(`[useMetadata] No logo found on TMDB for ${id}. Setting logo to undefined.`);
}
} else {
// If we couldn't get a TMDB ID, ensure logo is null/undefined
finalMetadata.logo = undefined;
logger.log(`[useMetadata] Could not determine TMDB ID for ${id}. Setting logo to undefined.`);
}
} catch (error) {
logger.error(`[useMetadata] Error fetching logo from TMDB for ${id}:`, error);
// Ensure logo is null/undefined on error
finalMetadata.logo = undefined;
}
// Set the final metadata state
setMetadata(finalMetadata);
// Update cache with final metadata (including potentially nulled logo)
cacheService.setMetadata(id, type, finalMetadata);
if (type === 'series') { if (type === 'series') {
// Load series data in parallel with other data // Load series data in parallel with other data
loadSeriesData().catch(console.error); loadSeriesData().catch(console.error);

View file

@ -1,6 +1,25 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
// Simple event emitter for settings changes
class SettingsEventEmitter {
private listeners: Array<() => void> = [];
addListener(listener: () => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
emit() {
this.listeners.forEach(listener => listener());
}
}
// Singleton instance for app-wide access
export const settingsEmitter = new SettingsEventEmitter();
export interface AppSettings { export interface AppSettings {
enableDarkMode: boolean; enableDarkMode: boolean;
enableNotifications: boolean; enableNotifications: boolean;
@ -9,6 +28,9 @@ export interface AppSettings {
enableBackgroundPlayback: boolean; enableBackgroundPlayback: boolean;
cacheLimit: number; cacheLimit: number;
useExternalPlayer: boolean; useExternalPlayer: boolean;
showHeroSection: boolean;
featuredContentSource: 'tmdb' | 'catalogs';
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
} }
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
@ -19,6 +41,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
enableBackgroundPlayback: false, enableBackgroundPlayback: false,
cacheLimit: 1024, cacheLimit: 1024,
useExternalPlayer: false, useExternalPlayer: false,
showHeroSection: true,
featuredContentSource: 'tmdb',
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
}; };
const SETTINGS_STORAGE_KEY = 'app_settings'; const SETTINGS_STORAGE_KEY = 'app_settings';
@ -28,6 +53,13 @@ export const useSettings = () => {
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
// Subscribe to settings changes
const unsubscribe = settingsEmitter.addListener(() => {
loadSettings();
});
return unsubscribe;
}, []); }, []);
const loadSettings = async () => { const loadSettings = async () => {
@ -41,7 +73,7 @@ export const useSettings = () => {
} }
}; };
const updateSetting = async <K extends keyof AppSettings>( const updateSetting = useCallback(async <K extends keyof AppSettings>(
key: K, key: K,
value: AppSettings[K] value: AppSettings[K]
) => { ) => {
@ -49,10 +81,12 @@ export const useSettings = () => {
try { try {
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)); await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings));
setSettings(newSettings); setSettings(newSettings);
// Notify all subscribers that settings have changed
settingsEmitter.emit();
} catch (error) { } catch (error) {
console.error('Failed to save settings:', error); console.error('Failed to save settings:', error);
} }
}; }, [settings]);
return { return {
settings, settings,

View file

@ -8,6 +8,7 @@ import type { MD3Theme } from 'react-native-paper';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs'; import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { colors } from '../styles/colors'; import { colors } from '../styles/colors';
import { NuvioHeader } from '../components/NuvioHeader'; import { NuvioHeader } from '../components/NuvioHeader';
import { Stream } from '../types/streams'; import { Stream } from '../types/streams';
@ -27,6 +28,10 @@ import CatalogSettingsScreen from '../screens/CatalogSettingsScreen';
import StreamsScreen from '../screens/StreamsScreen'; import StreamsScreen from '../screens/StreamsScreen';
import CalendarScreen from '../screens/CalendarScreen'; import CalendarScreen from '../screens/CalendarScreen';
import NotificationSettingsScreen from '../screens/NotificationSettingsScreen'; import NotificationSettingsScreen from '../screens/NotificationSettingsScreen';
import MDBListSettingsScreen from '../screens/MDBListSettingsScreen';
import TMDBSettingsScreen from '../screens/TMDBSettingsScreen';
import HomeScreenSettings from '../screens/HomeScreenSettings';
import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
// Stack navigator types // Stack navigator types
export type RootStackParamList = { export type RootStackParamList = {
@ -76,6 +81,10 @@ export type RootStackParamList = {
Addons: undefined; Addons: undefined;
CatalogSettings: undefined; CatalogSettings: undefined;
NotificationSettings: undefined; NotificationSettings: undefined;
MDBListSettings: undefined;
TMDBSettings: undefined;
HomeScreenSettings: undefined;
HeroCatalogs: undefined;
}; };
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>; export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
@ -85,7 +94,6 @@ export type MainTabParamList = {
Home: undefined; Home: undefined;
Discover: undefined; Discover: undefined;
Library: undefined; Library: undefined;
Addons: undefined;
Settings: undefined; Settings: undefined;
}; };
@ -320,27 +328,46 @@ const MainTabs = () => {
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
height: 75, height: 85,
backgroundColor: 'transparent', backgroundColor: 'transparent',
overflow: 'hidden',
}}> }}>
<LinearGradient {Platform.OS === 'ios' ? (
colors={[ <BlurView
'rgba(0, 0, 0, 0)', tint="dark"
'rgba(0, 0, 0, 0.65)', intensity={75}
'rgba(0, 0, 0, 0.85)', style={{
'rgba(0, 0, 0, 0.98)', position: 'absolute',
]} height: '100%',
locations={[0, 0.2, 0.4, 0.8]} width: '100%',
style={{ borderTopColor: 'rgba(255,255,255,0.2)',
position: 'absolute', borderTopWidth: 0.5,
height: '100%', shadowColor: '#000',
width: '100%', shadowOffset: { width: 0, height: -2 },
}} shadowOpacity: 0.1,
/> shadowRadius: 3,
}}
/>
) : (
<LinearGradient
colors={[
'rgba(0, 0, 0, 0)',
'rgba(0, 0, 0, 0.65)',
'rgba(0, 0, 0, 0.85)',
'rgba(0, 0, 0, 0.98)',
]}
locations={[0, 0.2, 0.4, 0.8]}
style={{
position: 'absolute',
height: '100%',
width: '100%',
}}
/>
)}
<View <View
style={{ style={{
height: '100%', height: '100%',
paddingBottom: 10, paddingBottom: 20,
paddingTop: 12, paddingTop: 12,
backgroundColor: 'transparent', backgroundColor: 'transparent',
}} }}
@ -380,9 +407,6 @@ const MainTabs = () => {
case 'Library': case 'Library':
iconName = 'play-box-multiple'; iconName = 'play-box-multiple';
break; break;
case 'Addons':
iconName = 'puzzle';
break;
case 'Settings': case 'Settings':
iconName = 'cog'; iconName = 'cog';
break; break;
@ -442,9 +466,6 @@ const MainTabs = () => {
case 'Library': case 'Library':
iconName = 'play-box-multiple'; iconName = 'play-box-multiple';
break; break;
case 'Addons':
iconName = 'puzzle';
break;
case 'Settings': case 'Settings':
iconName = 'cog'; iconName = 'cog';
break; break;
@ -459,8 +480,8 @@ const MainTabs = () => {
backgroundColor: 'transparent', backgroundColor: 'transparent',
borderTopWidth: 0, borderTopWidth: 0,
elevation: 0, elevation: 0,
height: 75, height: 85,
paddingBottom: 10, paddingBottom: 20,
paddingTop: 12, paddingTop: 12,
}, },
tabBarLabelStyle: { tabBarLabelStyle: {
@ -469,20 +490,38 @@ const MainTabs = () => {
marginTop: 0, marginTop: 0,
}, },
tabBarBackground: () => ( tabBarBackground: () => (
<LinearGradient Platform.OS === 'ios' ? (
colors={[ <BlurView
'rgba(0, 0, 0, 0)', tint="dark"
'rgba(0, 0, 0, 0.65)', intensity={75}
'rgba(0, 0, 0, 0.85)', style={{
'rgba(0, 0, 0, 0.98)', position: 'absolute',
]} height: '100%',
locations={[0, 0.2, 0.4, 0.8]} width: '100%',
style={{ borderTopColor: 'rgba(255,255,255,0.2)',
position: 'absolute', borderTopWidth: 0.5,
height: '100%', shadowColor: '#000',
width: '100%', shadowOffset: { width: 0, height: -2 },
}} shadowOpacity: 0.1,
/> shadowRadius: 3,
}}
/>
) : (
<LinearGradient
colors={[
'rgba(0, 0, 0, 0)',
'rgba(0, 0, 0, 0.65)',
'rgba(0, 0, 0, 0.85)',
'rgba(0, 0, 0, 0.98)',
]}
locations={[0, 0.2, 0.4, 0.8]}
style={{
position: 'absolute',
height: '100%',
width: '100%',
}}
/>
)
), ),
header: () => route.name === 'Home' ? <NuvioHeader /> : null, header: () => route.name === 'Home' ? <NuvioHeader /> : null,
headerShown: route.name === 'Home', headerShown: route.name === 'Home',
@ -509,13 +548,6 @@ const MainTabs = () => {
tabBarLabel: 'Library' tabBarLabel: 'Library'
}} }}
/> />
<Tab.Screen
name="Addons"
component={AddonsScreen as any}
options={{
tabBarLabel: 'Addons'
}}
/>
<Tab.Screen <Tab.Screen
name="Settings" name="Settings"
component={SettingsScreen as any} component={SettingsScreen as any}
@ -583,6 +615,36 @@ const AppNavigator = () => {
name="CatalogSettings" name="CatalogSettings"
component={CatalogSettingsScreen as any} component={CatalogSettingsScreen as any}
/> />
<Stack.Screen
name="HomeScreenSettings"
component={HomeScreenSettings}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: colors.darkBackground,
},
}}
/>
<Stack.Screen
name="HeroCatalogs"
component={HeroCatalogsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: colors.darkBackground,
},
}}
/>
<Stack.Screen <Stack.Screen
name="ShowRatings" name="ShowRatings"
component={ShowRatingsScreen} component={ShowRatingsScreen}
@ -606,6 +668,36 @@ const AppNavigator = () => {
name="NotificationSettings" name="NotificationSettings"
component={NotificationSettingsScreen as any} component={NotificationSettingsScreen as any}
/> />
<Stack.Screen
name="MDBListSettings"
component={MDBListSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: colors.darkBackground,
},
}}
/>
<Stack.Screen
name="TMDBSettings"
component={TMDBSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: colors.darkBackground,
},
}}
/>
</Stack.Navigator> </Stack.Navigator>
</PaperProvider> </PaperProvider>
</> </>

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ import {
StatusBar, StatusBar,
RefreshControl, RefreshControl,
Dimensions, Dimensions,
Platform,
} from 'react-native'; } from 'react-native';
import { RouteProp } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack'; import { StackNavigationProp } from '@react-navigation/stack';
@ -17,6 +18,7 @@ import { RootStackParamList } from '../navigation/AppNavigator';
import { Meta, stremioService } from '../services/stremioService'; import { Meta, stremioService } from '../services/stremioService';
import { colors } from '../styles'; import { colors } from '../styles';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
type CatalogScreenProps = { type CatalogScreenProps = {
@ -24,7 +26,7 @@ type CatalogScreenProps = {
navigation: StackNavigationProp<RootStackParamList, 'Catalog'>; navigation: StackNavigationProp<RootStackParamList, 'Catalog'>;
}; };
// Consistent spacing variables // Constants for layout
const SPACING = { const SPACING = {
xs: 4, xs: 4,
sm: 8, sm: 8,
@ -33,11 +35,13 @@ const SPACING = {
xl: 24, xl: 24,
}; };
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Screen dimensions and grid layout // Screen dimensions and grid layout
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const NUM_COLUMNS = 3; const NUM_COLUMNS = 3;
const ITEM_MARGIN = SPACING.sm; const ITEM_MARGIN = SPACING.sm;
const ITEM_WIDTH = (width - (SPACING.md * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS; const ITEM_WIDTH = (width - (SPACING.lg * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS;
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const { addonId, type, id, name, genreFilter } = route.params; const { addonId, type, id, name, genreFilter } = route.params;
@ -47,7 +51,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Force dark mode instead of using color scheme // Force dark mode
const isDarkMode = true; const isDarkMode = true;
const loadItems = useCallback(async (pageNum: number, shouldRefresh: boolean = false) => { const loadItems = useCallback(async (pageNum: number, shouldRefresh: boolean = false) => {
@ -160,9 +164,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
useEffect(() => { useEffect(() => {
loadItems(1); loadItems(1);
// Set the header title }, [loadItems]);
navigation.setOptions({ title: name || `${type} catalog` });
}, [loadItems, navigation, name, type]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
setPage(1); setPage(1);
@ -185,7 +187,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Image <Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image' }} source={{ uri: item.poster || 'https://via.placeholder.com/300x450/333333/666666?text=No+Image' }}
style={styles.poster} style={styles.poster}
contentFit="cover" contentFit="cover"
transition={200} transition={200}
@ -209,8 +211,9 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const renderEmptyState = () => ( const renderEmptyState = () => (
<View style={styles.centered}> <View style={styles.centered}>
<MaterialIcons name="search-off" size={56} color={colors.mediumGray} />
<Text style={styles.emptyText}> <Text style={styles.emptyText}>
No content found for the selected genre No content found
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.button} style={styles.button}
@ -223,6 +226,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const renderErrorState = () => ( const renderErrorState = () => (
<View style={styles.centered}> <View style={styles.centered}>
<MaterialIcons name="error-outline" size={56} color={colors.mediumGray} />
<Text style={styles.errorText}> <Text style={styles.errorText}>
{error} {error}
</Text> </Text>
@ -238,13 +242,24 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const renderLoadingState = () => ( const renderLoadingState = () => (
<View style={styles.centered}> <View style={styles.centered}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading content...</Text>
</View> </View>
); );
if (loading && items.length === 0) { if (loading && items.length === 0) {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} /> <StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{renderLoadingState()} {renderLoadingState()}
</SafeAreaView> </SafeAreaView>
); );
@ -253,7 +268,17 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
if (error && items.length === 0) { if (error && items.length === 0) {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} /> <StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{renderErrorState()} {renderErrorState()}
</SafeAreaView> </SafeAreaView>
); );
@ -261,7 +286,18 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} /> <StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{items.length > 0 ? ( {items.length > 0 ? (
<FlatList <FlatList
data={items} data={items}
@ -287,6 +323,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
} }
contentContainerStyle={styles.list} contentContainerStyle={styles.list}
columnWrapperStyle={styles.columnWrapper} columnWrapperStyle={styles.columnWrapper}
showsVerticalScrollIndicator={false}
/> />
) : renderEmptyState()} ) : renderEmptyState()}
</SafeAreaView> </SafeAreaView>
@ -298,29 +335,60 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: colors.darkBackground, backgroundColor: colors.darkBackground,
}, },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
},
backText: {
fontSize: 17,
fontWeight: '400',
color: colors.primary,
},
headerTitle: {
fontSize: 34,
fontWeight: '700',
color: colors.white,
paddingHorizontal: 16,
paddingBottom: 16,
paddingTop: 8,
},
list: { list: {
padding: SPACING.md, padding: SPACING.lg,
paddingTop: SPACING.sm,
}, },
columnWrapper: { columnWrapper: {
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
item: { item: {
width: ITEM_WIDTH, width: ITEM_WIDTH,
marginBottom: SPACING.md, marginBottom: SPACING.lg,
borderRadius: 8, borderRadius: 12,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: colors.elevation2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
}, },
poster: { poster: {
width: '100%', width: '100%',
aspectRatio: 2/3, aspectRatio: 2/3,
borderRadius: 8, borderTopLeftRadius: 12,
backgroundColor: colors.transparentLight, borderTopRightRadius: 12,
backgroundColor: colors.elevation3,
}, },
itemContent: { itemContent: {
padding: SPACING.xs, padding: SPACING.sm,
}, },
title: { title: {
marginTop: SPACING.xs,
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: colors.white, color: colors.white,
@ -329,7 +397,7 @@ const styles = StyleSheet.create({
releaseInfo: { releaseInfo: {
fontSize: 12, fontSize: 12,
marginTop: SPACING.xs, marginTop: SPACING.xs,
color: colors.lightGray, color: colors.mediumGray,
}, },
footer: { footer: {
padding: SPACING.lg, padding: SPACING.lg,
@ -358,14 +426,21 @@ const styles = StyleSheet.create({
color: colors.white, color: colors.white,
fontSize: 16, fontSize: 16,
textAlign: 'center', textAlign: 'center',
marginBottom: SPACING.md, marginTop: SPACING.md,
marginBottom: SPACING.sm,
}, },
errorText: { errorText: {
color: colors.white, color: colors.white,
fontSize: 16, fontSize: 16,
textAlign: 'center', textAlign: 'center',
marginBottom: SPACING.md, marginTop: SPACING.md,
marginBottom: SPACING.sm,
}, },
loadingText: {
color: colors.white,
fontSize: 16,
marginTop: SPACING.lg,
}
}); });
export default CatalogScreen; export default CatalogScreen;

View file

@ -7,6 +7,9 @@ import {
Switch, Switch,
ActivityIndicator, ActivityIndicator,
TouchableOpacity, TouchableOpacity,
SafeAreaView,
StatusBar,
Platform,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
@ -29,13 +32,25 @@ interface CatalogSettingsStorage {
_lastUpdate: number; _lastUpdate: number;
} }
interface GroupedCatalogs {
[addonId: string]: {
name: string;
catalogs: CatalogSetting[];
expanded: boolean;
enabledCount: number;
};
}
const CATALOG_SETTINGS_KEY = 'catalog_settings'; const CATALOG_SETTINGS_KEY = 'catalog_settings';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const CatalogSettingsScreen = () => { const CatalogSettingsScreen = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [settings, setSettings] = useState<CatalogSetting[]>([]); const [settings, setSettings] = useState<CatalogSetting[]>([]);
const [groupedSettings, setGroupedSettings] = useState<GroupedCatalogs>({});
const navigation = useNavigation(); const navigation = useNavigation();
const { refreshCatalogs } = useCatalogContext(); const { refreshCatalogs } = useCatalogContext();
const isDarkMode = true; // Force dark mode
// Load saved settings and available catalogs // Load saved settings and available catalogs
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
@ -61,37 +76,17 @@ const CatalogSettingsScreen = () => {
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`; const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
// Format catalog name // Format catalog name
let displayName = catalog.name; let displayName = catalog.name || catalog.id;
// Clean up the name and ensure type is included // If catalog is a movie or series catalog, make that clear
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; const catalogType = catalog.type === 'movie' ? 'Movies' : catalog.type === 'series' ? 'TV Shows' : catalog.type.charAt(0).toUpperCase() + catalog.type.slice(1);
// Remove duplicate words (case-insensitive)
const words = displayName.split(' ');
const uniqueWords = [];
const seenWords = new Set();
for (const word of words) {
const lowerWord = word.toLowerCase();
if (!seenWords.has(lowerWord)) {
uniqueWords.push(word); // Keep original case
seenWords.add(lowerWord);
}
}
displayName = uniqueWords.join(' ');
// Add content type if not present (case-insensitive)
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
displayName = `${displayName} ${contentType}`;
}
// Create unique catalog setting
uniqueCatalogs.set(settingKey, { uniqueCatalogs.set(settingKey, {
addonId: addon.id, addonId: addon.id,
catalogId: catalog.id, catalogId: catalog.id,
type: catalog.type, type: catalog.type,
name: `${addon.name} - ${displayName}`, name: displayName,
enabled: savedCatalogs[settingKey] ?? true // Enable by default enabled: savedCatalogs[settingKey] !== undefined ? savedCatalogs[settingKey] : true // Enable by default
}); });
}); });
@ -100,18 +95,30 @@ const CatalogSettingsScreen = () => {
} }
}); });
// Sort catalogs by addon name and then by catalog name // Group settings by addon name
const sortedCatalogs = availableCatalogs.sort((a, b) => { const grouped: GroupedCatalogs = {};
const [addonNameA] = a.name.split(' - ');
const [addonNameB] = b.name.split(' - '); availableCatalogs.forEach(setting => {
const addon = addons.find(a => a.id === setting.addonId);
if (!addon) return;
if (addonNameA !== addonNameB) { if (!grouped[setting.addonId]) {
return addonNameA.localeCompare(addonNameB); grouped[setting.addonId] = {
name: addon.name,
catalogs: [],
expanded: true, // Start expanded
enabledCount: 0
};
}
grouped[setting.addonId].catalogs.push(setting);
if (setting.enabled) {
grouped[setting.addonId].enabledCount++;
} }
return a.name.localeCompare(b.name);
}); });
setSettings(sortedCatalogs); setSettings(availableCatalogs);
setGroupedSettings(grouped);
} catch (error) { } catch (error) {
logger.error('Failed to load catalog settings:', error); logger.error('Failed to load catalog settings:', error);
} finally { } finally {
@ -137,85 +144,158 @@ const CatalogSettingsScreen = () => {
}; };
// Toggle individual catalog // Toggle individual catalog
const toggleCatalog = (setting: CatalogSetting) => { const toggleCatalog = (addonId: string, index: number) => {
const newSettings = settings.map(s => { const newSettings = [...settings];
if (s.addonId === setting.addonId && const catalogsForAddon = groupedSettings[addonId].catalogs;
s.type === setting.type && const setting = catalogsForAddon[index];
s.catalogId === setting.catalogId) {
return { ...s, enabled: !s.enabled }; const updatedSetting = {
} ...setting,
return s; enabled: !setting.enabled
}); };
// Update the setting in the flat list
const flatIndex = newSettings.findIndex(s =>
s.addonId === setting.addonId &&
s.type === setting.type &&
s.catalogId === setting.catalogId
);
if (flatIndex !== -1) {
newSettings[flatIndex] = updatedSetting;
}
// Update the grouped settings
const newGroupedSettings = { ...groupedSettings };
newGroupedSettings[addonId].catalogs[index] = updatedSetting;
newGroupedSettings[addonId].enabledCount += updatedSetting.enabled ? 1 : -1;
setSettings(newSettings); setSettings(newSettings);
setGroupedSettings(newGroupedSettings);
saveSettings(newSettings); saveSettings(newSettings);
}; };
// Toggle expansion of a group
const toggleExpansion = (addonId: string) => {
setGroupedSettings(prev => ({
...prev,
[addonId]: {
...prev[addonId],
expanded: !prev[addonId].expanded
}
}));
};
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
}, [loadSettings]); }, [loadSettings]);
// Group settings by addon
const groupedSettings: { [key: string]: CatalogSetting[] } = {};
settings.forEach(setting => {
if (!groupedSettings[setting.addonId]) {
groupedSettings[setting.addonId] = [];
}
groupedSettings[setting.addonId].push(setting);
});
if (loading) { if (loading) {
return ( return (
<View style={styles.loadingContainer}> <SafeAreaView style={styles.container}>
<ActivityIndicator size="large" color={colors.primary} /> <StatusBar barStyle="light-content" />
</View> <View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.primary} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>Catalogs</Text>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
); );
} }
return ( return (
<View style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity <TouchableOpacity
style={styles.backButton} style={styles.backButton}
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="arrow-back" size={24} color={colors.text} /> <MaterialIcons name="chevron-left" size={28} color={colors.primary} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Catalog Settings</Text>
</View> </View>
<Text style={styles.headerTitle}>Catalogs</Text>
<ScrollView style={styles.scrollView}> <ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
<Text style={styles.description}> {Object.entries(groupedSettings).map(([addonId, group]) => (
Choose which catalogs to show on your home screen. Changes will take effect immediately.
</Text>
{Object.entries(groupedSettings).map(([addonId, addonCatalogs]) => (
<View key={addonId} style={styles.addonSection}> <View key={addonId} style={styles.addonSection}>
<Text style={styles.addonTitle}> <Text style={styles.addonTitle}>
{addonCatalogs[0].name.split(' - ')[0]} {group.name.toUpperCase()}
</Text> </Text>
{addonCatalogs.map((setting) => (
<View key={`${setting.addonId}:${setting.type}:${setting.catalogId}`} style={styles.catalogItem}> <View style={styles.card}>
<Text style={styles.catalogName}> <TouchableOpacity
{setting.name.split(' - ')[1]} style={styles.groupHeader}
</Text> onPress={() => toggleExpansion(addonId)}
<Switch activeOpacity={0.7}
value={setting.enabled} >
onValueChange={() => toggleCatalog(setting)} <Text style={styles.groupTitle}>Catalogs</Text>
trackColor={{ false: colors.mediumGray, true: colors.primary }} <View style={styles.groupHeaderRight}>
/> <Text style={styles.enabledCount}>
</View> {group.enabledCount} of {group.catalogs.length} enabled
))} </Text>
<MaterialIcons
name={group.expanded ? "keyboard-arrow-down" : "keyboard-arrow-right"}
size={24}
color={colors.mediumGray}
/>
</View>
</TouchableOpacity>
{group.expanded && group.catalogs.map((setting, index) => (
<View key={`${setting.addonId}:${setting.type}:${setting.catalogId}`} style={styles.catalogItem}>
<View style={styles.catalogInfo}>
<Text style={styles.catalogName}>
{setting.name}
</Text>
<Text style={styles.catalogType}>
{setting.type.charAt(0).toUpperCase() + setting.type.slice(1)}
</Text>
</View>
<Switch
value={setting.enabled}
onValueChange={() => toggleCatalog(addonId, index)}
trackColor={{ false: '#505050', true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
ios_backgroundColor="#505050"
/>
</View>
))}
</View>
</View> </View>
))} ))}
<View style={styles.addonSection}>
<Text style={styles.addonTitle}>ORGANIZATION</Text>
<View style={styles.card}>
<TouchableOpacity style={styles.organizationItem}>
<Text style={styles.organizationItemText}>Reorder Sections</Text>
<MaterialIcons name="chevron-right" size={24} color={colors.mediumGray} />
</TouchableOpacity>
<TouchableOpacity style={styles.organizationItem}>
<Text style={styles.organizationItemText}>Customize Names</Text>
<MaterialIcons name="chevron-right" size={24} color={colors.mediumGray} />
</TouchableOpacity>
</View>
</View>
</ScrollView> </ScrollView>
</View> </SafeAreaView>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.background, backgroundColor: colors.darkBackground,
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
@ -225,35 +305,77 @@ const styles = StyleSheet.create({
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
padding: 16, paddingHorizontal: 16,
borderBottomWidth: 1, paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
borderBottomColor: colors.border,
}, },
backButton: { backButton: {
marginRight: 16, flexDirection: 'row',
alignItems: 'center',
padding: 8,
},
backText: {
fontSize: 17,
fontWeight: '400',
color: colors.primary,
}, },
headerTitle: { headerTitle: {
fontSize: 20, fontSize: 34,
fontWeight: 'bold', fontWeight: '700',
color: colors.text, color: colors.white,
paddingHorizontal: 16,
paddingBottom: 16,
paddingTop: 8,
}, },
scrollView: { scrollView: {
flex: 1, flex: 1,
}, },
description: { scrollContent: {
padding: 16, paddingBottom: 32,
fontSize: 14,
color: colors.mediumGray,
}, },
addonSection: { addonSection: {
marginBottom: 24, marginBottom: 24,
}, },
addonTitle: { addonTitle: {
fontSize: 18, fontSize: 13,
fontWeight: 'bold', fontWeight: '600',
color: colors.text, color: colors.mediumGray,
paddingHorizontal: 16, marginHorizontal: 16,
marginBottom: 8, marginBottom: 8,
letterSpacing: 0.8,
},
card: {
marginHorizontal: 16,
borderRadius: 12,
overflow: 'hidden',
backgroundColor: colors.elevation2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
groupHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 0.5,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
},
groupTitle: {
fontSize: 17,
fontWeight: '600',
color: colors.white,
},
groupHeaderRight: {
flexDirection: 'row',
alignItems: 'center',
},
enabledCount: {
fontSize: 15,
color: colors.mediumGray,
marginRight: 8,
}, },
catalogItem: { catalogItem: {
flexDirection: 'row', flexDirection: 'row',
@ -261,14 +383,33 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingVertical: 12, paddingVertical: 12,
paddingHorizontal: 16, paddingHorizontal: 16,
borderBottomWidth: 1, borderBottomWidth: 0.5,
borderBottomColor: colors.border, borderBottomColor: 'rgba(255, 255, 255, 0.1)',
},
catalogInfo: {
flex: 1,
}, },
catalogName: { catalogName: {
fontSize: 16, fontSize: 15,
color: colors.text, color: colors.white,
flex: 1, marginBottom: 2,
marginRight: 16, },
catalogType: {
fontSize: 13,
color: colors.mediumGray,
},
organizationItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 0.5,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
},
organizationItemText: {
fontSize: 17,
color: colors.white,
}, },
}); });

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@ -11,6 +11,7 @@ import {
Dimensions, Dimensions,
ScrollView, ScrollView,
Platform, Platform,
Animated,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
@ -18,10 +19,11 @@ import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles'; import { colors } from '../styles';
import { catalogService, StreamingContent, CatalogContent } from '../services/catalogService'; import { catalogService, StreamingContent, CatalogContent } from '../services/catalogService';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import Animated, { FadeIn, FadeOut, SlideInRight, Layout } from 'react-native-reanimated'; import { FadeIn, FadeOut, SlideInRight, Layout } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { BlurView } from 'expo-blur';
interface Category { interface Category {
id: string; id: string;
@ -65,28 +67,207 @@ const COMMON_GENRES = [
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const DiscoverScreen = () => { // Memoized child components
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const CategoryButton = React.memo(({
const [selectedCategory, setSelectedCategory] = useState<Category>(CATEGORIES[0]); category,
const [selectedGenre, setSelectedGenre] = useState<string>('All'); isSelected,
const [catalogs, setCatalogs] = useState<GenreCatalog[]>([]); onPress
const [allContent, setAllContent] = useState<StreamingContent[]>([]); }: {
const [loading, setLoading] = useState(true); category: Category;
const { width } = Dimensions.get('window'); isSelected: boolean;
const itemWidth = (width - 60) / 4; // 4 items per row with spacing onPress: () => void;
}) => {
const styles = useStyles();
return (
<TouchableOpacity
style={[
styles.categoryButton,
isSelected && styles.selectedCategoryButton
]}
onPress={onPress}
activeOpacity={0.7}
>
<MaterialIcons
name={category.icon}
size={24}
color={isSelected ? colors.white : colors.mediumGray}
/>
<Text
style={[
styles.categoryText,
isSelected && styles.selectedCategoryText
]}
>
{category.name}
</Text>
</TouchableOpacity>
);
});
const styles = StyleSheet.create({ const GenreButton = React.memo(({
genre,
isSelected,
onPress
}: {
genre: string;
isSelected: boolean;
onPress: () => void;
}) => {
const styles = useStyles();
return (
<TouchableOpacity
style={[
styles.genreButton,
isSelected && styles.selectedGenreButton
]}
onPress={onPress}
activeOpacity={0.7}
>
<Text
style={[
styles.genreText,
isSelected && styles.selectedGenreText
]}
>
{genre}
</Text>
</TouchableOpacity>
);
});
const ContentItem = React.memo(({
item,
onPress
}: {
item: StreamingContent;
onPress: () => void;
}) => {
const styles = useStyles();
const { width } = Dimensions.get('window');
const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing
return (
<TouchableOpacity
style={[styles.contentItem, { width: itemWidth }]}
onPress={onPress}
activeOpacity={0.6}
>
<View style={styles.posterContainer}>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
cachePolicy="memory-disk"
transition={300}
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.85)']}
style={styles.posterGradient}
>
<Text style={styles.contentTitle} numberOfLines={2}>
{item.name}
</Text>
{item.year && (
<Text style={styles.contentYear}>{item.year}</Text>
)}
</LinearGradient>
</View>
</TouchableOpacity>
);
});
const CatalogSection = React.memo(({
catalog,
selectedCategory,
navigation
}: {
catalog: GenreCatalog;
selectedCategory: Category;
navigation: NavigationProp<RootStackParamList>;
}) => {
const styles = useStyles();
const { width } = Dimensions.get('window');
const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing
// Only display the first 3 items in the row
const displayItems = useMemo(() =>
catalog.items.slice(0, 3),
[catalog.items]
);
const handleContentPress = useCallback((item: StreamingContent) => {
navigation.navigate('Metadata', { id: item.id, type: item.type });
}, [navigation]);
const renderItem = useCallback(({ item }: { item: StreamingContent }) => (
<ContentItem
item={item}
onPress={() => handleContentPress(item)}
/>
), [handleContentPress]);
const handleSeeMorePress = useCallback(() => {
navigation.navigate('Catalog', {
id: 'discover',
type: selectedCategory.type,
name: `${catalog.genre} ${selectedCategory.name}`,
genreFilter: catalog.genre
});
}, [navigation, selectedCategory, catalog.genre]);
const keyExtractor = useCallback((item: StreamingContent) => item.id, []);
const ItemSeparator = useCallback(() => <View style={{ width: 16 }} />, []);
return (
<View style={styles.catalogContainer}>
<View style={styles.catalogHeader}>
<View style={styles.catalogTitleContainer}>
<Text style={styles.catalogTitle}>{catalog.genre}</Text>
<View style={styles.catalogTitleBar} />
</View>
<TouchableOpacity
onPress={handleSeeMorePress}
style={styles.seeAllButton}
activeOpacity={0.6}
>
<Text style={styles.seeAllText}>See All</Text>
<MaterialIcons name="arrow-forward-ios" color={colors.primary} size={14} />
</TouchableOpacity>
</View>
<FlatList
data={displayItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16 }}
snapToInterval={itemWidth + 16}
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={ItemSeparator}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={3}
removeClippedSubviews={true}
/>
</View>
);
});
// Extract styles into a hook for better performance with dimensions
const useStyles = () => {
const { width } = Dimensions.get('window');
return StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.darkBackground, backgroundColor: colors.darkBackground,
}, },
header: { header: {
paddingHorizontal: 16, paddingHorizontal: 20,
paddingVertical: 12, paddingVertical: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 4, paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
backgroundColor: colors.darkBackground,
}, },
headerContent: { headerContent: {
flexDirection: 'row', flexDirection: 'row',
@ -96,66 +277,88 @@ const DiscoverScreen = () => {
headerTitle: { headerTitle: {
fontSize: 32, fontSize: 32,
fontWeight: '800', fontWeight: '800',
letterSpacing: 0.5,
color: colors.white, color: colors.white,
letterSpacing: 0.3,
}, },
searchButton: { searchButton: {
padding: 4, padding: 10,
marginLeft: 16, borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.08)',
}, },
categoryContainer: { categoryContainer: {
paddingVertical: 12, paddingVertical: 20,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)', borderBottomColor: 'rgba(255,255,255,0.05)',
}, },
categoriesContent: { categoriesContent: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
paddingHorizontal: 12, paddingHorizontal: 20,
gap: 12, gap: 16,
}, },
categoryButton: { categoryButton: {
paddingHorizontal: 20, paddingHorizontal: 20,
paddingVertical: 12, paddingVertical: 14,
marginHorizontal: 4, borderRadius: 24,
borderRadius: 16, backgroundColor: 'rgba(255,255,255,0.05)',
borderWidth: 1,
borderColor: colors.lightGray,
backgroundColor: 'transparent',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 10,
flex: 1,
maxWidth: 160,
justifyContent: 'center',
shadowColor: colors.black,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 4,
}, },
categoryIcon: { selectedCategoryButton: {
marginRight: 4, backgroundColor: colors.primary,
}, },
categoryText: { categoryText: {
color: colors.mediumGray, color: colors.mediumGray,
fontWeight: '500', fontWeight: '600',
fontSize: 15, fontSize: 16,
},
selectedCategoryText: {
color: colors.white,
fontWeight: '700',
}, },
genreContainer: { genreContainer: {
paddingVertical: 12, paddingTop: 20,
borderBottomWidth: 1, paddingBottom: 12,
borderBottomColor: 'rgba(255,255,255,0.1)', zIndex: 10,
}, },
genresScrollView: { genresScrollView: {
paddingHorizontal: 16, paddingHorizontal: 20,
paddingBottom: 8,
}, },
genreButton: { genreButton: {
paddingHorizontal: 16, paddingHorizontal: 18,
paddingVertical: 8, paddingVertical: 10,
marginRight: 8, marginRight: 12,
borderRadius: 16, borderRadius: 20,
borderWidth: 1, backgroundColor: 'rgba(255,255,255,0.05)',
borderColor: colors.lightGray, shadowColor: colors.black,
backgroundColor: 'transparent', shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
selectedGenreButton: {
backgroundColor: colors.primary,
}, },
genreText: { genreText: {
color: colors.mediumGray, color: colors.mediumGray,
fontWeight: '500', fontWeight: '500',
fontSize: 14, fontSize: 14,
}, },
selectedGenreText: {
color: colors.white,
fontWeight: '600',
},
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
@ -165,34 +368,36 @@ const DiscoverScreen = () => {
paddingVertical: 8, paddingVertical: 8,
}, },
catalogContainer: { catalogContainer: {
marginBottom: 24, marginBottom: 32,
}, },
catalogHeader: { catalogHeader: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 20,
marginBottom: 12, marginBottom: 16,
}, },
titleContainer: { catalogTitleContainer: {
flexDirection: 'column', flexDirection: 'column',
}, },
catalogTitleBar: {
width: 32,
height: 3,
backgroundColor: colors.primary,
marginTop: 6,
borderRadius: 2,
},
catalogTitle: { catalogTitle: {
fontSize: 18, fontSize: 20,
fontWeight: '700', fontWeight: '700',
color: colors.white, color: colors.white,
marginBottom: 2,
},
titleUnderline: {
height: 2,
width: 40,
backgroundColor: colors.primary,
borderRadius: 2,
}, },
seeAllButton: { seeAllButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 4,
paddingVertical: 6,
paddingHorizontal: 4,
}, },
seeAllText: { seeAllText: {
color: colors.primary, color: colors.primary,
@ -200,18 +405,17 @@ const DiscoverScreen = () => {
fontSize: 14, fontSize: 14,
}, },
contentItem: { contentItem: {
width: itemWidth, marginHorizontal: 0,
marginHorizontal: 5,
}, },
posterContainer: { posterContainer: {
borderRadius: 8, borderRadius: 16,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: colors.transparentLight, backgroundColor: 'rgba(255,255,255,0.03)',
elevation: 4, elevation: 5,
shadowColor: colors.black, shadowColor: colors.black,
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25, shadowOpacity: 0.2,
shadowRadius: 4, shadowRadius: 8,
}, },
poster: { poster: {
aspectRatio: 2/3, aspectRatio: 2/3,
@ -222,21 +426,23 @@ const DiscoverScreen = () => {
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
padding: 8, padding: 16,
justifyContent: 'flex-end', justifyContent: 'flex-end',
height: '45%',
}, },
contentTitle: { contentTitle: {
fontSize: 12, fontSize: 15,
fontWeight: '600', fontWeight: '700',
color: colors.white, color: colors.white,
marginBottom: 2, marginBottom: 4,
textShadowColor: 'rgba(0, 0, 0, 0.75)', textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: 0, height: 1 }, textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2, textShadowRadius: 2,
letterSpacing: 0.3,
}, },
contentYear: { contentYear: {
fontSize: 10, fontSize: 12,
color: colors.mediumGray, color: 'rgba(255,255,255,0.7)',
textShadowColor: 'rgba(0, 0, 0, 0.75)', textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: 0, height: 1 }, textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2, textShadowRadius: 2,
@ -245,15 +451,27 @@ const DiscoverScreen = () => {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingTop: 100, paddingTop: 80,
}, },
emptyText: { emptyText: {
color: colors.mediumGray, color: colors.mediumGray,
fontSize: 16, fontSize: 16,
fontWeight: '500', textAlign: 'center',
paddingHorizontal: 32,
}, },
}); });
};
const DiscoverScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const [selectedCategory, setSelectedCategory] = useState<Category>(CATEGORIES[0]);
const [selectedGenre, setSelectedGenre] = useState<string>('All');
const [catalogs, setCatalogs] = useState<GenreCatalog[]>([]);
const [allContent, setAllContent] = useState<StreamingContent[]>([]);
const [loading, setLoading] = useState(true);
const styles = useStyles();
// Load content when category or genre changes
useEffect(() => { useEffect(() => {
loadContent(selectedCategory, selectedGenre); loadContent(selectedCategory, selectedGenre);
}, [selectedCategory, selectedGenre]); }, [selectedCategory, selectedGenre]);
@ -316,204 +534,97 @@ const DiscoverScreen = () => {
} }
}; };
const handleCategoryPress = (category: Category) => { const handleCategoryPress = useCallback((category: Category) => {
if (category.id !== selectedCategory.id) { if (category.id !== selectedCategory.id) {
setSelectedCategory(category); setSelectedCategory(category);
setSelectedGenre('All'); // Reset to All when changing category setSelectedGenre('All'); // Reset to All when changing category
} }
}; }, [selectedCategory]);
const handleGenrePress = (genre: string) => { const handleGenrePress = useCallback((genre: string) => {
if (genre !== selectedGenre) { if (genre !== selectedGenre) {
setSelectedGenre(genre); setSelectedGenre(genre);
} }
};
const handleSearchPress = () => {
// @ts-ignore - We'll fix navigation types later
navigation.navigate('Search');
};
const renderCategory = ({ item }: { item: Category }) => {
const isSelected = selectedCategory.id === item.id;
return (
<TouchableOpacity
style={[
styles.categoryButton,
isSelected && {
backgroundColor: colors.primary,
borderColor: colors.primary,
transform: [{ scale: 1.05 }],
}
]}
onPress={() => handleCategoryPress(item)}
>
<MaterialIcons
name={item.icon}
size={24}
color={isSelected ? colors.white : colors.mediumGray}
style={styles.categoryIcon}
/>
<Text
style={[
styles.categoryText,
isSelected && { color: colors.white, fontWeight: '600' }
]}
>
{item.name}
</Text>
</TouchableOpacity>
);
};
const renderGenre = useCallback((genre: string) => {
const isSelected = selectedGenre === genre;
return (
<TouchableOpacity
key={genre}
style={[
styles.genreButton,
isSelected && {
backgroundColor: colors.primary,
borderColor: colors.primary
}
]}
onPress={() => handleGenrePress(genre)}
>
<Text
style={[
styles.genreText,
isSelected && { color: colors.white, fontWeight: '600' }
]}
>
{genre}
</Text>
</TouchableOpacity>
);
}, [selectedGenre]); }, [selectedGenre]);
const renderContentItem = useCallback(({ item }: { item: StreamingContent }) => { const handleSearchPress = useCallback(() => {
return ( navigation.navigate('Search');
<TouchableOpacity
style={styles.contentItem}
onPress={() => {
navigation.navigate('Metadata', { id: item.id, type: item.type });
}}
>
<View style={styles.posterContainer}>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.8)']}
style={styles.posterGradient}
>
<Text style={styles.contentTitle} numberOfLines={2}>
{item.name}
</Text>
{item.year && (
<Text style={styles.contentYear}>{item.year}</Text>
)}
</LinearGradient>
</View>
</TouchableOpacity>
);
}, [navigation]); }, [navigation]);
const renderCatalog = useCallback(({ item }: { item: GenreCatalog }) => { // Memoize rendering functions
// Only display the first 4 items in the row const renderCatalogItem = useCallback(({ item }: { item: GenreCatalog }) => (
const displayItems = item.items.slice(0, 4); <CatalogSection
catalog={item}
return ( selectedCategory={selectedCategory}
<View style={styles.catalogContainer}> navigation={navigation}
<View style={styles.catalogHeader}> />
<View style={styles.titleContainer}> ), [selectedCategory, navigation]);
<Text style={styles.catalogTitle}>{item.genre}</Text>
<View style={styles.titleUnderline} /> // Memoize list key extractor
</View> const catalogKeyExtractor = useCallback((item: GenreCatalog) => item.genre, []);
<TouchableOpacity
onPress={() => {
// Navigate to catalog view with genre filter
navigation.navigate('Catalog', {
id: 'discover',
type: selectedCategory.type,
name: `${item.genre} ${selectedCategory.name}`,
genreFilter: item.genre
});
}}
style={styles.seeAllButton}
>
<Text style={styles.seeAllText}>See More</Text>
<MaterialIcons name="arrow-forward" color={colors.primary} size={16} />
</TouchableOpacity>
</View>
<FlatList
data={displayItems}
renderItem={renderContentItem}
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 11 }}
snapToInterval={itemWidth + 10}
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
/>
</View>
);
}, [navigation, selectedCategory]);
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar <StatusBar
barStyle="light-content" barStyle="light-content"
backgroundColor={colors.darkBackground} backgroundColor="transparent"
translucent translucent
/> />
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
{/* Header Section */}
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerContent}> <View style={styles.headerContent}>
<Text style={styles.headerTitle}> <Text style={styles.headerTitle}>Discover</Text>
Discover
</Text>
<TouchableOpacity <TouchableOpacity
onPress={handleSearchPress} onPress={handleSearchPress}
style={styles.searchButton} style={styles.searchButton}
activeOpacity={0.7}
> >
<MaterialIcons <MaterialIcons
name="search" name="search"
size={24} size={24}
color={colors.white} color={colors.white}
style={{ opacity: 0.7 }}
/> />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
{/* Categories Section */}
<View style={styles.categoryContainer}> <View style={styles.categoryContainer}>
<View style={styles.categoriesContent}> <View style={styles.categoriesContent}>
{CATEGORIES.map((category) => ( {CATEGORIES.map((category) => (
<View key={category.id}> <CategoryButton
{renderCategory({ item: category })} key={category.id}
</View> category={category}
isSelected={selectedCategory.id === category.id}
onPress={() => handleCategoryPress(category)}
/>
))} ))}
</View> </View>
</View> </View>
{/* Genres Section */}
<View style={styles.genreContainer}> <View style={styles.genreContainer}>
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.genresScrollView} contentContainerStyle={styles.genresScrollView}
decelerationRate="fast"
snapToInterval={10}
> >
{COMMON_GENRES.map(genre => renderGenre(genre))} {COMMON_GENRES.map(genre => (
<GenreButton
key={genre}
genre={genre}
isSelected={selectedGenre === genre}
onPress={() => handleGenrePress(genre)}
/>
))}
</ScrollView> </ScrollView>
</View> </View>
{/* Content Section */}
{loading ? ( {loading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
@ -521,12 +632,14 @@ const DiscoverScreen = () => {
) : catalogs.length > 0 ? ( ) : catalogs.length > 0 ? (
<FlatList <FlatList
data={catalogs} data={catalogs}
renderItem={renderCatalog} renderItem={renderCatalogItem}
keyExtractor={(item) => item.genre} keyExtractor={catalogKeyExtractor}
contentContainerStyle={styles.catalogsContainer} contentContainerStyle={styles.catalogsContainer}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
initialNumToRender={3} initialNumToRender={3}
maxToRenderPerBatch={3} maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/> />
) : ( ) : (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
@ -540,4 +653,4 @@ const DiscoverScreen = () => {
); );
}; };
export default DiscoverScreen; export default React.memo(DiscoverScreen);

View file

@ -0,0 +1,318 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Switch,
ScrollView,
SafeAreaView,
StatusBar,
Platform,
useColorScheme,
ActivityIndicator,
Alert,
} from 'react-native';
import { useSettings } from '../hooks/useSettings';
import { useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles/colors';
import { catalogService, StreamingAddon } from '../services/catalogService';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
interface CatalogItem {
id: string; // Combined ID in format: addonId:type:catalogId
name: string;
addonName: string;
type: string;
}
const HeroCatalogsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme();
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
const navigation = useNavigation();
const [loading, setLoading] = useState(true);
const [catalogs, setCatalogs] = useState<CatalogItem[]>([]);
const [selectedCatalogs, setSelectedCatalogs] = useState<string[]>(settings.selectedHeroCatalogs || []);
const handleBack = useCallback(() => {
navigation.goBack();
}, [navigation]);
// Load all available catalogs
useEffect(() => {
const loadCatalogs = async () => {
setLoading(true);
try {
const addons = await catalogService.getAllAddons();
const catalogItems: CatalogItem[] = [];
addons.forEach(addon => {
if (addon.catalogs && addon.catalogs.length > 0) {
addon.catalogs.forEach(catalog => {
catalogItems.push({
id: `${addon.id}:${catalog.type}:${catalog.id}`,
name: catalog.name,
addonName: addon.name,
type: catalog.type,
});
});
}
});
setCatalogs(catalogItems);
} catch (error) {
console.error('Failed to load catalogs:', error);
Alert.alert('Error', 'Failed to load catalogs');
} finally {
setLoading(false);
}
};
loadCatalogs();
}, []);
const handleSelectAll = useCallback(() => {
setSelectedCatalogs(catalogs.map(catalog => catalog.id));
}, [catalogs]);
const handleSelectNone = useCallback(() => {
setSelectedCatalogs([]);
}, []);
const handleSave = useCallback(() => {
updateSetting('selectedHeroCatalogs', selectedCatalogs);
navigation.goBack();
}, [navigation, selectedCatalogs, updateSetting]);
const toggleCatalog = useCallback((catalogId: string) => {
setSelectedCatalogs(prev => {
if (prev.includes(catalogId)) {
return prev.filter(id => id !== catalogId);
} else {
return [...prev, catalogId];
}
});
}, []);
// Group catalogs by addon
const catalogsByAddon: Record<string, CatalogItem[]> = {};
catalogs.forEach(catalog => {
if (!catalogsByAddon[catalog.addonName]) {
catalogsByAddon[catalog.addonName] = [];
}
catalogsByAddon[catalog.addonName].push(catalog);
});
return (
<SafeAreaView style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
/>
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Hero Section Catalogs
</Text>
</View>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
Loading catalogs...
</Text>
</View>
) : (
<>
<View style={styles.actionBar}>
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: isDarkMode ? colors.elevation2 : colors.white }]}
onPress={handleSelectAll}
>
<Text style={[styles.actionButtonText, { color: colors.primary }]}>Select All</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: isDarkMode ? colors.elevation2 : colors.white }]}
onPress={handleSelectNone}
>
<Text style={[styles.actionButtonText, { color: colors.primary }]}>Clear All</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: colors.primary }]}
onPress={handleSave}
>
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
</View>
<View style={styles.infoCard}>
<Text style={[styles.infoText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
Select which catalogs to display in the hero section. If none are selected, all catalogs will be used.
</Text>
</View>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{Object.entries(catalogsByAddon).map(([addonName, addonCatalogs]) => (
<View key={addonName} style={styles.addonSection}>
<Text style={[styles.addonName, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
{addonName}
</Text>
<View style={[
styles.catalogsContainer,
{ backgroundColor: isDarkMode ? colors.elevation1 : colors.white }
]}>
{addonCatalogs.map(catalog => (
<TouchableOpacity
key={catalog.id}
style={[
styles.catalogItem,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
]}
onPress={() => toggleCatalog(catalog.id)}
>
<View style={styles.catalogInfo}>
<Text style={[styles.catalogName, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
{catalog.name}
</Text>
<Text style={[styles.catalogType, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
{catalog.type === 'movie' ? 'Movies' : 'TV Shows'}
</Text>
</View>
<MaterialIcons
name={selectedCatalogs.includes(catalog.id) ? "check-box" : "check-box-outline-blank"}
size={24}
color={selectedCatalogs.includes(catalog.id) ? colors.primary : isDarkMode ? colors.mediumEmphasis : colors.textMutedDark}
/>
</TouchableOpacity>
))}
</View>
</View>
))}
</ScrollView>
</>
)}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8,
},
backButton: {
marginRight: 16,
padding: 4,
},
headerTitle: {
fontSize: 22,
fontWeight: '700',
letterSpacing: 0.5,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 32,
},
actionBar: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 12,
justifyContent: 'space-between',
},
actionButton: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
marginRight: 8,
},
actionButtonText: {
fontSize: 14,
fontWeight: '600',
},
saveButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
saveButtonText: {
color: colors.white,
fontSize: 14,
fontWeight: '600',
},
infoCard: {
marginHorizontal: 16,
marginBottom: 16,
padding: 12,
borderRadius: 8,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
},
infoText: {
fontSize: 14,
},
addonSection: {
marginBottom: 16,
},
addonName: {
fontSize: 16,
fontWeight: '700',
marginHorizontal: 16,
marginBottom: 8,
},
catalogsContainer: {
marginHorizontal: 16,
borderRadius: 12,
overflow: 'hidden',
},
catalogItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
},
catalogInfo: {
flex: 1,
},
catalogName: {
fontSize: 16,
fontWeight: '500',
},
catalogType: {
fontSize: 14,
marginTop: 2,
},
});
export default HeroCatalogsScreen;

View file

@ -52,6 +52,9 @@ import * as Haptics from 'expo-haptics';
import { tmdbService } from '../services/tmdbService'; import { tmdbService } from '../services/tmdbService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { storageService } from '../services/storageService'; import { storageService } from '../services/storageService';
import { useHomeCatalogs } from '../hooks/useHomeCatalogs';
import { useFeaturedContent } from '../hooks/useFeaturedContent';
import { useSettings, settingsEmitter } from '../hooks/useSettings';
// Define interfaces for our data // Define interfaces for our data
interface Category { interface Category {
@ -119,6 +122,8 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
const menuStyle = useAnimatedStyle(() => ({ const menuStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }], transform: [{ translateY: translateY.value }],
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
})); }));
const menuOptions = [ const menuOptions = [
@ -193,7 +198,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
<MaterialIcons <MaterialIcons
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"} name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
size={24} size={24}
color={isDarkMode ? '#FFFFFF' : '#000000'} color={colors.primary}
/> />
<Text style={[ <Text style={[
styles.menuOptionText, styles.menuOptionText,
@ -279,7 +284,7 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
source={{ uri: localItem.poster }} source={{ uri: localItem.poster }}
style={styles.poster} style={styles.poster}
contentFit="cover" contentFit="cover"
transition={200} transition={300}
cachePolicy="memory-disk" cachePolicy="memory-disk"
recyclingKey={`poster-${localItem.id}`} recyclingKey={`poster-${localItem.id}`}
onLoadStart={() => { onLoadStart={() => {
@ -303,12 +308,12 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
)} )}
{isWatched && ( {isWatched && (
<View style={styles.watchedIndicator}> <View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={24} color="#00C853" /> <MaterialIcons name="check-circle" size={22} color={colors.success} />
</View> </View>
)} )}
{localItem.inLibrary && ( {localItem.inLibrary && (
<View style={styles.libraryBadge}> <View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color="#FFFFFF" /> <MaterialIcons name="bookmark" size={16} color={colors.white} />
</View> </View>
)} )}
</View> </View>
@ -333,114 +338,75 @@ const SAMPLE_CATEGORIES: Category[] = [
const SkeletonCatalog = () => ( const SkeletonCatalog = () => (
<View style={styles.catalogContainer}> <View style={styles.catalogContainer}>
<View style={styles.catalogHeader}> <View style={styles.loadingPlaceholder}>
<View style={[styles.skeletonBox, { width: 150, height: 24 }]} /> <ActivityIndicator size="small" color={colors.primary} />
<View style={[styles.skeletonBox, { width: 80, height: 20 }]} />
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.catalogList}>
{[1, 2, 3, 4].map((_, index) => (
<View key={index} style={[styles.contentItem, styles.skeletonPoster]} />
))}
</ScrollView>
</View> </View>
); );
const SkeletonFeatured = () => ( const SkeletonFeatured = () => (
<View style={styles.featuredContainer}> <View style={styles.featuredLoadingContainer}>
<View style={[styles.skeletonBox, styles.skeletonFeatured]}> <ActivityIndicator size="large" color={colors.primary} />
<LinearGradient <Text style={styles.loadingText}>Loading featured content...</Text>
colors={['rgba(0,0,0,0.1)', 'rgba(0,0,0,0.7)', 'rgba(0,0,0,0.95)']}
style={styles.featuredGradient}
>
<View style={styles.featuredContent}>
<View style={[styles.skeletonBox, { width: width * 0.6, height: 60, marginBottom: 16 }]} />
<View style={styles.genreContainer}>
{[1, 2, 3].map((_, index) => (
<View key={index} style={[styles.skeletonBox, { width: 80, height: 24, marginRight: 8 }]} />
))}
</View>
<View style={[styles.skeletonBox, { width: width * 0.8, height: 60, marginTop: 16 }]} />
<View style={styles.featuredButtons}>
<View style={[styles.skeletonBox, { flex: 1, height: 50, marginRight: 12, borderRadius: 25 }]} />
<View style={[styles.skeletonBox, { flex: 1, height: 50, borderRadius: 25 }]} />
</View>
</View>
</LinearGradient>
</View>
</View> </View>
); );
// Add genre mapping
const GENRE_MAP: { [key: number]: string } = {
28: 'Action',
12: 'Adventure',
16: 'Animation',
35: 'Comedy',
80: 'Crime',
99: 'Documentary',
18: 'Drama',
10751: 'Family',
14: 'Fantasy',
36: 'History',
27: 'Horror',
10402: 'Music',
9648: 'Mystery',
10749: 'Romance',
878: 'Sci-Fi',
10770: 'TV Movie',
53: 'Thriller',
10752: 'War',
37: 'Western'
};
const HomeScreen = () => { const HomeScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState('movie');
const [featuredContent, setFeaturedContent] = useState<StreamingContent | null>(null);
const [allFeaturedContent, setAllFeaturedContent] = useState<StreamingContent[]>([]);
const [catalogs, setCatalogs] = useState<CatalogContent[]>([]);
const [imagesPreloaded, setImagesPreloaded] = useState(false);
const [loadingImages, setLoadingImages] = useState(true);
const maxRetries = 3;
const { lastUpdate } = useCatalogContext();
const [isSaved, setIsSaved] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const currentIndexRef = useRef(0);
const continueWatchingRef = useRef<{ refresh: () => Promise<void> }>(null); const continueWatchingRef = useRef<{ refresh: () => Promise<void> }>(null);
const { settings } = useSettings();
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Add auto-rotation effect const {
catalogs,
loading: catalogsLoading,
refreshing: catalogsRefreshing,
refreshCatalogs
} = useHomeCatalogs();
const {
featuredContent,
loading: featuredLoading,
isSaved,
handleSaveToLibrary,
refreshFeatured
} = useFeaturedContent();
// Only count feature section as loading if it's enabled in settings
const isLoading = (showHeroSection ? featuredLoading : false) || catalogsLoading;
const isRefreshing = catalogsRefreshing;
// React to settings changes
useEffect(() => { useEffect(() => {
if (allFeaturedContent.length === 0) return; setShowHeroSection(settings.showHeroSection);
setFeaturedContentSource(settings.featuredContentSource);
}, [settings]);
const rotateContent = () => { // If featured content source changes, refresh featured content with debouncing
currentIndexRef.current = (currentIndexRef.current + 1) % allFeaturedContent.length; useEffect(() => {
setFeaturedContent(allFeaturedContent[currentIndexRef.current]); if (showHeroSection) {
}; // Clear any existing timeout
if (refreshTimeoutRef.current) {
const intervalId = setInterval(rotateContent, 15000); // 15 seconds clearTimeout(refreshTimeoutRef.current);
}
return () => {
clearInterval(intervalId); // Set a new timeout to debounce the refresh
}; refreshTimeoutRef.current = setTimeout(() => {
}, [allFeaturedContent]); refreshFeatured();
refreshTimeoutRef.current = null;
// Cleanup function for ongoing operations }, 300);
const cleanup = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
} }
}, []);
// Cleanup the timeout on unmount
// Cleanup on unmount
useEffect(() => {
return () => { return () => {
cleanup(); if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
}; };
}, [cleanup]); }, [featuredContentSource, showHeroSection, refreshFeatured]);
useEffect(() => { useEffect(() => {
StatusBar.setTranslucent(true); StatusBar.setTranslucent(true);
@ -451,11 +417,8 @@ const HomeScreen = () => {
}; };
}, []); }, []);
// Pre-warm the metadata screen
useEffect(() => { useEffect(() => {
// Pre-warm the navigation
navigation.addListener('beforeRemove', () => {}); navigation.addListener('beforeRemove', () => {});
return () => { return () => {
navigation.removeListener('beforeRemove', () => {}); navigation.removeListener('beforeRemove', () => {});
}; };
@ -465,7 +428,6 @@ const HomeScreen = () => {
if (!content.length) return; if (!content.length) return;
try { try {
setLoadingImages(true);
const imagePromises = content.map(item => { const imagePromises = content.map(item => {
const imagesToLoad = [ const imagesToLoad = [
item.poster, item.poster,
@ -481,167 +443,30 @@ const HomeScreen = () => {
}); });
await Promise.all(imagePromises); await Promise.all(imagePromises);
setImagesPreloaded(true);
} catch (error) { } catch (error) {
console.error('Error preloading images:', error); console.error('Error preloading images:', error);
} finally {
setLoadingImages(false);
} }
}, []); }, []);
const loadFeaturedContent = useCallback(async () => { const handleRefresh = useCallback(async () => {
try { try {
const trendingResults = await tmdbService.getTrending('movie', 'day'); const refreshTasks = [
refreshCatalogs(),
continueWatchingRef.current?.refresh(),
];
if (trendingResults.length > 0) { // Only refresh featured content if hero section is enabled
const formattedContent: StreamingContent[] = trendingResults if (showHeroSection) {
.filter(item => item.title || item.name) // Filter out items without a name refreshTasks.push(refreshFeatured());
.map(item => {
const yearString = (item.release_date || item.first_air_date)?.substring(0, 4);
return {
id: `tmdb:${item.id}`,
type: 'movie',
name: item.title || item.name || 'Unknown Title',
poster: tmdbService.getImageUrl(item.poster_path) || '',
banner: tmdbService.getImageUrl(item.backdrop_path) || '',
logo: item.external_ids?.imdb_id ? `https://images.metahub.space/logo/medium/${item.external_ids.imdb_id}/img` : undefined,
description: item.overview || '',
year: yearString ? parseInt(yearString, 10) : undefined,
genres: item.genre_ids.map(id => GENRE_MAP[id] || id.toString()),
inLibrary: false,
};
});
setAllFeaturedContent(formattedContent);
// Randomly select a featured item
const randomIndex = Math.floor(Math.random() * formattedContent.length);
setFeaturedContent(formattedContent[randomIndex]);
} }
} catch (error) {
logger.error('Failed to load featured content:', error);
}
}, []);
const loadCatalogs = useCallback(async () => {
// Create new abort controller for this load operation
cleanup();
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
try {
// Load catalogs from service
const homeCatalogs = await catalogService.getHomeCatalogs();
if (signal.aborted) return; await Promise.all(refreshTasks);
// If no catalogs found, wait and retry
if (!homeCatalogs?.length) {
console.log('No catalogs found');
return;
}
// Create a map to store unique catalogs by their content
const uniqueCatalogsMap = new Map();
homeCatalogs.forEach(catalog => {
const contentKey = catalog.items.map(item => item.id).sort().join(',');
if (!uniqueCatalogsMap.has(contentKey)) {
uniqueCatalogsMap.set(contentKey, catalog);
}
});
if (signal.aborted) return;
const uniqueCatalogs = Array.from(uniqueCatalogsMap.values());
setCatalogs(uniqueCatalogs);
return;
} catch (error) { } catch (error) {
console.error('Error in loadCatalogs:', error);
} finally {
if (!signal.aborted) {
setLoading(false);
setRefreshing(false);
}
}
}, [maxRetries, cleanup]);
// Update loadInitialData to remove continue watching loading
const loadInitialData = async () => {
setLoading(true);
try {
await Promise.all([
loadFeaturedContent(),
loadCatalogs(),
]);
} catch (error) {
logger.error('Error loading initial data:', error);
} finally {
setLoading(false);
}
};
// Add back the useEffect for loadInitialData
useEffect(() => {
loadInitialData();
}, [loadFeaturedContent, loadCatalogs, lastUpdate]);
// Update handleRefresh to remove continue watching loading
const handleRefresh = useCallback(() => {
setRefreshing(true);
Promise.all([
loadFeaturedContent(),
loadCatalogs(),
]).catch(error => {
logger.error('Error during refresh:', error); logger.error('Error during refresh:', error);
}).finally(() => {
setRefreshing(false);
});
}, [loadFeaturedContent, loadCatalogs]);
// Check if content is in library
useEffect(() => {
if (featuredContent) {
const checkLibrary = async () => {
const items = await catalogService.getLibraryItems();
setIsSaved(items.some(item => item.id === featuredContent.id));
};
checkLibrary();
} }
}, [featuredContent]); }, [refreshFeatured, refreshCatalogs, showHeroSection]);
// Subscribe to library updates
useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
if (featuredContent) {
setIsSaved(items.some(item => item.id === featuredContent.id));
}
});
return () => unsubscribe();
}, [featuredContent]);
const handleSaveToLibrary = useCallback(async () => {
if (!featuredContent) return;
try {
if (isSaved) {
await catalogService.removeFromLibrary(featuredContent.type, featuredContent.id);
} else {
await catalogService.addToLibrary(featuredContent);
}
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} catch (error) {
console.error('Error updating library:', error);
}
}, [featuredContent, isSaved]);
const handleCategoryChange = (categoryId: string) => {
setSelectedCategory(categoryId);
};
const handleContentPress = useCallback((id: string, type: string) => { const handleContentPress = useCallback((id: string, type: string) => {
// Immediate navigation without any delays
navigation.navigate('Metadata', { id, type }); navigation.navigate('Metadata', { id, type });
}, [navigation]); }, [navigation]);
@ -659,22 +484,18 @@ const HomeScreen = () => {
}); });
}, [featuredContent, navigation]); }, [featuredContent, navigation]);
// Add a function to refresh the Continue Watching section
const refreshContinueWatching = useCallback(() => { const refreshContinueWatching = useCallback(() => {
if (continueWatchingRef.current) { if (continueWatchingRef.current) {
continueWatchingRef.current.refresh(); continueWatchingRef.current.refresh();
} }
}, []); }, []);
// Update the event listener for video playback completion
useEffect(() => { useEffect(() => {
const handlePlaybackComplete = () => { const handlePlaybackComplete = () => {
refreshContinueWatching(); refreshContinueWatching();
}; };
// Listen for playback complete events
const unsubscribe = navigation.addListener('focus', () => { const unsubscribe = navigation.addListener('focus', () => {
// When returning to HomeScreen, refresh Continue Watching
refreshContinueWatching(); refreshContinueWatching();
}); });
@ -690,8 +511,15 @@ const HomeScreen = () => {
return ( return (
<TouchableOpacity <TouchableOpacity
activeOpacity={0.8} activeOpacity={0.9}
onPress={handleSaveToLibrary} onPress={() => {
if (featuredContent) {
navigation.navigate('Metadata', {
id: featuredContent.id,
type: featuredContent.type
});
}
}}
style={styles.featuredContainer} style={styles.featuredContainer}
> >
<ImageBackground <ImageBackground
@ -702,14 +530,14 @@ const HomeScreen = () => {
<LinearGradient <LinearGradient
colors={[ colors={[
'transparent', 'transparent',
'rgba(0,0,0,0.2)', 'rgba(0,0,0,0.1)',
'rgba(0,0,0,0.8)', 'rgba(0,0,0,0.7)',
colors.darkBackground, colors.darkBackground,
]} ]}
locations={[0, 0.4, 0.7, 1]} locations={[0, 0.3, 0.7, 1]}
style={styles.featuredGradient} style={styles.featuredGradient}
> >
<Animated.View style={styles.featuredContentContainer} entering={FadeIn.duration(500)}> <Animated.View style={styles.featuredContentContainer} entering={FadeIn.duration(600)}>
{featuredContent.logo ? ( {featuredContent.logo ? (
<ExpoImage <ExpoImage
source={{ uri: featuredContent.logo }} source={{ uri: featuredContent.logo }}
@ -720,8 +548,13 @@ const HomeScreen = () => {
<Text style={styles.featuredTitleText}>{featuredContent.name}</Text> <Text style={styles.featuredTitleText}>{featuredContent.name}</Text>
)} )}
<View style={styles.genreContainer}> <View style={styles.genreContainer}>
{featuredContent.genres?.slice(0, 3).map((genre, index) => ( {featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
<Text key={index} style={styles.genreText}>{genre}</Text> <React.Fragment key={index}>
<Text style={styles.genreText}>{genre}</Text>
{index < array.length - 1 && (
<Text style={styles.genreDot}></Text>
)}
</React.Fragment>
))} ))}
</View> </View>
<View style={styles.featuredButtons}> <View style={styles.featuredButtons}>
@ -758,15 +591,10 @@ const HomeScreen = () => {
style={styles.infoButton} style={styles.infoButton}
onPress={async () => { onPress={async () => {
if (featuredContent) { if (featuredContent) {
// Convert TMDB ID to Stremio ID navigation.navigate('Metadata', {
const tmdbId = featuredContent.id.replace('tmdb:', ''); id: featuredContent.id,
const stremioId = await catalogService.getStremioId(featuredContent.type, tmdbId); type: featuredContent.type
if (stremioId) { });
navigation.navigate('Metadata', {
id: stremioId,
type: featuredContent.type
});
}
} }
}} }}
> >
@ -781,18 +609,25 @@ const HomeScreen = () => {
); );
}; };
const renderContentItem = useCallback(({ item }: { item: StreamingContent }) => { const renderContentItem = useCallback(({ item, index }: { item: StreamingContent, index: number }) => {
return ( return (
<ContentItem <Animated.View
item={item} entering={FadeIn.duration(300).delay(100 + (index * 40))}
onPress={handleContentPress} >
/> <ContentItem
item={item}
onPress={handleContentPress}
/>
</Animated.View>
); );
}, [handleContentPress]); }, [handleContentPress]);
const renderCatalog = ({ item }: { item: CatalogContent }) => { const renderCatalog = ({ item }: { item: CatalogContent }) => {
return ( return (
<View style={styles.catalogContainer}> <Animated.View
style={styles.catalogContainer}
entering={FadeIn.duration(400).delay(50)}
>
<View style={styles.catalogHeader}> <View style={styles.catalogHeader}>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={styles.catalogTitle}>{item.name}</Text> <Text style={styles.catalogTitle}>{item.name}</Text>
@ -820,30 +655,30 @@ const HomeScreen = () => {
<FlatList <FlatList
data={item.items} data={item.items}
renderItem={renderContentItem} renderItem={({ item, index }) => renderContentItem({ item, index })}
keyExtractor={(item) => `${item.id}-${item.type}`} keyExtractor={(item) => `${item.id}-${item.type}`}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.catalogList} contentContainerStyle={styles.catalogList}
snapToInterval={POSTER_WIDTH + 10} snapToInterval={POSTER_WIDTH + 12}
decelerationRate="fast" decelerationRate="fast"
snapToAlignment="start" snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 10 }} />} ItemSeparatorComponent={() => <View style={{ width: 12 }} />}
initialNumToRender={4} initialNumToRender={4}
maxToRenderPerBatch={4} maxToRenderPerBatch={4}
windowSize={5} windowSize={5}
removeClippedSubviews={Platform.OS === 'android'} removeClippedSubviews={Platform.OS === 'android'}
getItemLayout={(data, index) => ({ getItemLayout={(data, index) => ({
length: POSTER_WIDTH + 10, length: POSTER_WIDTH + 12,
offset: (POSTER_WIDTH + 10) * index, offset: (POSTER_WIDTH + 12) * index,
index, index,
})} })}
/> />
</View> </Animated.View>
); );
}; };
if (loading && !refreshing) { if (isLoading && !isRefreshing) {
return ( return (
<View style={[styles.container]}> <View style={[styles.container]}>
<StatusBar <StatusBar
@ -851,15 +686,10 @@ const HomeScreen = () => {
backgroundColor="transparent" backgroundColor="transparent"
translucent translucent
/> />
<ScrollView <View style={styles.loadingMainContainer}>
contentContainerStyle={styles.scrollContent} <ActivityIndicator size="large" color={colors.primary} />
showsVerticalScrollIndicator={false} <Text style={styles.loadingText}>Loading your content...</Text>
> </View>
<SkeletonFeatured />
{[1, 2, 3].map((_, index) => (
<SkeletonCatalog key={index} />
))}
</ScrollView>
</View> </View>
); );
} }
@ -873,38 +703,48 @@ const HomeScreen = () => {
/> />
<ScrollView <ScrollView
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.text} /> <RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colors.primary}
colors={[colors.primary, colors.secondary]}
/>
} }
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Featured Content */} {showHeroSection && renderFeaturedContent()}
{renderFeaturedContent()}
{/* This Week Section */} <Animated.View entering={FadeIn.duration(400).delay(150)}>
<ThisWeekSection /> <ThisWeekSection />
</Animated.View>
{/* Continue Watching Section */} <Animated.View entering={FadeIn.duration(400).delay(250)}>
<ContinueWatchingSection ref={continueWatchingRef} /> <ContinueWatchingSection ref={continueWatchingRef} />
</Animated.View>
{/* Catalogs */}
{catalogs.length > 0 ? ( {catalogs.length > 0 ? (
<FlatList catalogs.map((catalog, index) => (
data={catalogs} <View key={`${catalog.addon}-${catalog.id}-${index}`}>
renderItem={renderCatalog} {renderCatalog({ item: catalog })}
keyExtractor={(item, index) => `${item.addon}-${item.id}-${index}`} </View>
scrollEnabled={false} ))
removeClippedSubviews={false}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={5}
/>
) : ( ) : (
<View style={styles.emptyCatalog}> !catalogsLoading && (
<Text style={{ color: colors.textDark }}> <View style={styles.emptyCatalog}>
No content available. Pull down to refresh. <MaterialIcons name="movie-filter" size={40} color={colors.textDark} />
</Text> <Text style={{ color: colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
</View> No content available
</Text>
<TouchableOpacity
style={styles.addCatalogButton}
onPress={() => navigation.navigate('Settings')}
>
<MaterialIcons name="add-circle" size={20} color={colors.white} />
<Text style={styles.addCatalogButtonText}>Add Catalogs</Text>
</TouchableOpacity>
</View>
)
)} )}
</ScrollView> </ScrollView>
</View> </View>
@ -912,7 +752,7 @@ const HomeScreen = () => {
}; };
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
const POSTER_WIDTH = (width - 40) / 2.7; const POSTER_WIDTH = (width - 50) / 3;
const styles = StyleSheet.create<any>({ const styles = StyleSheet.create<any>({
container: { container: {
@ -920,7 +760,7 @@ const styles = StyleSheet.create<any>({
backgroundColor: colors.darkBackground, backgroundColor: colors.darkBackground,
}, },
scrollContent: { scrollContent: {
paddingBottom: 32, paddingBottom: 40,
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
@ -929,11 +769,10 @@ const styles = StyleSheet.create<any>({
}, },
featuredContainer: { featuredContainer: {
width: '100%', width: '100%',
height: height * 0.65, height: height * 0.6,
marginTop: 0, marginTop: Platform.OS === 'ios' ? 85 : 75,
marginBottom: 0, marginBottom: 8,
position: 'relative', position: 'relative',
paddingTop: 56,
}, },
featuredBanner: { featuredBanner: {
width: '100%', width: '100%',
@ -950,7 +789,7 @@ const styles = StyleSheet.create<any>({
alignItems: 'center', alignItems: 'center',
flex: 1, flex: 1,
justifyContent: 'flex-end', justifyContent: 'flex-end',
gap: 8, gap: 12,
}, },
featuredLogo: { featuredLogo: {
width: width * 0.7, width: width * 0.7,
@ -972,21 +811,22 @@ const styles = StyleSheet.create<any>({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 0, marginBottom: 16,
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 4, gap: 4,
}, },
genreText: { genreText: {
color: colors.white, color: colors.white,
fontSize: 13, fontSize: 14,
fontWeight: '500', fontWeight: '500',
opacity: 0.9, opacity: 0.9,
}, },
genreDot: { genreDot: {
color: colors.white, color: colors.white,
fontSize: 13, fontSize: 14,
marginHorizontal: 4, fontWeight: '500',
opacity: 0.6, opacity: 0.6,
marginHorizontal: 4,
}, },
featuredButtons: { featuredButtons: {
flexDirection: 'row', flexDirection: 'row',
@ -994,16 +834,16 @@ const styles = StyleSheet.create<any>({
justifyContent: 'space-evenly', justifyContent: 'space-evenly',
width: '100%', width: '100%',
flex: 1, flex: 1,
maxHeight: 60, maxHeight: 65,
paddingTop: 12, paddingTop: 16,
}, },
playButton: { playButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 14, paddingVertical: 14,
paddingHorizontal: 24, paddingHorizontal: 32,
borderRadius: 100, borderRadius: 30,
backgroundColor: colors.white, backgroundColor: colors.white,
elevation: 4, elevation: 4,
shadowColor: '#000', shadowColor: '#000',
@ -1019,8 +859,8 @@ const styles = StyleSheet.create<any>({
alignItems: 'center', alignItems: 'center',
padding: 0, padding: 0,
gap: 6, gap: 6,
width: 40, width: 44,
height: 41, height: 44,
flex: null, flex: null,
}, },
infoButton: { infoButton: {
@ -1029,8 +869,8 @@ const styles = StyleSheet.create<any>({
alignItems: 'center', alignItems: 'center',
padding: 0, padding: 0,
gap: 4, gap: 4,
width: 40, width: 44,
height: 39, height: 44,
flex: null, flex: null,
}, },
playButtonText: { playButtonText: {
@ -1052,14 +892,14 @@ const styles = StyleSheet.create<any>({
catalogContainer: { catalogContainer: {
marginBottom: 24, marginBottom: 24,
paddingTop: 0, paddingTop: 0,
marginTop: 12, marginTop: 16,
}, },
catalogHeader: { catalogHeader: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
marginBottom: 8, marginBottom: 12,
}, },
titleContainer: { titleContainer: {
position: 'relative', position: 'relative',
@ -1096,14 +936,14 @@ const styles = StyleSheet.create<any>({
}, },
catalogList: { catalogList: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 8, paddingBottom: 12,
paddingTop: 4, paddingTop: 6,
}, },
contentItem: { contentItem: {
width: POSTER_WIDTH, width: POSTER_WIDTH,
aspectRatio: 2/3, aspectRatio: 2/3,
margin: 0, margin: 0,
borderRadius: 12, borderRadius: 16,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
elevation: 8, elevation: 8,
@ -1112,12 +952,12 @@ const styles = StyleSheet.create<any>({
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 8, shadowRadius: 8,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)', borderColor: 'rgba(255,255,255,0.08)',
}, },
poster: { poster: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 12, borderRadius: 16,
}, },
imdbLogo: { imdbLogo: {
width: 35, width: 35,
@ -1147,7 +987,7 @@ const styles = StyleSheet.create<any>({
}, },
skeletonBox: { skeletonBox: {
backgroundColor: colors.elevation2, backgroundColor: colors.elevation2,
borderRadius: 12, borderRadius: 16,
overflow: 'hidden', overflow: 'hidden',
}, },
skeletonFeatured: { skeletonFeatured: {
@ -1161,12 +1001,12 @@ const styles = StyleSheet.create<any>({
skeletonPoster: { skeletonPoster: {
backgroundColor: colors.elevation1, backgroundColor: colors.elevation1,
marginHorizontal: 4, marginHorizontal: 4,
borderRadius: 12, borderRadius: 16,
}, },
contentItemContainer: { contentItemContainer: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 12, borderRadius: 16,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
}, },
@ -1197,11 +1037,11 @@ const styles = StyleSheet.create<any>({
borderRadius: 2, borderRadius: 2,
alignSelf: 'center', alignSelf: 'center',
marginTop: 12, marginTop: 12,
marginBottom: 8, marginBottom: 10,
}, },
menuContainer: { menuContainer: {
borderTopLeftRadius: 16, borderTopLeftRadius: 24,
borderTopRightRadius: 16, borderTopRightRadius: 24,
paddingBottom: Platform.select({ ios: 40, android: 24 }), paddingBottom: Platform.select({ ios: 40, android: 24 }),
...Platform.select({ ...Platform.select({
ios: { ios: {
@ -1224,7 +1064,7 @@ const styles = StyleSheet.create<any>({
menuPoster: { menuPoster: {
width: 60, width: 60,
height: 90, height: 90,
borderRadius: 8, borderRadius: 12,
}, },
menuTitleContainer: { menuTitleContainer: {
flex: 1, flex: 1,
@ -1280,7 +1120,7 @@ const styles = StyleSheet.create<any>({
backgroundColor: 'rgba(0,0,0,0.5)', backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderRadius: 12, borderRadius: 16,
}, },
featuredImage: { featuredImage: {
width: '100%', width: '100%',
@ -1289,6 +1129,8 @@ const styles = StyleSheet.create<any>({
featuredContentContainer: { featuredContentContainer: {
flex: 1, flex: 1,
justifyContent: 'flex-end', justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingBottom: 20,
}, },
featuredTitleText: { featuredTitleText: {
color: colors.highEmphasis, color: colors.highEmphasis,
@ -1301,6 +1143,51 @@ const styles = StyleSheet.create<any>({
textAlign: 'center', textAlign: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
addCatalogButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primary,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 30,
marginTop: 16,
elevation: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 3,
},
addCatalogButtonText: {
color: colors.white,
fontSize: 14,
fontWeight: '600',
marginLeft: 8,
},
loadingMainContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingBottom: 40,
},
loadingText: {
color: colors.textMuted,
marginTop: 12,
fontSize: 14,
},
loadingPlaceholder: {
height: 200,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.elevation1,
borderRadius: 12,
marginHorizontal: 16,
},
featuredLoadingContainer: {
height: height * 0.4,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.elevation1,
},
}); });
export default HomeScreen; export default HomeScreen;

View file

@ -0,0 +1,472 @@
import React, { useCallback, useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Switch,
ScrollView,
SafeAreaView,
StatusBar,
Platform,
useColorScheme,
Animated
} from 'react-native';
import { useSettings } from '../hooks/useSettings';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles/colors';
import { RootStackParamList } from '../navigation/AppNavigator';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
interface SettingsCardProps {
children: React.ReactNode;
isDarkMode: boolean;
}
const SettingsCard: React.FC<SettingsCardProps> = ({ children, isDarkMode }) => (
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
{children}
</View>
);
// Restrict icon names to those available in MaterialIcons
type MaterialIconName = React.ComponentProps<typeof MaterialIcons>['name'];
interface SettingItemProps {
title: string;
description?: string;
icon: MaterialIconName;
renderControl: () => React.ReactNode;
isLast?: boolean;
onPress?: () => void;
isDarkMode: boolean;
}
const SettingItem: React.FC<SettingItemProps> = ({
title,
description,
icon,
renderControl,
isLast = false,
onPress,
isDarkMode
}) => {
return (
<TouchableOpacity
activeOpacity={onPress ? 0.7 : 1}
onPress={onPress}
style={[
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
]}
>
<View style={styles.settingIconContainer}>
<MaterialIcons name={icon} size={22} color={colors.primary} />
</View>
<View style={styles.settingContent}>
<View style={styles.settingTitleRow}>
<Text style={[styles.settingTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
{title}
</Text>
{description && (
<Text style={[styles.settingDescription, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
{description}
</Text>
)}
</View>
</View>
<View style={styles.settingControl}>
{renderControl()}
</View>
</TouchableOpacity>
);
};
const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ title, isDarkMode }) => (
<View style={styles.sectionHeader}>
<Text style={[
styles.sectionHeaderText,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
{title}
</Text>
</View>
);
const HomeScreenSettings: React.FC = () => {
const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme();
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const [showSavedIndicator, setShowSavedIndicator] = useState(false);
const fadeAnim = React.useRef(new Animated.Value(0)).current;
const handleBack = useCallback(() => {
navigation.goBack();
}, [navigation]);
// Fade in/out animation for the "Changes saved" indicator
useEffect(() => {
if (showSavedIndicator) {
Animated.sequence([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true
}),
Animated.delay(1000),
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true
})
]).start(() => setShowSavedIndicator(false));
}
}, [showSavedIndicator, fadeAnim]);
const handleUpdateSetting = useCallback(<K extends keyof typeof settings>(
key: K,
value: typeof settings[K]
) => {
updateSetting(key, value);
setShowSavedIndicator(true);
}, [updateSetting]);
const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => (
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', true: colors.primary }}
thumbColor={Platform.OS === 'android' ? (value ? colors.white : colors.white) : ''}
ios_backgroundColor={isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
/>
);
// Radio button component for content source selection
const RadioOption = ({ selected, onPress, label }: { selected: boolean, onPress: () => void, label: string }) => (
<TouchableOpacity
style={styles.radioOption}
onPress={onPress}
activeOpacity={0.7}
>
<View style={styles.radioContainer}>
<View style={[
styles.radio,
{ borderColor: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
{selected && <View style={styles.radioInner} />}
</View>
<Text style={[
styles.radioLabel,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
{label}
</Text>
</View>
</TouchableOpacity>
);
// Format selected catalogs text
const getSelectedCatalogsText = useCallback(() => {
if (!settings.selectedHeroCatalogs || settings.selectedHeroCatalogs.length === 0) {
return "All catalogs";
} else {
return `${settings.selectedHeroCatalogs.length} selected`;
}
}, [settings.selectedHeroCatalogs]);
const ChevronRight = () => (
<MaterialIcons
name="chevron-right"
size={24}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
);
return (
<SafeAreaView style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
/>
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Home Screen Settings
</Text>
</View>
{/* Saved indicator */}
<Animated.View
style={[
styles.savedIndicator,
{
opacity: fadeAnim,
backgroundColor: isDarkMode ? 'rgba(0, 180, 150, 0.9)' : 'rgba(0, 180, 150, 0.9)'
}
]}
pointerEvents="none"
>
<MaterialIcons name="check-circle" size={20} color="#FFFFFF" />
<Text style={styles.savedIndicatorText}>Changes Applied</Text>
</Animated.View>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<SectionHeader title="DISPLAY OPTIONS" isDarkMode={isDarkMode} />
<SettingsCard isDarkMode={isDarkMode}>
<SettingItem
title="Show Hero Section"
description="Featured content at the top"
icon="movie-filter"
isDarkMode={isDarkMode}
renderControl={() => (
<CustomSwitch
value={settings.showHeroSection}
onValueChange={(value) => handleUpdateSetting('showHeroSection', value)}
/>
)}
/>
<SettingItem
title="Featured Content Source"
description={settings.featuredContentSource === 'tmdb' ? 'TMDB Trending' : 'From Catalogs'}
icon="settings-input-component"
isDarkMode={isDarkMode}
renderControl={() => <View />}
/>
{settings.featuredContentSource === 'catalogs' && (
<SettingItem
title="Select Catalogs"
description={getSelectedCatalogsText()}
icon="list"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('HeroCatalogs')}
isLast={true}
/>
)}
{settings.featuredContentSource !== 'catalogs' && (
<View style={{ height: 0 }} /> // Placeholder to maintain layout
)}
</SettingsCard>
{settings.showHeroSection && (
<>
<View style={styles.radioCardContainer}>
<RadioOption
selected={settings.featuredContentSource === 'tmdb'}
onPress={() => handleUpdateSetting('featuredContentSource', 'tmdb')}
label="TMDB Trending Movies"
/>
<View style={styles.radioDescription}>
<Text style={[styles.radioDescriptionText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
Featured content will be sourced from TMDB's trending movies API. This provides a variety of popular and recent content, even if not available in your catalogs.
</Text>
</View>
</View>
<View style={styles.radioCardContainer}>
<RadioOption
selected={settings.featuredContentSource === 'catalogs'}
onPress={() => handleUpdateSetting('featuredContentSource', 'catalogs')}
label="Installed Catalogs"
/>
<View style={styles.radioDescription}>
<Text style={[styles.radioDescriptionText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
Featured content will be sourced from your enabled catalogs. This ensures that featured content is available to stream from your installed add-ons.
</Text>
</View>
</View>
</>
)}
<SectionHeader title="ABOUT THESE SETTINGS" isDarkMode={isDarkMode} />
<View style={[styles.infoCard, { backgroundColor: isDarkMode ? colors.elevation1 : 'rgba(0,0,0,0.03)' }]}>
<Text style={[styles.infoText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
These settings control how content is displayed on your Home screen. Changes are applied immediately without requiring an app restart.
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8,
},
backButton: {
marginRight: 16,
padding: 4,
},
headerTitle: {
fontSize: 22,
fontWeight: '700',
letterSpacing: 0.5,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 32,
},
sectionHeader: {
paddingHorizontal: 16,
paddingTop: 20,
paddingBottom: 8,
},
sectionHeaderText: {
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.8,
},
card: {
marginHorizontal: 16,
borderRadius: 12,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 0.5,
minHeight: 44,
},
settingItemBorder: {
// Border styling handled directly in the component with borderBottomWidth
},
settingIconContainer: {
marginRight: 12,
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
},
settingContent: {
flex: 1,
marginRight: 8,
},
settingTitleRow: {
flexDirection: 'column',
justifyContent: 'center',
gap: 4,
},
settingTitle: {
fontSize: 16,
fontWeight: '500',
},
settingDescription: {
fontSize: 14,
opacity: 0.7,
},
settingControl: {
justifyContent: 'center',
alignItems: 'center',
paddingLeft: 12,
},
radioCardContainer: {
marginHorizontal: 16,
marginVertical: 8,
borderRadius: 12,
backgroundColor: colors.elevation1,
overflow: 'hidden',
},
radioOption: {
padding: 16,
},
radioContainer: {
flexDirection: 'row',
alignItems: 'center',
},
radio: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
marginRight: 10,
justifyContent: 'center',
alignItems: 'center',
},
radioInner: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.primary,
},
radioLabel: {
fontSize: 16,
fontWeight: '500',
},
radioDescription: {
paddingHorizontal: 16,
paddingBottom: 16,
paddingTop: 0,
},
radioDescriptionText: {
fontSize: 14,
lineHeight: 20,
},
infoCard: {
marginHorizontal: 16,
marginTop: 8,
padding: 16,
borderRadius: 12,
},
infoText: {
fontSize: 14,
lineHeight: 20,
},
savedIndicator: {
position: 'absolute',
top: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 60 : 90,
alignSelf: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
savedIndicatorText: {
color: '#FFFFFF',
marginLeft: 6,
fontWeight: '600',
},
});
export default HomeScreenSettings;

View file

@ -19,6 +19,7 @@ import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles'; import { colors } from '../styles';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { catalogService } from '../services/catalogService'; import { catalogService } from '../services/catalogService';
import type { StreamingContent } from '../services/catalogService'; import type { StreamingContent } from '../services/catalogService';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
@ -81,7 +82,7 @@ const SkeletonLoader = () => {
return ( return (
<View style={styles.skeletonContainer}> <View style={styles.skeletonContainer}>
{[...Array(6)].map((_, index) => ( {[...Array(6)].map((_, index) => (
<View key={index} style={{ width: itemWidth }}> <View key={index} style={{ width: itemWidth, margin: 8 }}>
{renderSkeletonItem()} {renderSkeletonItem()}
</View> </View>
))} ))}
@ -135,13 +136,32 @@ const LibraryScreen = () => {
<TouchableOpacity <TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]} style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })} onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
activeOpacity={0.7}
> >
<View style={styles.posterContainer}> <View style={styles.posterContainer}>
<Image <Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster} style={styles.poster}
contentFit="cover" contentFit="cover"
transition={300}
/> />
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.85)']}
style={styles.posterGradient}
>
<Text
style={styles.itemTitle}
numberOfLines={2}
>
{item.name}
</Text>
{item.lastWatched && (
<Text style={styles.lastWatched}>
{item.lastWatched}
</Text>
)}
</LinearGradient>
{item.progress !== undefined && item.progress < 1 && ( {item.progress !== undefined && item.progress < 1 && (
<View style={styles.progressBarContainer}> <View style={styles.progressBarContainer}>
<View <View
@ -156,7 +176,7 @@ const LibraryScreen = () => {
<View style={styles.badgeContainer}> <View style={styles.badgeContainer}>
<MaterialIcons <MaterialIcons
name="live-tv" name="live-tv"
size={12} size={14}
color={colors.white} color={colors.white}
style={{ marginRight: 4 }} style={{ marginRight: 4 }}
/> />
@ -164,17 +184,6 @@ const LibraryScreen = () => {
</View> </View>
)} )}
</View> </View>
<Text
style={[styles.itemTitle, { color: isDarkMode ? colors.white : colors.black }]}
numberOfLines={2}
>
{item.name}
</Text>
{item.lastWatched && (
<Text style={[styles.lastWatched, { color: isDarkMode ? colors.lightGray : colors.mediumGray }]}>
{item.lastWatched}
</Text>
)}
</TouchableOpacity> </TouchableOpacity>
); );
@ -185,25 +194,21 @@ const LibraryScreen = () => {
style={[ style={[
styles.filterButton, styles.filterButton,
isActive && styles.filterButtonActive, isActive && styles.filterButtonActive,
{
borderColor: isDarkMode ? 'rgba(255,255,255,0.3)' : colors.border,
backgroundColor: isDarkMode && !isActive ? 'rgba(255,255,255,0.15)' : 'transparent'
}
]} ]}
onPress={() => setFilter(filterType)} onPress={() => setFilter(filterType)}
activeOpacity={0.7}
> >
<MaterialIcons <MaterialIcons
name={iconName} name={iconName}
size={20} size={22}
color={isActive ? colors.primary : (isDarkMode ? colors.white : colors.mediumGray)} color={isActive ? colors.white : colors.mediumGray}
style={styles.filterIcon} style={styles.filterIcon}
/> />
<Text <Text
style={{ style={[
fontSize: 14, styles.filterText,
fontWeight: isActive ? '600' : '500', isActive && styles.filterTextActive
color: isActive ? colors.primary : colors.white ]}
}}
> >
{label} {label}
</Text> </Text>
@ -212,10 +217,11 @@ const LibraryScreen = () => {
}; };
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.black }]}> <SafeAreaView style={styles.container}>
<StatusBar <StatusBar
barStyle="light-content" barStyle="light-content"
backgroundColor={colors.black} backgroundColor="transparent"
translucent
/> />
<View style={styles.header}> <View style={styles.header}>
@ -236,21 +242,21 @@ const LibraryScreen = () => {
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<MaterialIcons <MaterialIcons
name="video-library" name="video-library"
size={64} size={80}
color={isDarkMode ? colors.lightGray : colors.mediumGray} color={colors.mediumGray}
style={{ opacity: 0.7 }}
/> />
<Text style={[ <Text style={styles.emptyText}>Your library is empty</Text>
styles.emptyText, <Text style={styles.emptySubtext}>
{ color: isDarkMode ? colors.white : colors.black } Add content to your library to keep track of what you're watching
]}>
Your library is empty
</Text>
<Text style={[
styles.emptySubtext,
{ color: isDarkMode ? colors.lightGray : colors.mediumGray }
]}>
Add items to your library by marking them as favorites
</Text> </Text>
<TouchableOpacity
style={styles.exploreButton}
onPress={() => navigation.navigate('Discover')}
activeOpacity={0.7}
>
<Text style={styles.exploreButtonText}>Explore Content</Text>
</TouchableOpacity>
</View> </View>
) : ( ) : (
<FlatList <FlatList
@ -258,8 +264,13 @@ const LibraryScreen = () => {
renderItem={renderItem} renderItem={renderItem}
keyExtractor={item => item.id} keyExtractor={item => item.id}
numColumns={2} numColumns={2}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.columnWrapper}
initialNumToRender={6}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/> />
)} )}
</SafeAreaView> </SafeAreaView>
@ -269,14 +280,12 @@ const LibraryScreen = () => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.darkBackground,
}, },
header: { header: {
paddingHorizontal: 16, paddingHorizontal: 20,
paddingVertical: 12, paddingVertical: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 4, paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
backgroundColor: colors.darkBackground,
}, },
headerContent: { headerContent: {
flexDirection: 'row', flexDirection: 'row',
@ -287,90 +296,94 @@ const styles = StyleSheet.create({
fontSize: 32, fontSize: 32,
fontWeight: '800', fontWeight: '800',
color: colors.white, color: colors.white,
letterSpacing: 0.5, letterSpacing: 0.3,
}, },
filtersContainer: { filtersContainer: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingBottom: 16,
gap: 12, paddingTop: 8,
backgroundColor: colors.black, borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.05)',
zIndex: 10,
}, },
filterButton: { filterButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 8, marginHorizontal: 4,
borderRadius: 20, borderRadius: 24,
borderWidth: 1, backgroundColor: 'rgba(255,255,255,0.05)',
borderColor: colors.darkGray, shadowColor: colors.black,
backgroundColor: 'transparent', shadowOffset: { width: 0, height: 2 },
gap: 6, shadowOpacity: 0.1,
minWidth: 100, shadowRadius: 4,
justifyContent: 'center', elevation: 2,
}, },
filterButtonActive: { filterButtonActive: {
backgroundColor: colors.primary + '20', backgroundColor: colors.primary,
borderColor: colors.primary,
}, },
filterIcon: { filterIcon: {
marginRight: 2, marginRight: 8,
}, },
filterText: { filterText: {
fontSize: 14, fontSize: 15,
fontWeight: '500', fontWeight: '500',
color: colors.mediumGray,
}, },
filterTextActive: { filterTextActive: {
color: colors.primary,
fontWeight: '600', fontWeight: '600',
color: colors.white,
}, },
listContent: { listContainer: {
paddingHorizontal: 8, paddingHorizontal: 12,
paddingVertical: 16,
},
columnWrapper: {
justifyContent: 'space-between',
marginBottom: 16,
},
skeletonContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
paddingTop: 16, paddingTop: 16,
paddingBottom: 32, justifyContent: 'space-between',
alignItems: 'flex-start',
}, },
itemContainer: { itemContainer: {
marginHorizontal: 8, marginBottom: 16,
marginBottom: 24,
}, },
posterContainer: { posterContainer: {
position: 'relative', borderRadius: 16,
borderRadius: 12,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.03)',
aspectRatio: 2/3, aspectRatio: 2/3,
marginBottom: 8, elevation: 5,
backgroundColor: colors.darkBackground, shadowColor: colors.black,
elevation: 4, shadowOffset: { width: 0, height: 4 },
shadowColor: '#000', shadowOpacity: 0.2,
shadowOffset: { shadowRadius: 8,
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
}, },
poster: { poster: {
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
itemTitle: { posterGradient: {
fontSize: 14, position: 'absolute',
fontWeight: '600', bottom: 0,
marginBottom: 4, left: 0,
lineHeight: 20, right: 0,
}, padding: 16,
lastWatched: { justifyContent: 'flex-end',
fontSize: 12, height: '45%',
lineHeight: 16,
opacity: 0.7,
}, },
progressBarContainer: { progressBarContainer: {
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
height: 3, height: 4,
backgroundColor: 'rgba(0,0,0,0.5)', backgroundColor: 'rgba(0,0,0,0.5)',
}, },
progressBar: { progressBar: {
@ -379,9 +392,9 @@ const styles = StyleSheet.create({
}, },
badgeContainer: { badgeContainer: {
position: 'absolute', position: 'absolute',
top: 8, top: 10,
right: 8, right: 10,
backgroundColor: 'rgba(0,0,0,0.75)', backgroundColor: 'rgba(0,0,0,0.7)',
borderRadius: 12, borderRadius: 12,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 4,
@ -390,9 +403,31 @@ const styles = StyleSheet.create({
}, },
badgeText: { badgeText: {
color: colors.white, color: colors.white,
fontSize: 12, fontSize: 10,
fontWeight: '600', fontWeight: '600',
}, },
itemTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
marginBottom: 4,
textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
letterSpacing: 0.3,
},
lastWatched: {
fontSize: 12,
color: 'rgba(255,255,255,0.7)',
textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
skeletonTitle: {
height: 14,
marginTop: 8,
borderRadius: 4,
},
emptyContainer: { emptyContainer: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
@ -400,30 +435,34 @@ const styles = StyleSheet.create({
paddingHorizontal: 32, paddingHorizontal: 32,
}, },
emptyText: { emptyText: {
fontSize: 18, fontSize: 20,
fontWeight: 'bold', fontWeight: '700',
color: colors.white,
marginTop: 16, marginTop: 16,
marginBottom: 8, marginBottom: 8,
textAlign: 'center',
}, },
emptySubtext: { emptySubtext: {
fontSize: 14, fontSize: 15,
color: colors.mediumGray,
textAlign: 'center', textAlign: 'center',
lineHeight: 20, marginBottom: 24,
opacity: 0.7,
}, },
skeletonContainer: { exploreButton: {
padding: 16, backgroundColor: colors.primary,
flexDirection: 'row', paddingVertical: 12,
flexWrap: 'wrap', paddingHorizontal: 24,
justifyContent: 'space-between', borderRadius: 24,
}, elevation: 3,
skeletonTitle: { shadowColor: colors.black,
height: 20, shadowOffset: { width: 0, height: 2 },
borderRadius: 4, shadowOpacity: 0.2,
marginTop: 8, shadowRadius: 4,
width: '80%',
}, },
exploreButtonText: {
color: colors.white,
fontSize: 16,
fontWeight: '600',
}
}); });
export default LibraryScreen; export default LibraryScreen;

View file

@ -0,0 +1,824 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TextInput,
SafeAreaView,
StatusBar,
Platform,
Alert,
ActivityIndicator,
Linking,
ScrollView,
Keyboard,
Clipboard,
Switch,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors } from '../styles/colors';
import { logger } from '../utils/logger';
import { RATING_PROVIDERS } from '../components/metadata/RatingsSection';
export const MDBLIST_API_KEY_STORAGE_KEY = 'mdblist_api_key';
export const RATING_PROVIDERS_STORAGE_KEY = 'rating_providers_config';
export const MDBLIST_ENABLED_STORAGE_KEY = 'mdblist_enabled';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Function to check if MDBList is enabled
export const isMDBListEnabled = async (): Promise<boolean> => {
try {
const enabledSetting = await AsyncStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY);
return enabledSetting === null || enabledSetting === 'true';
} catch (error) {
logger.error('[MDBList] Error checking if MDBList is enabled:', error);
return true; // Default to enabled if there's an error
}
};
// Function to get MDBList API key if enabled
export const getMDBListAPIKey = async (): Promise<string | null> => {
try {
const isEnabled = await isMDBListEnabled();
if (!isEnabled) {
logger.log('[MDBList] MDBList is disabled, not retrieving API key');
return null;
}
return await AsyncStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY);
} catch (error) {
logger.error('[MDBList] Error retrieving API key:', error);
return null;
}
};
const MDBListSettingsScreen = () => {
const navigation = useNavigation();
const [apiKey, setApiKey] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isKeySet, setIsKeySet] = useState(false);
const [isMdbListEnabled, setIsMdbListEnabled] = useState(true);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [isInputFocused, setIsInputFocused] = useState(false);
const [enabledProviders, setEnabledProviders] = useState<Record<string, boolean>>({});
const apiKeyInputRef = useRef<TextInput>(null);
useEffect(() => {
logger.log('[MDBListSettingsScreen] Component mounted');
loadApiKey();
loadProviderSettings();
loadMdbListEnabledSetting();
return () => {
logger.log('[MDBListSettingsScreen] Component unmounted');
};
}, []);
const loadMdbListEnabledSetting = async () => {
logger.log('[MDBListSettingsScreen] Loading MDBList enabled setting');
try {
const savedSetting = await AsyncStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY);
if (savedSetting !== null) {
setIsMdbListEnabled(savedSetting === 'true');
logger.log('[MDBListSettingsScreen] MDBList enabled setting:', savedSetting === 'true');
} else {
// Default to enabled if no setting found
setIsMdbListEnabled(true);
await AsyncStorage.setItem(MDBLIST_ENABLED_STORAGE_KEY, 'true');
logger.log('[MDBListSettingsScreen] MDBList enabled setting not found, defaulting to true');
}
} catch (error) {
logger.error('[MDBListSettingsScreen] Failed to load MDBList enabled setting:', error);
setIsMdbListEnabled(true);
}
};
const toggleMdbListEnabled = async () => {
logger.log('[MDBListSettingsScreen] Toggling MDBList enabled setting');
try {
const newValue = !isMdbListEnabled;
setIsMdbListEnabled(newValue);
await AsyncStorage.setItem(MDBLIST_ENABLED_STORAGE_KEY, newValue.toString());
logger.log('[MDBListSettingsScreen] MDBList enabled set to:', newValue);
} catch (error) {
logger.error('[MDBListSettingsScreen] Failed to save MDBList enabled setting:', error);
}
};
const loadApiKey = async () => {
logger.log('[MDBListSettingsScreen] Loading API key from storage');
try {
const savedKey = await AsyncStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY);
logger.log('[MDBListSettingsScreen] API key status:', savedKey ? 'Found' : 'Not found');
if (savedKey) {
setApiKey(savedKey);
setIsKeySet(true);
} else {
setIsKeySet(false);
}
} catch (error) {
logger.error('[MDBListSettingsScreen] Failed to load API key:', error);
setIsKeySet(false);
} finally {
setIsLoading(false);
logger.log('[MDBListSettingsScreen] Finished loading API key');
}
};
const loadProviderSettings = async () => {
try {
const savedSettings = await AsyncStorage.getItem(RATING_PROVIDERS_STORAGE_KEY);
if (savedSettings) {
setEnabledProviders(JSON.parse(savedSettings));
} else {
// Default all providers to enabled
const defaultSettings = Object.keys(RATING_PROVIDERS).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as Record<string, boolean>);
setEnabledProviders(defaultSettings);
await AsyncStorage.setItem(RATING_PROVIDERS_STORAGE_KEY, JSON.stringify(defaultSettings));
}
} catch (error) {
logger.error('[MDBListSettingsScreen] Failed to load provider settings:', error);
}
};
const toggleProvider = async (providerId: string) => {
try {
const newSettings = {
...enabledProviders,
[providerId]: !enabledProviders[providerId]
};
setEnabledProviders(newSettings);
await AsyncStorage.setItem(RATING_PROVIDERS_STORAGE_KEY, JSON.stringify(newSettings));
} catch (error) {
logger.error('[MDBListSettingsScreen] Failed to save provider settings:', error);
}
};
const saveApiKey = async () => {
logger.log('[MDBListSettingsScreen] Starting API key save');
Keyboard.dismiss();
try {
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
logger.warn('[MDBListSettingsScreen] Empty API key provided');
setTestResult({ success: false, message: 'API Key cannot be empty.' });
return;
}
logger.log('[MDBListSettingsScreen] Saving API key');
await AsyncStorage.setItem(MDBLIST_API_KEY_STORAGE_KEY, trimmedKey);
setIsKeySet(true);
setTestResult({ success: true, message: 'API key saved successfully.' });
logger.log('[MDBListSettingsScreen] API key saved successfully');
} catch (error) {
logger.error('[MDBListSettingsScreen] Error saving API key:', error);
setTestResult({
success: false,
message: 'An error occurred while saving. Please try again.'
});
}
};
const clearApiKey = async () => {
logger.log('[MDBListSettingsScreen] Clear API key requested');
Alert.alert(
'Clear API Key',
'Are you sure you want to remove the saved API key?',
[
{
text: 'Cancel',
style: 'cancel',
onPress: () => logger.log('[MDBListSettingsScreen] Clear API key cancelled')
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
logger.log('[MDBListSettingsScreen] Proceeding with API key clear');
try {
await AsyncStorage.removeItem(MDBLIST_API_KEY_STORAGE_KEY);
setApiKey('');
setIsKeySet(false);
setTestResult(null);
logger.log('[MDBListSettingsScreen] API key cleared successfully');
} catch (error) {
logger.error('[MDBListSettingsScreen] Failed to clear API key:', error);
Alert.alert('Error', 'Failed to clear API key');
}
}
}
]
);
};
const pasteFromClipboard = async () => {
logger.log('[MDBListSettingsScreen] Attempting to paste from clipboard');
try {
const clipboardContent = await Clipboard.getString();
if (clipboardContent) {
logger.log('[MDBListSettingsScreen] Content pasted from clipboard');
setApiKey(clipboardContent);
setTestResult(null);
} else {
logger.warn('[MDBListSettingsScreen] No content in clipboard');
}
} catch (error) {
logger.error('[MDBListSettingsScreen] Error pasting from clipboard:', error);
}
};
const openMDBListWebsite = () => {
logger.log('[MDBListSettingsScreen] Opening MDBList website');
Linking.openURL('https://mdblist.com/settings').catch(error => {
logger.error('[MDBListSettingsScreen] Error opening website:', error);
});
};
if (isLoading) {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading Settings...</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.primary} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>Rating Sources</Text>
<ScrollView
style={styles.content}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.statusCard}>
<MaterialIcons
name={isKeySet && isMdbListEnabled ? "check-circle" : "error-outline"}
size={28}
color={isKeySet && isMdbListEnabled ? colors.success : colors.warning}
style={styles.statusIcon}
/>
<View style={styles.statusTextContainer}>
<Text style={styles.statusTitle}>
{!isMdbListEnabled
? "MDBList Disabled"
: isKeySet
? "API Key Active"
: "API Key Required"}
</Text>
<Text style={styles.statusDescription}>
{!isMdbListEnabled
? "MDBList functionality is currently disabled."
: isKeySet
? "Ratings from MDBList are enabled."
: "Add your key below to enable ratings."}
</Text>
</View>
</View>
<View style={styles.card}>
<View style={styles.masterToggleContainer}>
<View style={styles.masterToggleInfo}>
<Text style={styles.masterToggleTitle}>Enable MDBList</Text>
<Text style={styles.masterToggleDescription}>
Turn on/off all MDBList functionality
</Text>
</View>
<Switch
value={isMdbListEnabled}
onValueChange={toggleMdbListEnabled}
trackColor={{ false: colors.elevation1, true: colors.primary + '50' }}
thumbColor={isMdbListEnabled ? colors.primary : colors.mediumGray}
/>
</View>
</View>
<View style={[styles.card, !isMdbListEnabled && styles.disabledCard]}>
<Text style={styles.sectionTitle}>API Key</Text>
<View style={[styles.inputWrapper, !isMdbListEnabled && styles.disabledInput]}>
<TextInput
ref={apiKeyInputRef}
style={[
styles.input,
isInputFocused && styles.inputFocused,
!isMdbListEnabled && styles.disabledText
]}
value={apiKey}
onChangeText={(text) => {
setApiKey(text);
if (testResult) setTestResult(null);
}}
placeholder="Paste your MDBList API key"
placeholderTextColor={!isMdbListEnabled ? colors.darkGray : colors.mediumGray}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
editable={isMdbListEnabled}
/>
<TouchableOpacity
style={styles.pasteButton}
onPress={pasteFromClipboard}
disabled={!isMdbListEnabled}
>
<MaterialIcons
name="content-paste"
size={20}
color={!isMdbListEnabled ? colors.darkGray : colors.primary}
/>
</TouchableOpacity>
</View>
{testResult && (
<View style={[
styles.testResultContainer,
testResult.success ? styles.testResultSuccess : styles.testResultError
]}>
<MaterialIcons
name={testResult.success ? "check" : "warning"}
size={18}
color={testResult.success ? colors.success : colors.error}
/>
<Text style={styles.testResultText}>
{testResult.message}
</Text>
</View>
)}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.saveButton,
(!apiKey.trim() || !isMdbListEnabled) && styles.saveButtonDisabled
]}
onPress={saveApiKey}
disabled={!apiKey.trim() || !isMdbListEnabled}
>
<MaterialIcons name="save" size={18} color={colors.white} style={styles.buttonIcon} />
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
{isKeySet && (
<TouchableOpacity
style={[styles.clearButton, !isMdbListEnabled && styles.clearButtonDisabled]}
onPress={clearApiKey}
disabled={!isMdbListEnabled}
>
<MaterialIcons
name="delete-outline"
size={18}
color={!isMdbListEnabled ? colors.darkGray : colors.error}
style={styles.buttonIcon}
/>
<Text style={[
styles.clearButtonText,
!isMdbListEnabled && styles.clearButtonTextDisabled
]}>
Clear Key
</Text>
</TouchableOpacity>
)}
</View>
</View>
<View style={[styles.card, !isMdbListEnabled && styles.disabledCard]}>
<Text style={styles.sectionTitle}>Rating Providers</Text>
<Text style={styles.sectionDescription}>
Choose which ratings to display in the app
</Text>
{Object.entries(RATING_PROVIDERS).map(([id, provider]) => (
<View key={id} style={styles.providerItem}>
<View style={styles.providerInfo}>
<Text style={[
styles.providerName,
!isMdbListEnabled && styles.disabledText
]}>
{provider.name}
</Text>
</View>
<Switch
value={enabledProviders[id] ?? true}
onValueChange={() => toggleProvider(id)}
trackColor={{ false: colors.elevation1, true: colors.primary + '50' }}
thumbColor={enabledProviders[id] ? colors.primary : colors.mediumGray}
disabled={!isMdbListEnabled}
/>
</View>
))}
</View>
<View style={[styles.infoCard, !isMdbListEnabled && styles.disabledCard]}>
<View style={styles.infoHeader}>
<MaterialIcons
name="help-outline"
size={20}
color={!isMdbListEnabled ? colors.darkGray : colors.primary}
/>
<Text style={[
styles.infoHeaderText,
!isMdbListEnabled && styles.disabledText
]}>
How to get an API key
</Text>
</View>
<View style={styles.infoSteps}>
<View style={styles.infoStep}>
<Text style={[
styles.infoStepNumber,
!isMdbListEnabled && styles.disabledText
]}>
1.
</Text>
<Text style={[
styles.infoStepText,
!isMdbListEnabled && styles.disabledText
]}>
Log in on the <Text style={[
styles.boldText,
!isMdbListEnabled && styles.disabledBoldText
]}>MDBList website</Text>.
</Text>
</View>
<View style={styles.infoStep}>
<Text style={[
styles.infoStepNumber,
!isMdbListEnabled && styles.disabledText
]}>
2.
</Text>
<Text style={[
styles.infoStepText,
!isMdbListEnabled && styles.disabledText
]}>
Go to <Text style={[
styles.boldText,
!isMdbListEnabled && styles.disabledBoldText
]}>Settings</Text> {'>'} <Text style={[
styles.boldText,
!isMdbListEnabled && styles.disabledBoldText
]}>API</Text> section.
</Text>
</View>
<View style={styles.infoStep}>
<Text style={[
styles.infoStepNumber,
!isMdbListEnabled && styles.disabledText
]}>
3.
</Text>
<Text style={[
styles.infoStepText,
!isMdbListEnabled && styles.disabledText
]}>
Generate a new key and copy it.
</Text>
</View>
</View>
<TouchableOpacity
style={[
styles.websiteButton,
!isMdbListEnabled && styles.websiteButtonDisabled
]}
onPress={openMDBListWebsite}
disabled={!isMdbListEnabled}
>
<MaterialIcons
name="open-in-new"
size={18}
color={!isMdbListEnabled ? colors.darkGray : colors.primary}
style={styles.buttonIcon}
/>
<Text style={[
styles.websiteButtonText,
!isMdbListEnabled && styles.websiteButtonTextDisabled
]}>
Go to MDBList
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.darkBackground,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
},
backText: {
fontSize: 17,
fontWeight: '400',
color: colors.primary,
marginLeft: 0,
},
headerTitle: {
fontSize: 34,
fontWeight: '700',
color: colors.white,
paddingHorizontal: 16,
paddingBottom: 16,
paddingTop: 8,
},
content: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 12,
paddingTop: 10,
paddingBottom: 20,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.darkBackground,
},
loadingText: {
marginTop: 12,
fontSize: 15,
color: colors.mediumGray,
},
card: {
backgroundColor: colors.elevation2,
borderRadius: 10,
padding: 12,
marginBottom: 16,
},
statusCard: {
backgroundColor: colors.elevation1,
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 16,
marginBottom: 16,
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
},
infoCard: {
backgroundColor: colors.elevation1,
borderRadius: 10,
padding: 12,
},
statusIcon: {
marginRight: 12,
},
statusTextContainer: {
flex: 1,
},
statusTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.white,
marginBottom: 2,
},
statusDescription: {
fontSize: 13,
color: colors.mediumGray,
lineHeight: 18,
},
sectionTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.lightGray,
marginBottom: 10,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.elevation2,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
},
input: {
flex: 1,
paddingVertical: 10,
paddingHorizontal: 10,
color: colors.white,
fontSize: 15,
},
inputFocused: {
borderColor: colors.primary,
},
pasteButton: {
padding: 8,
marginRight: 2,
},
testResultContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 6,
marginTop: 10,
borderWidth: 1,
},
testResultSuccess: {
backgroundColor: colors.success + '15',
borderColor: colors.success + '40',
},
testResultError: {
backgroundColor: colors.error + '15',
borderColor: colors.error + '40',
},
testResultText: {
marginLeft: 8,
fontSize: 13,
flex: 1,
},
buttonContainer: {
marginTop: 12,
gap: 10,
},
buttonIcon: {
marginRight: 6,
},
saveButton: {
backgroundColor: colors.primary,
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 12,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
},
saveButtonDisabled: {
backgroundColor: colors.elevation2,
opacity: 0.8,
},
saveButtonText: {
color: colors.white,
fontSize: 15,
fontWeight: '600',
},
clearButton: {
backgroundColor: 'transparent',
borderRadius: 8,
borderWidth: 1,
borderColor: colors.error + '40',
paddingVertical: 12,
paddingHorizontal: 12,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
},
clearButtonDisabled: {
borderColor: colors.border,
},
clearButtonText: {
color: colors.error,
fontSize: 15,
fontWeight: '600',
},
clearButtonTextDisabled: {
color: colors.darkGray,
},
infoHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
infoHeaderText: {
fontSize: 15,
fontWeight: '600',
color: colors.white,
marginLeft: 8,
},
infoSteps: {
marginBottom: 12,
gap: 6,
},
infoStep: {
flexDirection: 'row',
alignItems: 'flex-start',
},
infoStepNumber: {
fontSize: 13,
color: colors.mediumGray,
width: 20,
},
infoStepText: {
color: colors.mediumGray,
fontSize: 13,
flex: 1,
lineHeight: 18,
},
boldText: {
fontWeight: '600',
color: colors.lightGray,
},
websiteButton: {
backgroundColor: colors.primary + '20',
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 12,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
marginTop: 12,
},
websiteButtonText: {
color: colors.primary,
fontSize: 15,
fontWeight: '600',
},
websiteButtonDisabled: {
backgroundColor: colors.elevation1,
},
websiteButtonTextDisabled: {
color: colors.darkGray,
},
sectionDescription: {
fontSize: 13,
color: colors.mediumGray,
marginBottom: 12,
},
providerItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
providerInfo: {
flex: 1,
},
providerName: {
fontSize: 15,
color: colors.white,
fontWeight: '500',
},
masterToggleContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 4,
},
masterToggleInfo: {
flex: 1,
},
masterToggleTitle: {
fontSize: 15,
color: colors.white,
fontWeight: '600',
},
masterToggleDescription: {
fontSize: 13,
color: colors.mediumGray,
marginTop: 2,
},
disabledCard: {
opacity: 0.7,
},
disabledInput: {
borderColor: colors.border,
backgroundColor: colors.elevation1,
},
disabledText: {
color: colors.darkGray,
},
disabledBoldText: {
color: colors.darkGray,
},
darkGray: {
color: colors.darkGray || '#555555',
},
});
export default MDBListSettingsScreen;

View file

@ -1,4 +1,4 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@ -20,10 +20,11 @@ import { LinearGradient } from 'expo-linear-gradient';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { colors } from '../styles/colors'; import { colors } from '../styles/colors';
import { useMetadata } from '../hooks/useMetadata'; import { useMetadata } from '../hooks/useMetadata';
import { CastSection } from '../components/metadata/CastSection'; import { CastSection as OriginalCastSection } from '../components/metadata/CastSection';
import { SeriesContent } from '../components/metadata/SeriesContent'; import { SeriesContent as OriginalSeriesContent } from '../components/metadata/SeriesContent';
import { MovieContent } from '../components/metadata/MovieContent'; import { MovieContent as OriginalMovieContent } from '../components/metadata/MovieContent';
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; import { MoreLikeThisSection as OriginalMoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
import { RatingsSection as OriginalRatingsSection } from '../components/metadata/RatingsSection';
import { StreamingContent } from '../services/catalogService'; import { StreamingContent } from '../services/catalogService';
import { GroupedStreams } from '../types/streams'; import { GroupedStreams } from '../types/streams';
import { TMDBEpisode } from '../services/tmdbService'; import { TMDBEpisode } from '../services/tmdbService';
@ -40,6 +41,7 @@ import Animated, {
withSpring, withSpring,
FadeIn, FadeIn,
runOnJS, runOnJS,
Layout,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { RouteProp } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
@ -47,9 +49,17 @@ import { RootStackParamList } from '../navigation/AppNavigator';
import { TMDBService } from '../services/tmdbService'; import { TMDBService } from '../services/tmdbService';
import { storageService } from '../services/storageService'; import { storageService } from '../services/storageService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { useGenres } from '../contexts/GenreContext';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
// Memoize child components
const CastSection = React.memo(OriginalCastSection);
const SeriesContent = React.memo(OriginalSeriesContent);
const MovieContent = React.memo(OriginalMovieContent);
const MoreLikeThisSection = React.memo(OriginalMoreLikeThisSection);
const RatingsSection = React.memo(OriginalRatingsSection);
// Animation configs // Animation configs
const springConfig = { const springConfig = {
damping: 20, damping: 20,
@ -60,6 +70,116 @@ const springConfig = {
// Add debug log for storageService // Add debug log for storageService
logger.log('[MetadataScreen] StorageService instance:', storageService); logger.log('[MetadataScreen] StorageService instance:', storageService);
// Memoized ActionButtons Component
const ActionButtons = React.memo(({
handleShowStreams,
toggleLibrary,
inLibrary,
type,
id,
navigation,
playButtonText
}: {
handleShowStreams: () => void;
toggleLibrary: () => void;
inLibrary: boolean;
type: 'movie' | 'series';
id: string;
navigation: NavigationProp<RootStackParamList>;
playButtonText: string;
}) => (
<View style={styles.actionButtons}>
<TouchableOpacity
style={[styles.actionButton, styles.playButton]}
onPress={handleShowStreams}
>
<MaterialIcons
name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"}
size={24}
color="#000"
/>
<Text style={styles.playButtonText}>
{playButtonText}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.infoButton]}
onPress={toggleLibrary}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={24}
color="#fff"
/>
<Text style={styles.infoButtonText}>
{inLibrary ? 'Saved' : 'Save'}
</Text>
</TouchableOpacity>
{type === 'series' && (
<TouchableOpacity
style={[styles.iconButton]}
onPress={async () => {
const tmdb = TMDBService.getInstance();
const tmdbId = await tmdb.extractTMDBIdFromStremioId(id);
if (tmdbId) {
navigation.navigate('ShowRatings', { showId: tmdbId });
} else {
logger.error('Could not find TMDB ID for show');
}
}}
>
<MaterialIcons name="star-rate" size={24} color="#fff" />
</TouchableOpacity>
)}
</View>
));
// Memoized WatchProgress Component
const WatchProgressDisplay = React.memo(({
watchProgress,
type,
getEpisodeDetails,
animatedStyle
}: {
watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null;
type: 'movie' | 'series';
getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null;
animatedStyle: any;
}) => {
if (!watchProgress || watchProgress.duration === 0) {
return null;
}
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString();
let episodeInfo = '';
if (type === 'series' && watchProgress.episodeId) {
const details = getEpisodeDetails(watchProgress.episodeId);
if (details) {
episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`;
}
}
return (
<Animated.View style={[styles.watchProgressContainer, animatedStyle]}>
<View style={styles.watchProgressBar}>
<View
style={[
styles.watchProgressFill,
{ width: `${progressPercent}%` }
]}
/>
</View>
<Text style={styles.watchProgressText}>
{progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} Last watched on {formattedTime}
</Text>
</Animated.View>
);
});
const MetadataScreen = () => { const MetadataScreen = () => {
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>(); const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -84,6 +204,9 @@ const MetadataScreen = () => {
setMetadata, setMetadata,
} = useMetadata({ id, type }); } = useMetadata({ id, type });
// Get genres from context
const { genreMap, loadingGenres } = useGenres();
const contentRef = useRef<ScrollView>(null); const contentRef = useRef<ScrollView>(null);
const [lastScrollTop, setLastScrollTop] = useState(0); const [lastScrollTop, setLastScrollTop] = useState(0);
const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false);
@ -106,17 +229,40 @@ const MetadataScreen = () => {
const watchProgressOpacity = useSharedValue(0); const watchProgressOpacity = useSharedValue(0);
const watchProgressScaleY = useSharedValue(0); const watchProgressScaleY = useSharedValue(0);
// Add new animated value for logo scale // Add animated value for logo
const logoScale = useSharedValue(0); const logoOpacity = useSharedValue(0);
// Add new animated value for creator fade-in
const creatorOpacity = useSharedValue(0);
// Debug log for route params // Debug log for route params
// logger.log('[MetadataScreen] Component mounted with route params:', { id, type, episodeId }); // logger.log('[MetadataScreen] Component mounted with route params:', { id, type, episodeId });
// Fetch logo immediately for TMDB content
useEffect(() => {
if (metadata && id.startsWith('tmdb:')) {
const fetchLogo = async () => {
try {
const tmdbId = id.split(':')[1];
const tmdbType = type === 'series' ? 'tv' : 'movie';
const logoUrl = await TMDBService.getInstance().getContentLogo(tmdbType, tmdbId);
if (logoUrl) {
// Update metadata with logo
setMetadata(prevMetadata => ({
...prevMetadata!,
logo: logoUrl
}));
logger.log(`Successfully fetched logo for ${type} ${tmdbId} from TMDB on MetadataScreen`);
}
} catch (error) {
logger.error('Failed to fetch logo in MetadataScreen:', error);
}
};
fetchLogo();
}
}, [id, type, metadata, setMetadata]);
// Function to get episode details from episodeId // Function to get episode details from episodeId
const getEpisodeDetails = useCallback((episodeId: string) => { const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => {
// Try to parse from format "seriesId:season:episode" // Try to parse from format "seriesId:season:episode"
const parts = episodeId.split(':'); const parts = episodeId.split(':');
if (parts.length === 3) { if (parts.length === 3) {
@ -274,7 +420,7 @@ const MetadataScreen = () => {
logger.error('[MetadataScreen] Error loading watch progress:', error); logger.error('[MetadataScreen] Error loading watch progress:', error);
setWatchProgress(null); setWatchProgress(null);
} }
}, [id, type, episodeId, episodes]); }, [id, type, episodeId, episodes, getEpisodeDetails]);
// Initial load // Initial load
useEffect(() => { useEffect(() => {
@ -328,7 +474,7 @@ const MetadataScreen = () => {
damping: 18 damping: 18
}); });
} }
}, [watchProgress]); }, [watchProgress, watchProgressOpacity, watchProgressScaleY]);
// Add animated style for watch progress // Add animated style for watch progress
const watchProgressAnimatedStyle = useAnimatedStyle(() => { const watchProgressAnimatedStyle = useAnimatedStyle(() => {
@ -351,123 +497,33 @@ const MetadataScreen = () => {
// Add animated style for logo // Add animated style for logo
const logoAnimatedStyle = useAnimatedStyle(() => { const logoAnimatedStyle = useAnimatedStyle(() => {
return { return {
transform: [{ scale: logoScale.value }], opacity: logoOpacity.value,
transform: [{ scale: interpolate(
logoOpacity.value,
[0, 1],
[0.95, 1],
Extrapolate.CLAMP
) }],
}; };
}); });
// Effect to animate logo scale when logo URI is available // Effect to animate logo when it's available
useEffect(() => { useEffect(() => {
if (metadata?.logo) { if (metadata?.logo) {
logoScale.value = withSpring(1, { logoOpacity.value = withTiming(1, {
damping: 18, duration: 500,
stiffness: 120, easing: Easing.out(Easing.ease)
mass: 0.5
}); });
} else { } else {
// Optional: Reset scale if logo disappears? logoOpacity.value = withTiming(0, {
// logoScale.value = withTiming(0, { duration: 100 }); duration: 200,
easing: Easing.in(Easing.ease)
});
} }
}, [metadata?.logo]); }, [metadata?.logo, logoOpacity]);
// Add animated style for creator fade-in // Update the watch progress render function - Now uses WatchProgressDisplay component
const creatorFadeInStyle = useAnimatedStyle(() => { // const renderWatchProgress = () => { ... }; // Removed old inline function
return {
opacity: creatorOpacity.value,
};
});
// Effect to fade in creator section when data is available
useEffect(() => {
const hasCreators = metadata?.directors?.length || metadata?.creators?.length;
creatorOpacity.value = withTiming(hasCreators ? 1 : 0, {
duration: 300, // Adjust duration as needed
easing: Easing.out(Easing.quad), // Use an easing function
});
}, [metadata?.directors, metadata?.creators]);
// Update the watch progress render function
const renderWatchProgress = () => {
if (!watchProgress || watchProgress.duration === 0) {
return null;
}
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString();
let episodeInfo = '';
if (type === 'series' && watchProgress.episodeId) {
const details = getEpisodeDetails(watchProgress.episodeId);
if (details) {
episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`;
}
}
return (
<Animated.View style={[styles.watchProgressContainer, watchProgressAnimatedStyle]}>
<View style={styles.watchProgressBar}>
<View
style={[
styles.watchProgressFill,
{ width: `${progressPercent}%` }
]}
/>
</View>
<Text style={styles.watchProgressText}>
{progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} Last watched on {formattedTime}
</Text>
</Animated.View>
);
};
// Update the action buttons section
const ActionButtons = () => (
<View style={styles.actionButtons}>
<TouchableOpacity
style={[styles.actionButton, styles.playButton]}
onPress={handleShowStreams}
>
<MaterialIcons
name={watchProgress && watchProgress.currentTime > 0 ? "play-circle-outline" : "play-arrow"}
size={24}
color="#000"
/>
<Text style={styles.playButtonText}>
{getPlayButtonText()}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.infoButton]}
onPress={toggleLibrary}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={24}
color="#fff"
/>
<Text style={styles.infoButtonText}>
{inLibrary ? 'Saved' : 'Save'}
</Text>
</TouchableOpacity>
{type === 'series' && (
<TouchableOpacity
style={[styles.iconButton]}
onPress={async () => {
const tmdb = TMDBService.getInstance();
const tmdbId = await tmdb.extractTMDBIdFromStremioId(id);
if (tmdbId) {
navigation.navigate('ShowRatings', { showId: tmdbId });
} else {
logger.error('Could not find TMDB ID for show');
}
}}
>
<MaterialIcons name="star-rate" size={24} color="#fff" />
</TouchableOpacity>
)}
</View>
);
// Handler functions // Handler functions
const handleShowStreams = useCallback(() => { const handleShowStreams = useCallback(() => {
@ -500,18 +556,19 @@ const MetadataScreen = () => {
navigation.navigate('Streams', { id, type, episodeId }); navigation.navigate('Streams', { id, type, episodeId });
}, [navigation, id, type, episodes, episodeId, watchProgress]); }, [navigation, id, type, episodes, episodeId, watchProgress]);
const handleSelectCastMember = (castMember: any) => { const handleSelectCastMember = useCallback((castMember: any) => {
logger.log('Cast member selected:', castMember); // Potentially navigate to a cast member screen or show details
}; logger.log('Cast member selected:', castMember);
}, []); // Empty dependency array as it doesn't depend on component state/props currently
const handleEpisodeSelect = (episode: Episode) => { const handleEpisodeSelect = useCallback((episode: Episode) => {
const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`;
navigation.navigate('Streams', { navigation.navigate('Streams', {
id, id,
type, type,
episodeId episodeId
}); });
}; }, [navigation, id, type]); // Added dependencies
// Animated styles // Animated styles
const containerAnimatedStyle = useAnimatedStyle(() => ({ const containerAnimatedStyle = useAnimatedStyle(() => ({
@ -613,6 +670,31 @@ const MetadataScreen = () => {
navigation.goBack(); navigation.goBack();
}, [navigation]); }, [navigation]);
// Function to render genres (updated to handle string array and use useMemo)
const renderGenres = useMemo(() => {
if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) {
return null;
}
// Since metadata.genres is string[], we display them directly
const genresToDisplay: string[] = metadata.genres as string[];
return (
<View style={styles.genreContainer}>
{genresToDisplay.slice(0, 4).map((genreName, index, array) => (
// Use React.Fragment to avoid extra View wrappers
<React.Fragment key={index}>
<Text style={styles.genreText}>{genreName}</Text>
{/* Add dot separator */}
{index < array.length - 1 && (
<Text style={styles.genreDot}></Text>
)}
</React.Fragment>
))}
</View>
);
}, [metadata?.genres]); // Dependency on metadata.genres
if (loading) { if (loading) {
return ( return (
<SafeAreaView <SafeAreaView
@ -726,40 +808,45 @@ const MetadataScreen = () => {
locations={[0, 0.4, 0.65, 0.8, 0.9, 1]} locations={[0, 0.4, 0.65, 0.8, 0.9, 1]}
style={styles.heroGradient} style={styles.heroGradient}
> >
<Animated.View entering={FadeInDown.delay(100).springify()} style={styles.heroContent}> <View style={styles.heroContent}>
{/* Title */} {/* Title */}
{metadata.logo ? ( <View style={styles.logoContainer}>
<Animated.View style={logoAnimatedStyle}> <Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
<Image {metadata.logo ? (
source={{ uri: metadata.logo }} <Image
style={styles.titleLogo} source={{ uri: metadata.logo }}
contentFit="contain" style={styles.titleLogo}
/> contentFit="contain"
transition={300}
/>
) : (
<Text style={styles.heroTitle}>{metadata.name}</Text>
)}
</Animated.View> </Animated.View>
) : ( </View>
<Text style={styles.titleText}>{metadata.name}</Text>
)}
{/* Watch Progress */} {/* Watch Progress */}
{renderWatchProgress()} <WatchProgressDisplay
watchProgress={watchProgress}
type={type as 'movie' | 'series'}
getEpisodeDetails={getEpisodeDetails}
animatedStyle={watchProgressAnimatedStyle}
/>
{/* Genre Tags */} {/* Genre Tags */}
{metadata.genres && metadata.genres.length > 0 && ( {renderGenres}
<View style={styles.genreContainer}>
{metadata.genres.slice(0, 3).map((genre, index, array) => (
<React.Fragment key={index}>
<Text style={styles.genreText}>{genre}</Text>
{index < array.length - 1 && (
<Text style={styles.genreDot}></Text>
)}
</React.Fragment>
))}
</View>
)}
{/* Action Buttons */} {/* Action Buttons */}
<ActionButtons /> <ActionButtons
</Animated.View> handleShowStreams={handleShowStreams}
toggleLibrary={toggleLibrary}
inLibrary={inLibrary}
type={type as 'movie' | 'series'}
id={id}
navigation={navigation}
playButtonText={getPlayButtonText()}
/>
</View>
</LinearGradient> </LinearGradient>
</ImageBackground> </ImageBackground>
</Animated.View> </Animated.View>
@ -789,12 +876,18 @@ const MetadataScreen = () => {
)} )}
</View> </View>
{/* Add RatingsSection right under the main metadata */}
{id && (
<RatingsSection
imdbId={id}
type={type === 'series' ? 'show' : 'movie'}
/>
)}
{/* Creator/Director Info */} {/* Creator/Director Info */}
<Animated.View <Animated.View
style={[ entering={FadeIn.duration(500).delay(200)}
styles.creatorContainer, style={styles.creatorContainer}
creatorFadeInStyle,
]}
> >
{metadata.directors && metadata.directors.length > 0 && ( {metadata.directors && metadata.directors.length > 0 && (
<View style={styles.creatorSection}> <View style={styles.creatorSection}>
@ -812,11 +905,29 @@ const MetadataScreen = () => {
{/* Description */} {/* Description */}
{metadata.description && ( {metadata.description && (
<View style={styles.descriptionContainer}> <Animated.View
<Text style={styles.description}> style={styles.descriptionContainer}
{`${metadata.description}`} layout={Layout.duration(300).easing(Easing.inOut(Easing.ease))}
>
<TouchableOpacity
onPress={() => setIsFullDescriptionOpen(!isFullDescriptionOpen)}
activeOpacity={0.7}
>
<Text style={styles.description} numberOfLines={isFullDescriptionOpen ? undefined : 3}>
{metadata.description}
</Text> </Text>
</View> <View style={styles.showMoreButton}>
<Text style={styles.showMoreText}>
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
</Text>
<MaterialIcons
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={18}
color={colors.textMuted}
/>
</View>
</TouchableOpacity>
</Animated.View>
)} )}
{/* Cast Section */} {/* Cast Section */}
@ -940,22 +1051,33 @@ const styles = StyleSheet.create({
genreContainer: { genreContainer: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 12, alignItems: 'center',
width: '100%', marginTop: 8,
marginBottom: 16,
gap: 4,
}, },
genreText: { genreText: {
color: colors.highEmphasis, color: colors.text,
fontSize: 14, fontSize: 12,
fontWeight: '500', fontWeight: '500',
opacity: 0.8,
}, },
genreDot: { genreDot: {
color: colors.highEmphasis, color: colors.text,
fontSize: 14, fontSize: 12,
marginHorizontal: 8, fontWeight: '500',
opacity: 0.6, opacity: 0.6,
marginHorizontal: 4,
},
logoContainer: {
alignItems: 'center',
justifyContent: 'center',
width: '100%',
},
titleLogoContainer: {
alignItems: 'center',
justifyContent: 'center',
width: '100%',
}, },
titleLogo: { titleLogo: {
width: width * 0.65, width: width * 0.65,
@ -963,7 +1085,7 @@ const styles = StyleSheet.create({
marginBottom: 0, marginBottom: 0,
alignSelf: 'center', alignSelf: 'center',
}, },
titleText: { heroTitle: {
color: colors.highEmphasis, color: colors.highEmphasis,
fontSize: 28, fontSize: 28,
fontWeight: '900', fontWeight: '900',
@ -1015,18 +1137,13 @@ const styles = StyleSheet.create({
showMoreButton: { showMoreButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginTop: 10, marginTop: 8,
backgroundColor: colors.elevation1, paddingVertical: 4,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
alignSelf: 'flex-start',
}, },
showMoreText: { showMoreText: {
color: colors.highEmphasis, color: colors.textMuted,
fontSize: 14, fontSize: 14,
marginRight: 4, marginRight: 4,
fontWeight: '500',
}, },
actionButtons: { actionButtons: {
flexDirection: 'row', flexDirection: 'row',
@ -1084,37 +1201,6 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
fontSize: 16, fontSize: 16,
}, },
fullDescriptionContainer: {
flex: 1,
backgroundColor: colors.darkBackground,
},
fullDescriptionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
paddingHorizontal: 24,
borderBottomWidth: 1,
borderBottomColor: colors.elevation1,
position: 'relative',
},
fullDescriptionTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text,
},
fullDescriptionCloseButton: {
position: 'absolute',
left: 16,
padding: 8,
},
fullDescriptionContent: {
flex: 1,
padding: 24,
},
fullDescriptionText: {
color: colors.text,
},
creatorContainer: { creatorContainer: {
marginBottom: 2, marginBottom: 2,
paddingHorizontal: 16, paddingHorizontal: 16,

View file

@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useState, useEffect } from 'react';
import { import {
View, View,
Text, Text,
@ -14,6 +14,7 @@ import {
Dimensions, Dimensions,
Pressable Pressable
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
@ -21,14 +22,30 @@ import { colors } from '../styles/colors';
import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings'; import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import { stremioService } from '../services/stremioService'; import { stremioService } from '../services/stremioService';
import { useCatalogContext } from '../contexts/CatalogContext';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Card component for iOS Fluent design style
interface SettingsCardProps {
children: React.ReactNode;
isDarkMode: boolean;
}
const SettingsCard: React.FC<SettingsCardProps> = ({ children, isDarkMode }) => (
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
{children}
</View>
);
interface SettingItemProps { interface SettingItemProps {
title: string; title: string;
description: string; description?: string;
icon: string; icon: string;
renderControl: () => React.ReactNode; renderControl: () => React.ReactNode;
isLast?: boolean; isLast?: boolean;
@ -46,48 +63,110 @@ const SettingItem: React.FC<SettingItemProps> = ({
isDarkMode isDarkMode
}) => { }) => {
return ( return (
<View <TouchableOpacity
activeOpacity={0.7}
onPress={onPress}
style={[ style={[
styles.settingItem, styles.settingItem,
!isLast && styles.settingItemBorder, !isLast && styles.settingItemBorder,
{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)' } { borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
]} ]}
> >
<Pressable <View style={styles.settingIconContainer}>
style={styles.settingTouchable} <MaterialIcons name={icon} size={22} color={colors.primary} />
onPress={onPress} </View>
android_ripple={{ <View style={styles.settingContent}>
color: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', <View style={styles.settingTitleRow}>
borderless: true
}}
>
<View style={[
styles.settingIconContainer,
{ backgroundColor: isDarkMode ? colors.elevation2 : 'rgba(147, 51, 234, 0.08)' }
]}>
<MaterialIcons name={icon} size={24} color={colors.primary} />
</View>
<View style={styles.settingContent}>
<Text style={[styles.settingTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}> <Text style={[styles.settingTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
{title} {title}
</Text> </Text>
<Text style={[styles.settingDescription, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}> {description && (
{description} <Text style={[styles.settingDescription, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
</Text> {description}
</Text>
)}
</View> </View>
<View style={styles.settingControl}> </View>
{renderControl()} <View style={styles.settingControl}>
</View> {renderControl()}
</Pressable> </View>
</View> </TouchableOpacity>
); );
}; };
const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ title, isDarkMode }) => (
<View style={styles.sectionHeader}>
<Text style={[
styles.sectionHeaderText,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
{title}
</Text>
</View>
);
const SettingsScreen: React.FC = () => { const SettingsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme(); const systemColorScheme = useColorScheme();
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode; const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { lastUpdate } = useCatalogContext();
// States for dynamic content
const [addonCount, setAddonCount] = useState<number>(0);
const [catalogCount, setCatalogCount] = useState<number>(0);
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
const loadData = useCallback(async () => {
try {
// Load addon count and get their catalogs
const addons = await stremioService.getInstalledAddonsAsync();
setAddonCount(addons.length);
// Count total available catalogs
let totalCatalogs = 0;
addons.forEach(addon => {
if (addon.catalogs && addon.catalogs.length > 0) {
totalCatalogs += addon.catalogs.length;
}
});
// Load saved catalog settings
const catalogSettingsJson = await AsyncStorage.getItem('catalog_settings');
if (catalogSettingsJson) {
const catalogSettings = JSON.parse(catalogSettingsJson);
// Filter out _lastUpdate key and count only explicitly disabled catalogs
const disabledCount = Object.entries(catalogSettings)
.filter(([key, value]) => key !== '_lastUpdate' && value === false)
.length;
// Since catalogs are enabled by default, subtract disabled ones from total
setCatalogCount(totalCatalogs - disabledCount);
} else {
// If no settings saved, all catalogs are enabled by default
setCatalogCount(totalCatalogs);
}
// Check MDBList API key status
const mdblistKey = await AsyncStorage.getItem('mdblist_api_key');
setMdblistKeySet(!!mdblistKey);
} catch (error) {
console.error('Error loading settings data:', error);
}
}, []);
// Load data initially and when catalogs are updated
useEffect(() => {
loadData();
}, [loadData, lastUpdate]);
// Add focus listener to reload data when screen comes into focus
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
loadData();
});
return unsubscribe;
}, [navigation, loadData]);
const handleResetSettings = useCallback(() => { const handleResetSettings = useCallback(() => {
Alert.alert( Alert.alert(
@ -108,205 +187,143 @@ const SettingsScreen: React.FC = () => {
); );
}, [updateSetting]); }, [updateSetting]);
const renderSectionHeader = (title: string) => (
<View style={styles.sectionHeader}>
<Text style={[
styles.sectionHeaderText,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
{title}
</Text>
</View>
);
const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => ( const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => (
<Switch <Switch
value={value} value={value}
onValueChange={onValueChange} onValueChange={onValueChange}
trackColor={{ false: isDarkMode ? colors.elevation2 : colors.surfaceVariant, true: `${colors.primary}80` }} trackColor={{ false: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', true: colors.primary }}
thumbColor={value ? colors.primary : (isDarkMode ? colors.white : colors.white)} thumbColor={Platform.OS === 'android' ? (value ? colors.white : colors.white) : ''}
ios_backgroundColor={isDarkMode ? colors.elevation2 : colors.surfaceVariant} ios_backgroundColor={isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
style={Platform.select({ ios: { transform: [{ scale: 0.8 }] } })} />
);
const ChevronRight = () => (
<MaterialIcons
name="chevron-right"
size={24}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/> />
); );
return ( return (
<SafeAreaView style={[ <SafeAreaView style={[
styles.container, styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : colors.lightBackground } { backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
]}> ]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} /> <StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={[styles.header, { <View style={styles.header}>
borderBottomColor: isDarkMode ? colors.border : 'rgba(0,0,0,0.08)' <Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
}]}> Settings
<View style={styles.headerContent}> </Text>
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Settings
</Text>
</View>
</View> </View>
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
> >
{renderSectionHeader('Playback')} <SectionHeader title="USER & ACCOUNT" isDarkMode={isDarkMode} />
<SettingItem <SettingsCard isDarkMode={isDarkMode}>
title="External Player" <SettingItem
description="Use external video player when available" title="Trakt"
icon="open-in-new" description="Not Connected"
isDarkMode={isDarkMode} icon="person"
renderControl={() => ( isDarkMode={isDarkMode}
<CustomSwitch renderControl={ChevronRight}
value={settings.useExternalPlayer} onPress={() => Alert.alert('Trakt', 'Trakt integration coming soon')}
onValueChange={(value) => updateSetting('useExternalPlayer', value)} />
/> <SettingItem
)} title="iCloud Sync"
/> description="Enabled"
icon="cloud"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
isLast={true}
/>
</SettingsCard>
{renderSectionHeader('Content')} <SectionHeader title="CONTENT" isDarkMode={isDarkMode} />
<SettingItem <SettingsCard isDarkMode={isDarkMode}>
title="Catalog Settings" <SettingItem
description="Customize which catalogs appear on your home screen" title="Addons"
icon="view-list" description={addonCount + " installed"}
isDarkMode={isDarkMode} icon="extension"
renderControl={() => ( isDarkMode={isDarkMode}
<View style={[styles.actionButton, { backgroundColor: colors.primary }]}> renderControl={ChevronRight}
<Text style={styles.actionButtonText}>Configure</Text> onPress={() => navigation.navigate('Addons')}
</View> />
)} <SettingItem
onPress={() => navigation.navigate('CatalogSettings')} title="Catalogs"
/> description={`${catalogCount} ${catalogCount === 1 ? 'catalog' : 'catalogs'} enabled`}
<SettingItem icon="view-list"
title="Calendar & Upcoming" isDarkMode={isDarkMode}
description="View and manage your upcoming episode schedule" renderControl={ChevronRight}
icon="calendar-today" onPress={() => navigation.navigate('CatalogSettings')}
isDarkMode={isDarkMode} />
renderControl={() => ( <SettingItem
<MaterialIcons title="Home Screen"
name="chevron-right" description="Customize home layout and content"
size={24} icon="home"
color={isDarkMode ? colors.lightGray : colors.mediumGray} isDarkMode={isDarkMode}
style={styles.chevronIcon} renderControl={ChevronRight}
/> onPress={() => navigation.navigate('HomeScreenSettings')}
)} />
onPress={() => navigation.navigate('Calendar')} <SettingItem
/> title="Folders"
<SettingItem description="0 created"
title="Notifications" icon="folder"
description="Configure notifications for new episodes" isDarkMode={isDarkMode}
icon="notifications" renderControl={ChevronRight}
isDarkMode={isDarkMode} />
renderControl={() => ( <SettingItem
<MaterialIcons title="Ratings Source"
name="chevron-right" description={mdblistKeySet ? "MDBList API Configured" : "MDBList API Not Set"}
size={24} icon="info-outline"
color={isDarkMode ? colors.lightGray : colors.mediumGray} isDarkMode={isDarkMode}
style={styles.chevronIcon} renderControl={ChevronRight}
/> onPress={() => navigation.navigate('MDBListSettings')}
)} />
onPress={() => navigation.navigate('NotificationSettings')} <SettingItem
/> title="TMDB"
description="API & Metadata Settings"
icon="movie-filter"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TMDBSettings')}
/>
<SettingItem
title="Resource Filters"
icon="tune"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
/>
<SettingItem
title="AI Features"
description="Not Connected"
icon="auto-awesome"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
isLast={true}
/>
</SettingsCard>
{renderSectionHeader('Advanced')} <SectionHeader title="PLAYBACK" isDarkMode={isDarkMode} />
<SettingItem <SettingsCard isDarkMode={isDarkMode}>
title="Manage Addons" <SettingItem
description="Configure and update your addons" title="Video Player"
icon="extension" description="Infuse"
isDarkMode={isDarkMode} icon="play-arrow"
renderControl={() => ( isDarkMode={isDarkMode}
<MaterialIcons renderControl={ChevronRight}
name="chevron-right" />
size={24} <SettingItem
color={isDarkMode ? colors.lightGray : colors.mediumGray} title="Auto-Filtering"
style={styles.chevronIcon} description="Disabled"
/> icon="tune"
)} isDarkMode={isDarkMode}
onPress={() => navigation.navigate('Addons')} renderControl={ChevronRight}
/> isLast={true}
<SettingItem />
title="Check TMDB Addon" </SettingsCard>
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"
icon="settings-backup-restore"
isDarkMode={isDarkMode}
renderControl={() => (
<View style={[styles.actionButton, { backgroundColor: colors.warning }]}>
<Text style={styles.actionButtonText}>Reset</Text>
</View>
)}
isLast={true}
onPress={handleResetSettings}
/>
{renderSectionHeader('About')}
<SettingItem
title="App Version"
description="HuHuMobile v1.0.0"
icon="info"
isDarkMode={isDarkMode}
renderControl={() => null}
isLast={true}
/>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );
@ -319,21 +336,12 @@ const styles = StyleSheet.create({
header: { header: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 4, paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
backgroundColor: colors.darkBackground,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}, },
headerTitle: { headerTitle: {
fontSize: 32, fontSize: 34,
fontWeight: '800', fontWeight: '700',
letterSpacing: 0.5, letterSpacing: 0.5,
color: colors.white,
}, },
scrollView: { scrollView: {
flex: 1, flex: 1,
@ -342,84 +350,69 @@ const styles = StyleSheet.create({
paddingBottom: 32, paddingBottom: 32,
}, },
sectionHeader: { sectionHeader: {
padding: 16, paddingHorizontal: 16,
paddingTop: 20,
paddingBottom: 8, paddingBottom: 8,
}, },
sectionHeaderText: { sectionHeaderText: {
fontSize: 13, fontSize: 12,
fontWeight: '600', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: 0.8,
letterSpacing: 1, },
card: {
marginHorizontal: 16,
borderRadius: 12,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
}, },
settingItem: { settingItem: {
marginHorizontal: 16,
marginVertical: 4,
borderRadius: 16,
overflow: Platform.OS === 'android' ? 'hidden' : 'visible',
},
settingItemBorder: {
marginBottom: 8,
},
settingTouchable: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingVertical: 16, paddingVertical: 8,
paddingHorizontal: 16, paddingHorizontal: 16,
borderBottomWidth: 0.5,
minHeight: 44,
},
settingItemBorder: {
// Border styling handled directly in the component with borderBottomWidth
}, },
settingIconContainer: { settingIconContainer: {
marginRight: 16, marginRight: 12,
width: 40, width: 24,
height: 40, height: 24,
borderRadius: 20,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
settingContent: { settingContent: {
flex: 1, flex: 1,
marginRight: 16, marginRight: 8,
},
settingTitleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
}, },
settingTitle: { settingTitle: {
fontSize: 16, fontSize: 15,
fontWeight: '600', fontWeight: '400',
marginBottom: 4, flex: 1,
letterSpacing: 0.15,
}, },
settingDescription: { settingDescription: {
fontSize: 14, fontSize: 14,
lineHeight: 20, opacity: 0.7,
letterSpacing: 0.25, textAlign: 'right',
flexShrink: 1,
maxWidth: '60%',
}, },
settingControl: { settingControl: {
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
minWidth: 50, paddingLeft: 8,
},
selectButton: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
},
selectButtonText: {
fontWeight: '600',
marginRight: 4,
fontSize: 14,
letterSpacing: 0.25,
},
actionButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
actionButtonText: {
color: colors.white,
fontWeight: '600',
fontSize: 14,
letterSpacing: 0.5,
},
chevronIcon: {
opacity: 0.8,
}, },
}); });

View file

@ -0,0 +1,621 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TextInput,
SafeAreaView,
StatusBar,
Platform,
Alert,
ActivityIndicator,
Linking,
ScrollView,
Keyboard,
Clipboard,
Switch,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors } from '../styles/colors';
import { logger } from '../utils/logger';
const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const TMDBSettingsScreen = () => {
const navigation = useNavigation();
const [apiKey, setApiKey] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isKeySet, setIsKeySet] = useState(false);
const [useCustomKey, setUseCustomKey] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [isInputFocused, setIsInputFocused] = useState(false);
const apiKeyInputRef = useRef<TextInput>(null);
useEffect(() => {
logger.log('[TMDBSettingsScreen] Component mounted');
loadSettings();
return () => {
logger.log('[TMDBSettingsScreen] Component unmounted');
};
}, []);
const loadSettings = async () => {
logger.log('[TMDBSettingsScreen] Loading settings from storage');
try {
const [savedKey, savedUseCustomKey] = await Promise.all([
AsyncStorage.getItem(TMDB_API_KEY_STORAGE_KEY),
AsyncStorage.getItem(USE_CUSTOM_TMDB_API_KEY)
]);
logger.log('[TMDBSettingsScreen] API key status:', savedKey ? 'Found' : 'Not found');
logger.log('[TMDBSettingsScreen] Use custom API setting:', savedUseCustomKey);
if (savedKey) {
setApiKey(savedKey);
setIsKeySet(true);
} else {
setIsKeySet(false);
}
setUseCustomKey(savedUseCustomKey === 'true');
} catch (error) {
logger.error('[TMDBSettingsScreen] Failed to load settings:', error);
setIsKeySet(false);
setUseCustomKey(false);
} finally {
setIsLoading(false);
logger.log('[TMDBSettingsScreen] Finished loading settings');
}
};
const saveApiKey = async () => {
logger.log('[TMDBSettingsScreen] Starting API key save');
Keyboard.dismiss();
try {
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
logger.warn('[TMDBSettingsScreen] Empty API key provided');
setTestResult({ success: false, message: 'API Key cannot be empty.' });
return;
}
// Test the API key to make sure it works
if (await testApiKey(trimmedKey)) {
logger.log('[TMDBSettingsScreen] API key test successful, saving key');
await AsyncStorage.setItem(TMDB_API_KEY_STORAGE_KEY, trimmedKey);
await AsyncStorage.setItem(USE_CUSTOM_TMDB_API_KEY, 'true');
setIsKeySet(true);
setUseCustomKey(true);
setTestResult({ success: true, message: 'API key verified and saved successfully.' });
logger.log('[TMDBSettingsScreen] API key saved successfully');
} else {
logger.warn('[TMDBSettingsScreen] API key test failed');
setTestResult({ success: false, message: 'Invalid API key. Please check and try again.' });
}
} catch (error) {
logger.error('[TMDBSettingsScreen] Error saving API key:', error);
setTestResult({
success: false,
message: 'An error occurred while saving. Please try again.'
});
}
};
const testApiKey = async (key: string): Promise<boolean> => {
try {
// Simple API call to test the key
const response = await fetch(
'https://api.themoviedb.org/3/configuration',
{
method: 'GET',
headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json',
}
}
);
return response.ok;
} catch (error) {
logger.error('[TMDBSettingsScreen] API key test error:', error);
return false;
}
};
const clearApiKey = async () => {
logger.log('[TMDBSettingsScreen] Clear API key requested');
Alert.alert(
'Clear API Key',
'Are you sure you want to remove your custom API key and revert to the default?',
[
{
text: 'Cancel',
style: 'cancel',
onPress: () => logger.log('[TMDBSettingsScreen] Clear API key cancelled')
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
logger.log('[TMDBSettingsScreen] Proceeding with API key clear');
try {
await AsyncStorage.removeItem(TMDB_API_KEY_STORAGE_KEY);
await AsyncStorage.setItem(USE_CUSTOM_TMDB_API_KEY, 'false');
setApiKey('');
setIsKeySet(false);
setUseCustomKey(false);
setTestResult(null);
logger.log('[TMDBSettingsScreen] API key cleared successfully');
} catch (error) {
logger.error('[TMDBSettingsScreen] Failed to clear API key:', error);
Alert.alert('Error', 'Failed to clear API key');
}
}
}
]
);
};
const toggleUseCustomKey = async (value: boolean) => {
logger.log('[TMDBSettingsScreen] Toggle use custom key:', value);
try {
await AsyncStorage.setItem(USE_CUSTOM_TMDB_API_KEY, value ? 'true' : 'false');
setUseCustomKey(value);
if (!value) {
// If switching to built-in key, show confirmation
logger.log('[TMDBSettingsScreen] Switching to built-in API key');
setTestResult({
success: true,
message: 'Now using the built-in TMDb API key.'
});
} else if (apiKey && isKeySet) {
// If switching to custom key and we have a key
logger.log('[TMDBSettingsScreen] Switching to custom API key');
setTestResult({
success: true,
message: 'Now using your custom TMDb API key.'
});
} else {
// If switching to custom key but don't have a key yet
logger.log('[TMDBSettingsScreen] No custom key available yet');
setTestResult({
success: false,
message: 'Please enter and save your custom TMDb API key.'
});
}
} catch (error) {
logger.error('[TMDBSettingsScreen] Failed to toggle custom key setting:', error);
}
};
const pasteFromClipboard = async () => {
logger.log('[TMDBSettingsScreen] Attempting to paste from clipboard');
try {
const clipboardContent = await Clipboard.getString();
if (clipboardContent) {
logger.log('[TMDBSettingsScreen] Content pasted from clipboard');
setApiKey(clipboardContent);
setTestResult(null);
} else {
logger.warn('[TMDBSettingsScreen] No content in clipboard');
}
} catch (error) {
logger.error('[TMDBSettingsScreen] Error pasting from clipboard:', error);
}
};
const openTMDBWebsite = () => {
logger.log('[TMDBSettingsScreen] Opening TMDb website');
Linking.openURL('https://www.themoviedb.org/settings/api').catch(error => {
logger.error('[TMDBSettingsScreen] Error opening website:', error);
});
};
if (isLoading) {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading Settings...</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.primary} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>TMDb Settings</Text>
<ScrollView
style={styles.content}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.switchCard}>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Use Custom TMDb API Key</Text>
<Switch
value={useCustomKey}
onValueChange={toggleUseCustomKey}
trackColor={{ false: colors.lightGray, true: colors.accentLight }}
thumbColor={Platform.OS === 'android' ? colors.primary : ''}
ios_backgroundColor={colors.lightGray}
/>
</View>
<Text style={styles.switchDescription}>
Enable to use your own TMDb API key instead of the built-in one.
Using your own API key may provide better performance and higher rate limits.
</Text>
</View>
{useCustomKey && (
<>
<View style={styles.statusCard}>
<MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"}
size={28}
color={isKeySet ? colors.success : colors.warning}
style={styles.statusIcon}
/>
<View style={styles.statusTextContainer}>
<Text style={styles.statusTitle}>
{isKeySet ? "API Key Active" : "API Key Required"}
</Text>
<Text style={styles.statusDescription}>
{isKeySet
? "Your custom TMDb API key is set and active."
: "Add your TMDb API key below."}
</Text>
</View>
</View>
<View style={styles.card}>
<Text style={styles.sectionTitle}>API Key</Text>
<View style={styles.inputWrapper}>
<TextInput
ref={apiKeyInputRef}
style={[styles.input, isInputFocused && styles.inputFocused]}
value={apiKey}
onChangeText={(text) => {
setApiKey(text);
if (testResult) setTestResult(null);
}}
placeholder="Paste your TMDb API key (v4 auth)"
placeholderTextColor={colors.mediumGray}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
<TouchableOpacity
style={styles.pasteButton}
onPress={pasteFromClipboard}
>
<MaterialIcons name="content-paste" size={20} color={colors.primary} />
</TouchableOpacity>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity
style={styles.button}
onPress={saveApiKey}
>
<Text style={styles.buttonText}>Save API Key</Text>
</TouchableOpacity>
{isKeySet && (
<TouchableOpacity
style={[styles.button, styles.clearButton]}
onPress={clearApiKey}
>
<Text style={[styles.buttonText, styles.clearButtonText]}>Clear</Text>
</TouchableOpacity>
)}
</View>
{testResult && (
<View style={[
styles.resultMessage,
testResult.success ? styles.successMessage : styles.errorMessage
]}>
<MaterialIcons
name={testResult.success ? "check-circle" : "error"}
size={18}
color={testResult.success ? colors.success : colors.error}
style={styles.resultIcon}
/>
<Text style={[
styles.resultText,
testResult.success ? styles.successText : styles.errorText
]}>
{testResult.message}
</Text>
</View>
)}
<TouchableOpacity
style={styles.helpLink}
onPress={openTMDBWebsite}
>
<MaterialIcons name="help" size={16} color={colors.primary} style={styles.helpIcon} />
<Text style={styles.helpText}>
How to get a TMDb API key?
</Text>
</TouchableOpacity>
</View>
<View style={styles.infoCard}>
<MaterialIcons name="info-outline" size={22} color={colors.primary} style={styles.infoIcon} />
<Text style={styles.infoText}>
To get your own TMDb API key (v4 auth token), you need to create a TMDb account and request an API key from their website.
Using your own API key gives you dedicated quota and may improve app performance.
</Text>
</View>
</>
)}
{!useCustomKey && (
<View style={styles.infoCard}>
<MaterialIcons name="info-outline" size={22} color={colors.primary} style={styles.infoIcon} />
<Text style={styles.infoText}>
Currently using the built-in TMDb API key. This key is shared among all users.
For better performance and reliability, consider using your own API key.
</Text>
</View>
)}
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.darkBackground,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: colors.white,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
paddingBottom: 8,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
},
backText: {
color: colors.primary,
fontSize: 16,
fontWeight: '500',
},
headerTitle: {
fontSize: 28,
fontWeight: 'bold',
color: colors.white,
marginHorizontal: 16,
marginBottom: 16,
},
content: {
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
switchCard: {
backgroundColor: colors.elevation2,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
switchRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
switchLabel: {
fontSize: 16,
fontWeight: '500',
color: colors.white,
},
switchDescription: {
fontSize: 14,
color: colors.mediumEmphasis,
lineHeight: 20,
},
statusCard: {
flexDirection: 'row',
backgroundColor: colors.elevation2,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
statusIcon: {
marginRight: 12,
},
statusTextContainer: {
flex: 1,
},
statusTitle: {
fontSize: 16,
fontWeight: '500',
color: colors.white,
marginBottom: 4,
},
statusDescription: {
fontSize: 14,
color: colors.mediumEmphasis,
},
card: {
backgroundColor: colors.elevation2,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
sectionTitle: {
fontSize: 16,
fontWeight: '500',
color: colors.white,
marginBottom: 16,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
input: {
flex: 1,
backgroundColor: colors.elevation1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
color: colors.white,
fontSize: 15,
borderWidth: 1,
borderColor: 'transparent',
},
inputFocused: {
borderColor: colors.primary,
},
pasteButton: {
position: 'absolute',
right: 8,
padding: 8,
},
buttonRow: {
flexDirection: 'row',
marginBottom: 16,
},
button: {
backgroundColor: colors.primary,
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 20,
alignItems: 'center',
flex: 1,
marginRight: 8,
},
clearButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: colors.error,
marginRight: 0,
marginLeft: 8,
flex: 0,
},
buttonText: {
color: colors.white,
fontWeight: '500',
fontSize: 15,
},
clearButtonText: {
color: colors.error,
},
resultMessage: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 8,
padding: 12,
marginBottom: 16,
},
successMessage: {
backgroundColor: colors.success + '1A', // 10% opacity
},
errorMessage: {
backgroundColor: colors.error + '1A', // 10% opacity
},
resultIcon: {
marginRight: 8,
},
resultText: {
fontSize: 14,
flex: 1,
},
successText: {
color: colors.success,
},
errorText: {
color: colors.error,
},
helpLink: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 8,
},
helpIcon: {
marginRight: 6,
},
helpText: {
color: colors.primary,
fontSize: 14,
},
infoCard: {
backgroundColor: colors.elevation1,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
},
infoIcon: {
marginRight: 12,
marginTop: 2,
},
infoText: {
color: colors.mediumEmphasis,
fontSize: 14,
flex: 1,
lineHeight: 20,
},
});
export default TMDBSettingsScreen;

View file

@ -0,0 +1,182 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { logger } from '../utils/logger';
import {
MDBLIST_API_KEY_STORAGE_KEY,
MDBLIST_ENABLED_STORAGE_KEY,
isMDBListEnabled
} from '../screens/MDBListSettingsScreen';
export interface MDBListRatings {
trakt?: number;
imdb?: number;
tmdb?: number;
letterboxd?: number;
tomatoes?: number;
audience?: number;
metacritic?: number;
}
export class MDBListService {
private static instance: MDBListService;
private apiKey: string | null = null;
private enabled: boolean = true;
private constructor() {
logger.log('[MDBListService] Service initialized');
}
static getInstance(): MDBListService {
if (!MDBListService.instance) {
MDBListService.instance = new MDBListService();
}
return MDBListService.instance;
}
async initialize(): Promise<void> {
try {
// First check if MDBList is enabled
const enabledSetting = await AsyncStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY);
this.enabled = enabledSetting === null || enabledSetting === 'true';
logger.log('[MDBListService] MDBList enabled:', this.enabled);
if (!this.enabled) {
logger.log('[MDBListService] MDBList is disabled, skipping API key loading');
this.apiKey = null;
return;
}
this.apiKey = await AsyncStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY);
logger.log('[MDBListService] Initialized with API key:', this.apiKey ? 'Present' : 'Not found');
} catch (error) {
logger.error('[MDBListService] Failed to load settings:', error);
this.apiKey = null;
this.enabled = true; // Default to enabled on error
}
}
async getRatings(imdbId: string, mediaType: 'movie' | 'show'): Promise<MDBListRatings | null> {
logger.log(`[MDBListService] Fetching ratings for ${mediaType} with IMDB ID:`, imdbId);
// Check if MDBList is enabled before doing anything else
if (!this.enabled) {
// Try to refresh enabled status in case it was changed
try {
const enabledSetting = await AsyncStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY);
this.enabled = enabledSetting === null || enabledSetting === 'true';
} catch (error) {
// Ignore error and keep current state
}
if (!this.enabled) {
logger.log('[MDBListService] MDBList is disabled, not fetching ratings');
return null;
}
}
if (!this.apiKey) {
logger.log('[MDBListService] No API key found, attempting to initialize');
await this.initialize();
if (!this.apiKey || !this.enabled) {
const reason = !this.enabled ? 'MDBList is disabled' : 'No API key found';
logger.warn(`[MDBListService] ${reason}`);
return null;
}
}
try {
const ratings: MDBListRatings = {};
const ratingTypes = ['trakt', 'imdb', 'tmdb', 'letterboxd', 'tomatoes', 'audience', 'metacritic'];
logger.log(`[MDBListService] Starting to fetch ${ratingTypes.length} different rating types in parallel`);
const formattedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
if (!/^tt\d+$/.test(formattedImdbId)) {
logger.error('[MDBListService] Invalid IMDB ID format:', formattedImdbId);
return null;
}
logger.log(`[MDBListService] Using formatted IMDB ID:`, formattedImdbId);
// Create an array of fetch promises
const fetchPromises = ratingTypes.map(async (ratingType) => {
try {
// API Key in URL query parameter
const url = `https://api.mdblist.com/rating/${mediaType}/${ratingType}?apikey=${this.apiKey}`;
logger.log(`[MDBListService] Fetching ${ratingType} rating from:`, url);
// Body contains only ids and provider
const body = {
ids: [formattedImdbId],
provider: 'imdb'
};
logger.log(`[MDBListService] Request body:`, body);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body)
});
logger.log(`[MDBListService] ${ratingType} response status:`, response.status);
if (response.ok) {
const data = await response.json();
logger.log(`[MDBListService] ${ratingType} response data:`, data);
if (data.ratings?.[0]?.rating) {
ratings[ratingType as keyof MDBListRatings] = data.ratings[0].rating;
logger.log(`[MDBListService] Added ${ratingType} rating:`, data.ratings[0].rating);
return { type: ratingType, rating: data.ratings[0].rating };
} else {
logger.warn(`[MDBListService] No ${ratingType} rating found in response`);
return null;
}
} else {
// Log specific error for invalid API key
if (response.status === 403) {
const errorText = await response.text();
try {
const errorJson = JSON.parse(errorText);
if (errorJson.error === "Invalid API key") {
logger.error('[MDBListService] API Key rejected by server:', this.apiKey);
} else {
logger.warn(`[MDBListService] 403 Forbidden, but not invalid key error:`, errorJson);
}
} catch (parseError) {
logger.warn(`[MDBListService] 403 Forbidden, non-JSON response:`, errorText);
}
} else {
logger.warn(`[MDBListService] Failed to fetch ${ratingType} rating. Status:`, response.status);
const errorText = await response.text();
logger.warn(`[MDBListService] Error response:`, errorText);
}
return null;
}
} catch (error) {
logger.error(`[MDBListService] Error fetching ${ratingType} rating:`, error);
return null;
}
});
// Execute all fetch promises in parallel
const results = await Promise.all(fetchPromises);
// Process results
results.forEach(result => {
if (result) {
ratings[result.type as keyof MDBListRatings] = result.rating;
}
});
const ratingCount = Object.keys(ratings).length;
logger.log(`[MDBListService] Fetched ${ratingCount} ratings successfully:`, ratings);
return ratingCount > 0 ? ratings : null;
} catch (error) {
logger.error('[MDBListService] Error fetching MDBList ratings:', error);
return null;
}
}
}
export const mdblistService = MDBListService.getInstance();

View file

@ -1,9 +1,12 @@
import axios from 'axios'; import axios from 'axios';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
// TMDB API configuration // TMDB API configuration
const API_KEY = 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0MzljNDc4YTc3MWYzNWMwNTAyMmY5ZmVhYmNjYTAxYyIsIm5iZiI6MTcwOTkxMTEzNS4xNCwic3ViIjoiNjVlYjJjNWYzODlkYTEwMTYyZDgyOWU0Iiwic2NvcGVzIjpbImFwaV9yZWFkIl0sInZlcnNpb24iOjF9.gosBVl1wYUbePOeB9WieHn8bY9x938-GSGmlXZK_UVM'; const DEFAULT_API_KEY = '439c478a771f35c05022f9feabcca01c';
const BASE_URL = 'https://api.themoviedb.org/3'; const BASE_URL = 'https://api.themoviedb.org/3';
const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key';
// Types for TMDB responses // Types for TMDB responses
export interface TMDBEpisode { export interface TMDBEpisode {
@ -40,6 +43,7 @@ export interface TMDBShow {
last_air_date: string; last_air_date: string;
number_of_seasons: number; number_of_seasons: number;
number_of_episodes: number; number_of_episodes: number;
genres?: { id: number; name: string }[];
seasons: { seasons: {
id: number; id: number;
name: string; name: string;
@ -69,8 +73,13 @@ export interface TMDBTrendingResult {
export class TMDBService { export class TMDBService {
private static instance: TMDBService; private static instance: TMDBService;
private static ratingCache: Map<string, number | null> = new Map(); private static ratingCache: Map<string, number | null> = new Map();
private apiKey: string = DEFAULT_API_KEY;
private useCustomKey: boolean = false;
private apiKeyLoaded: boolean = false;
private constructor() {} private constructor() {
this.loadApiKey();
}
static getInstance(): TMDBService { static getInstance(): TMDBService {
if (!TMDBService.instance) { if (!TMDBService.instance) {
@ -79,13 +88,54 @@ export class TMDBService {
return TMDBService.instance; return TMDBService.instance;
} }
private getHeaders() { private async loadApiKey() {
try {
const [savedKey, savedUseCustomKey] = await Promise.all([
AsyncStorage.getItem(TMDB_API_KEY_STORAGE_KEY),
AsyncStorage.getItem(USE_CUSTOM_TMDB_API_KEY)
]);
this.useCustomKey = savedUseCustomKey === 'true';
if (this.useCustomKey && savedKey) {
this.apiKey = savedKey;
logger.log('Using custom TMDb API key');
} else {
this.apiKey = DEFAULT_API_KEY;
logger.log('Using default TMDb API key');
}
this.apiKeyLoaded = true;
} catch (error) {
logger.error('Failed to load TMDb API key from storage, using default:', error);
this.apiKey = DEFAULT_API_KEY;
this.apiKeyLoaded = true;
}
}
private async getHeaders() {
// Ensure API key is loaded before returning headers
if (!this.apiKeyLoaded) {
await this.loadApiKey();
}
return { return {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
} }
private async getParams(additionalParams = {}) {
// Ensure API key is loaded before returning params
if (!this.apiKeyLoaded) {
await this.loadApiKey();
}
return {
api_key: this.apiKey,
...additionalParams
};
}
private generateRatingCacheKey(showName: string, seasonNumber: number, episodeNumber: number): string { private generateRatingCacheKey(showName: string, seasonNumber: number, episodeNumber: number): string {
return `${showName.toLowerCase()}_s${seasonNumber}_e${episodeNumber}`; return `${showName.toLowerCase()}_s${seasonNumber}_e${episodeNumber}`;
} }
@ -96,13 +146,13 @@ export class TMDBService {
async searchTVShow(query: string): Promise<TMDBShow[]> { async searchTVShow(query: string): Promise<TMDBShow[]> {
try { try {
const response = await axios.get(`${BASE_URL}/search/tv`, { const response = await axios.get(`${BASE_URL}/search/tv`, {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { params: await this.getParams({
query, query,
include_adult: false, include_adult: false,
language: 'en-US', language: 'en-US',
page: 1, page: 1,
}, }),
}); });
return response.data.results; return response.data.results;
} catch (error) { } catch (error) {
@ -117,10 +167,10 @@ export class TMDBService {
async getTVShowDetails(tmdbId: number): Promise<TMDBShow | null> { async getTVShowDetails(tmdbId: number): Promise<TMDBShow | null> {
try { try {
const response = await axios.get(`${BASE_URL}/tv/${tmdbId}`, { const response = await axios.get(`${BASE_URL}/tv/${tmdbId}`, {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { params: await this.getParams({
language: 'en-US', language: 'en-US',
}, }),
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@ -141,7 +191,8 @@ export class TMDBService {
const response = await axios.get( const response = await axios.get(
`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}/episode/${episodeNumber}/external_ids`, `${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}/episode/${episodeNumber}/external_ids`,
{ {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: await this.getParams(),
} }
); );
return response.data; return response.data;
@ -195,10 +246,10 @@ export class TMDBService {
async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string): Promise<TMDBSeason | null> { async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string): Promise<TMDBSeason | null> {
try { try {
const response = await axios.get(`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}`, { const response = await axios.get(`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}`, {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { params: await this.getParams({
language: 'en-US', language: 'en-US',
}, }),
}); });
const season = response.data; const season = response.data;
@ -254,10 +305,10 @@ export class TMDBService {
const response = await axios.get( const response = await axios.get(
`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}/episode/${episodeNumber}`, `${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}/episode/${episodeNumber}`,
{ {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { params: await this.getParams({
language: 'en-US', language: 'en-US',
}, }),
} }
); );
return response.data; return response.data;
@ -295,11 +346,11 @@ export class TMDBService {
const baseImdbId = imdbId.split(':')[0]; const baseImdbId = imdbId.split(':')[0];
const response = await axios.get(`${BASE_URL}/find/${baseImdbId}`, { const response = await axios.get(`${BASE_URL}/find/${baseImdbId}`, {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { params: await this.getParams({
external_source: 'imdb_id', external_source: 'imdb_id',
language: 'en-US', language: 'en-US',
}, }),
}); });
// Check TV results first // Check TV results first
@ -402,10 +453,10 @@ export class TMDBService {
async getCredits(tmdbId: number, type: string) { async getCredits(tmdbId: number, type: string) {
try { try {
const response = await axios.get(`${BASE_URL}/${type === 'series' ? 'tv' : 'movie'}/${tmdbId}/credits`, { const response = await axios.get(`${BASE_URL}/${type === 'series' ? 'tv' : 'movie'}/${tmdbId}/credits`, {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { params: await this.getParams({
language: 'en-US', language: 'en-US',
}, }),
}); });
return { return {
cast: response.data.cast || [], cast: response.data.cast || [],
@ -420,10 +471,10 @@ export class TMDBService {
async getPersonDetails(personId: number) { async getPersonDetails(personId: number) {
try { try {
const response = await axios.get(`${BASE_URL}/person/${personId}`, { const response = await axios.get(`${BASE_URL}/person/${personId}`, {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { params: await this.getParams({
language: 'en-US', language: 'en-US',
}, }),
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@ -440,7 +491,8 @@ export class TMDBService {
const response = await axios.get( const response = await axios.get(
`${BASE_URL}/tv/${tmdbId}/external_ids`, `${BASE_URL}/tv/${tmdbId}/external_ids`,
{ {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: await this.getParams(),
} }
); );
return response.data; return response.data;
@ -451,14 +503,14 @@ export class TMDBService {
} }
async getRecommendations(type: 'movie' | 'tv', tmdbId: string): Promise<any[]> { async getRecommendations(type: 'movie' | 'tv', tmdbId: string): Promise<any[]> {
if (!API_KEY) { if (!this.apiKey) {
logger.error('TMDB API key not set'); logger.error('TMDB API key not set');
return []; return [];
} }
try { try {
const response = await axios.get(`${BASE_URL}/${type}/${tmdbId}/recommendations`, { const response = await axios.get(`${BASE_URL}/${type}/${tmdbId}/recommendations`, {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { language: 'en-US' } params: await this.getParams({ language: 'en-US' })
}); });
return response.data.results || []; return response.data.results || [];
} catch (error) { } catch (error) {
@ -470,13 +522,13 @@ export class TMDBService {
async searchMulti(query: string): Promise<any[]> { async searchMulti(query: string): Promise<any[]> {
try { try {
const response = await axios.get(`${BASE_URL}/search/multi`, { const response = await axios.get(`${BASE_URL}/search/multi`, {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { params: await this.getParams({
query, query,
include_adult: false, include_adult: false,
language: 'en-US', language: 'en-US',
page: 1, page: 1,
}, }),
}); });
return response.data.results; return response.data.results;
} catch (error) { } catch (error) {
@ -485,25 +537,189 @@ export class TMDBService {
} }
} }
/**
* Get movie details by TMDB ID
*/
async getMovieDetails(movieId: string): Promise<any> { async getMovieDetails(movieId: string): Promise<any> {
try { try {
const response = await axios.get(`${BASE_URL}/movie/${movieId}`, { const response = await axios.get(`${BASE_URL}/movie/${movieId}`, {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { language: 'en-US' } params: await this.getParams({
language: 'en-US',
append_to_response: 'external_ids' // Append external IDs
}),
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
logger.error('Error fetching movie details:', error); logger.error('Failed to get movie details:', error);
return null; return null;
} }
} }
/**
* Get movie images (logos, posters, backdrops) by TMDB ID
*/
async getMovieImages(movieId: number | string): Promise<string | null> {
try {
const response = await axios.get(`${BASE_URL}/movie/${movieId}/images`, {
headers: await this.getHeaders(),
params: await this.getParams({
include_image_language: 'en,null'
}),
});
const images = response.data;
if (images && images.logos && images.logos.length > 0) {
// First prioritize English SVG logos
const enSvgLogo = images.logos.find((logo: any) =>
logo.file_path &&
logo.file_path.endsWith('.svg') &&
logo.iso_639_1 === 'en'
);
if (enSvgLogo) {
return this.getImageUrl(enSvgLogo.file_path);
}
// Then English PNG logos
const enPngLogo = images.logos.find((logo: any) =>
logo.file_path &&
logo.file_path.endsWith('.png') &&
logo.iso_639_1 === 'en'
);
if (enPngLogo) {
return this.getImageUrl(enPngLogo.file_path);
}
// Then any English logo
const enLogo = images.logos.find((logo: any) =>
logo.iso_639_1 === 'en'
);
if (enLogo) {
return this.getImageUrl(enLogo.file_path);
}
// Fallback to any SVG logo
const svgLogo = images.logos.find((logo: any) =>
logo.file_path && logo.file_path.endsWith('.svg')
);
if (svgLogo) {
return this.getImageUrl(svgLogo.file_path);
}
// Then any PNG logo
const pngLogo = images.logos.find((logo: any) =>
logo.file_path && logo.file_path.endsWith('.png')
);
if (pngLogo) {
return this.getImageUrl(pngLogo.file_path);
}
// Last resort: any logo
return this.getImageUrl(images.logos[0].file_path);
}
return null; // No logos found
} catch (error) {
// Log error but don't throw, just return null if fetching images fails
logger.error(`Failed to get movie images for ID ${movieId}:`, error);
return null;
}
}
/**
* Get TV show images (logos, posters, backdrops) by TMDB ID
*/
async getTvShowImages(showId: number | string): Promise<string | null> {
try {
const response = await axios.get(`${BASE_URL}/tv/${showId}/images`, {
headers: await this.getHeaders(),
params: await this.getParams({
include_image_language: 'en,null'
}),
});
const images = response.data;
if (images && images.logos && images.logos.length > 0) {
// First prioritize English SVG logos
const enSvgLogo = images.logos.find((logo: any) =>
logo.file_path &&
logo.file_path.endsWith('.svg') &&
logo.iso_639_1 === 'en'
);
if (enSvgLogo) {
return this.getImageUrl(enSvgLogo.file_path);
}
// Then English PNG logos
const enPngLogo = images.logos.find((logo: any) =>
logo.file_path &&
logo.file_path.endsWith('.png') &&
logo.iso_639_1 === 'en'
);
if (enPngLogo) {
return this.getImageUrl(enPngLogo.file_path);
}
// Then any English logo
const enLogo = images.logos.find((logo: any) =>
logo.iso_639_1 === 'en'
);
if (enLogo) {
return this.getImageUrl(enLogo.file_path);
}
// Fallback to any SVG logo
const svgLogo = images.logos.find((logo: any) =>
logo.file_path && logo.file_path.endsWith('.svg')
);
if (svgLogo) {
return this.getImageUrl(svgLogo.file_path);
}
// Then any PNG logo
const pngLogo = images.logos.find((logo: any) =>
logo.file_path && logo.file_path.endsWith('.png')
);
if (pngLogo) {
return this.getImageUrl(pngLogo.file_path);
}
// Last resort: any logo
return this.getImageUrl(images.logos[0].file_path);
}
return null; // No logos found
} catch (error) {
// Log error but don't throw, just return null if fetching images fails
logger.error(`Failed to get TV show images for ID ${showId}:`, error);
return null;
}
}
/**
* Get content logo based on type (movie or TV show)
*/
async getContentLogo(type: 'movie' | 'tv', id: number | string): Promise<string | null> {
try {
return type === 'movie'
? await this.getMovieImages(id)
: await this.getTvShowImages(id);
} catch (error) {
logger.error(`Failed to get content logo for ${type} ID ${id}:`, error);
return null;
}
}
/**
* Get content certification rating
*/
async getCertification(type: string, id: number): Promise<string | null> { async getCertification(type: string, id: number): Promise<string | null> {
try { try {
// Different endpoints for movies and TV shows // Different endpoints for movies and TV shows
const endpoint = type === 'movie' ? 'movie' : 'tv'; const endpoint = type === 'movie' ? 'movie' : 'tv';
const response = await axios.get(`${BASE_URL}/${endpoint}/${id}/release_dates`, { const response = await axios.get(`${BASE_URL}/${endpoint}/${id}/release_dates`, {
headers: this.getHeaders() headers: await this.getHeaders(),
params: await this.getParams()
}); });
if (response.data && response.data.results) { if (response.data && response.data.results) {
@ -537,10 +753,10 @@ export class TMDBService {
async getTrending(type: 'movie' | 'tv', timeWindow: 'day' | 'week'): Promise<TMDBTrendingResult[]> { async getTrending(type: 'movie' | 'tv', timeWindow: 'day' | 'week'): Promise<TMDBTrendingResult[]> {
try { try {
const response = await axios.get(`${BASE_URL}/trending/${type}/${timeWindow}`, { const response = await axios.get(`${BASE_URL}/trending/${type}/${timeWindow}`, {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: { params: await this.getParams({
language: 'en-US', language: 'en-US',
}, }),
}); });
// Get external IDs for each trending item // Get external IDs for each trending item
@ -551,7 +767,8 @@ export class TMDBService {
const externalIdsResponse = await axios.get( const externalIdsResponse = await axios.get(
`${BASE_URL}/${type}/${item.id}/external_ids`, `${BASE_URL}/${type}/${item.id}/external_ids`,
{ {
headers: this.getHeaders(), headers: await this.getHeaders(),
params: await this.getParams(),
} }
); );
return { return {
@ -571,6 +788,42 @@ export class TMDBService {
return []; return [];
} }
} }
/**
* Get the list of official movie genres from TMDB
*/
async getMovieGenres(): Promise<{ id: number; name: string }[]> {
try {
const response = await axios.get(`${BASE_URL}/genre/movie/list`, {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
}),
});
return response.data.genres || [];
} catch (error) {
logger.error('Failed to fetch movie genres:', error);
return [];
}
}
/**
* Get the list of official TV genres from TMDB
*/
async getTvGenres(): Promise<{ id: number; name: string }[]> {
try {
const response = await axios.get(`${BASE_URL}/genre/tv/list`, {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
}),
});
return response.data.genres || [];
} catch (error) {
logger.error('Failed to fetch TV genres:', error);
return [];
}
}
} }
export const tmdbService = TMDBService.getInstance(); export const tmdbService = TMDBService.getInstance();

View file

10
src/types/images.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
declare module '*.png' {
const content: any;
export default content;
}
declare module '*.svg' {
import { SvgProps } from 'react-native-svg';
const content: React.FC<SvgProps>;
export default content;
}