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.
27
App.tsx
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
assets/WhatsApp Image 2025-04-17 at 03.45.56_f37ab22f.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/rating-icons/Metacritic.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
5
assets/rating-icons/Metacritic.svg
Normal 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 |
20
assets/rating-icons/RottenTomatoes.svg
Normal 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 |
BIN
assets/rating-icons/audienscore.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
assets/rating-icons/imdb.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
34
assets/rating-icons/letterboxd.svg
Normal 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 |
1
assets/rating-icons/tmdb.svg
Normal 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 |
1
assets/rating-icons/trakt.svg
Normal 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
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
314
src/components/metadata/RatingsSection.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
80
src/contexts/GenreContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
227
src/hooks/useFeaturedContent.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
87
src/hooks/useHomeCatalogs.ts
Normal 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 };
|
||||||
|
}
|
||||||
49
src/hooks/useMDBListRatings.ts
Normal 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 };
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
318
src/screens/HeroCatalogsScreen.tsx
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
472
src/screens/HomeScreenSettings.tsx
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
824
src/screens/MDBListSettingsScreen.tsx
Normal 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;
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
621
src/screens/TMDBSettingsScreen.tsx
Normal 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;
|
||||||
182
src/services/mdblistService.ts
Normal 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();
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
0
src/temp_settings_screen.tsx
Normal file
10
src/types/images.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||