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';
|
||||
import 'react-native-reanimated';
|
||||
import { CatalogProvider } from './src/contexts/CatalogContext';
|
||||
import { GenreProvider } from './src/contexts/GenreContext';
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
// Always use dark mode
|
||||
|
|
@ -27,18 +28,20 @@ function App(): React.JSX.Element {
|
|||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<CatalogProvider>
|
||||
<PaperProvider theme={CustomDarkTheme}>
|
||||
<NavigationContainer theme={CustomNavigationDarkTheme}>
|
||||
<View style={[styles.container, { backgroundColor: '#000000' }]}>
|
||||
<StatusBar
|
||||
style="light"
|
||||
/>
|
||||
<AppNavigator />
|
||||
</View>
|
||||
</NavigationContainer>
|
||||
</PaperProvider>
|
||||
</CatalogProvider>
|
||||
<GenreProvider>
|
||||
<CatalogProvider>
|
||||
<PaperProvider theme={CustomDarkTheme}>
|
||||
<NavigationContainer theme={CustomNavigationDarkTheme}>
|
||||
<View style={[styles.container, { backgroundColor: '#000000' }]}>
|
||||
<StatusBar
|
||||
style="light"
|
||||
/>
|
||||
<AppNavigator />
|
||||
</View>
|
||||
</NavigationContainer>
|
||||
</PaperProvider>
|
||||
</CatalogProvider>
|
||||
</GenreProvider>
|
||||
</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": {
|
||||
"@expo/metro-runtime": "~4.0.1",
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@gorhom/bottom-sheet": "^5.1.2",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native-community/slider": "^4.5.6",
|
||||
|
|
@ -45,7 +46,7 @@
|
|||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-safe-area-context": "4.12.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-web": "~0.19.13",
|
||||
"subsrt": "^1.1.1"
|
||||
|
|
@ -2881,6 +2882,45 @@
|
|||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
|
||||
|
|
@ -4466,7 +4506,7 @@
|
|||
"version": "0.72.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz",
|
||||
"integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native/virtualized-lists": "^0.72.4",
|
||||
|
|
@ -4487,7 +4527,7 @@
|
|||
"version": "0.72.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz",
|
||||
"integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4",
|
||||
|
|
@ -10731,9 +10771,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-native-svg": {
|
||||
"version": "15.8.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.8.0.tgz",
|
||||
"integrity": "sha512-KHJzKpgOjwj1qeZzsBjxNdoIgv2zNCO9fVcoq2TEhTRsVV5DGTZ9JzUZwybd7q4giT/H3RdtqC3u44dWdO0Ffw==",
|
||||
"version": "15.11.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.11.2.tgz",
|
||||
"integrity": "sha512-+YfF72IbWQUKzCIydlijV1fLuBsQNGMT6Da2kFlo1sh+LE3BIm/2Q7AR1zAAR6L0BFLi1WaQPLfFUC9bNZpOmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"@expo/metro-runtime": "~4.0.1",
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@gorhom/bottom-sheet": "^5.1.2",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native-community/slider": "^4.5.6",
|
||||
|
|
@ -46,7 +47,7 @@
|
|||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-safe-area-context": "4.12.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-web": "~0.19.13",
|
||||
"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)}
|
||||
>
|
||||
<View style={styles.castImageContainer}>
|
||||
{member.profile_path && tmdbService.getImageUrl(member.profile_path, 'w185') ? (
|
||||
{member.profile_path ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: tmdbService.getImageUrl(member.profile_path, 'w185')!
|
||||
uri: `https://image.tmdb.org/t/p/w185${member.profile_path}`
|
||||
}}
|
||||
style={styles.castImage}
|
||||
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 { storageService } from '../../services/storageService';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
|
||||
interface SeriesContentProps {
|
||||
episodes: Episode[];
|
||||
|
|
@ -246,27 +247,49 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{renderSeasonSelector()}
|
||||
|
||||
<Text style={styles.sectionTitle}>
|
||||
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
style={styles.episodeList}
|
||||
contentContainerStyle={[
|
||||
styles.episodeListContent,
|
||||
isTablet && styles.episodeListContentTablet
|
||||
]}
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(500).delay(100)}
|
||||
>
|
||||
{isTablet ? (
|
||||
<View style={styles.episodeGrid}>
|
||||
{episodes.map(episode => renderEpisodeCard(episode))}
|
||||
</View>
|
||||
) : (
|
||||
episodes.map(episode => renderEpisodeCard(episode))
|
||||
)}
|
||||
</ScrollView>
|
||||
{renderSeasonSelector()}
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(500).delay(200)}
|
||||
>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
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 () => {
|
||||
setLoadingCast(true);
|
||||
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);
|
||||
if (cachedCast) {
|
||||
setCast(cachedCast);
|
||||
|
|
@ -277,12 +303,172 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
|||
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
|
||||
const [content, castData] = await Promise.allSettled([
|
||||
// Load content with timeout and retry
|
||||
withRetry(async () => {
|
||||
const result = await withTimeout(
|
||||
catalogService.getContentDetails(type, id),
|
||||
catalogService.getContentDetails(type, actualId),
|
||||
API_TIMEOUT
|
||||
);
|
||||
return result;
|
||||
|
|
@ -298,6 +484,41 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
|||
setInLibrary(isInLib);
|
||||
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') {
|
||||
// Load series data in parallel with other data
|
||||
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';
|
||||
|
||||
// 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 {
|
||||
enableDarkMode: boolean;
|
||||
enableNotifications: boolean;
|
||||
|
|
@ -9,6 +28,9 @@ export interface AppSettings {
|
|||
enableBackgroundPlayback: boolean;
|
||||
cacheLimit: number;
|
||||
useExternalPlayer: boolean;
|
||||
showHeroSection: boolean;
|
||||
featuredContentSource: 'tmdb' | 'catalogs';
|
||||
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
|
|
@ -19,6 +41,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
enableBackgroundPlayback: false,
|
||||
cacheLimit: 1024,
|
||||
useExternalPlayer: false,
|
||||
showHeroSection: true,
|
||||
featuredContentSource: 'tmdb',
|
||||
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
|
||||
};
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'app_settings';
|
||||
|
|
@ -28,6 +53,13 @@ export const useSettings = () => {
|
|||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
|
||||
// Subscribe to settings changes
|
||||
const unsubscribe = settingsEmitter.addListener(() => {
|
||||
loadSettings();
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
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,
|
||||
value: AppSettings[K]
|
||||
) => {
|
||||
|
|
@ -49,10 +81,12 @@ export const useSettings = () => {
|
|||
try {
|
||||
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings));
|
||||
setSettings(newSettings);
|
||||
// Notify all subscribers that settings have changed
|
||||
settingsEmitter.emit();
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
};
|
||||
}, [settings]);
|
||||
|
||||
return {
|
||||
settings,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { MD3Theme } from 'react-native-paper';
|
|||
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { colors } from '../styles/colors';
|
||||
import { NuvioHeader } from '../components/NuvioHeader';
|
||||
import { Stream } from '../types/streams';
|
||||
|
|
@ -27,6 +28,10 @@ import CatalogSettingsScreen from '../screens/CatalogSettingsScreen';
|
|||
import StreamsScreen from '../screens/StreamsScreen';
|
||||
import CalendarScreen from '../screens/CalendarScreen';
|
||||
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
|
||||
export type RootStackParamList = {
|
||||
|
|
@ -76,6 +81,10 @@ export type RootStackParamList = {
|
|||
Addons: undefined;
|
||||
CatalogSettings: undefined;
|
||||
NotificationSettings: undefined;
|
||||
MDBListSettings: undefined;
|
||||
TMDBSettings: undefined;
|
||||
HomeScreenSettings: undefined;
|
||||
HeroCatalogs: undefined;
|
||||
};
|
||||
|
||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
|
@ -85,7 +94,6 @@ export type MainTabParamList = {
|
|||
Home: undefined;
|
||||
Discover: undefined;
|
||||
Library: undefined;
|
||||
Addons: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
|
|
@ -320,27 +328,46 @@ const MainTabs = () => {
|
|||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 75,
|
||||
height: 85,
|
||||
backgroundColor: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<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%',
|
||||
}}
|
||||
/>
|
||||
{Platform.OS === 'ios' ? (
|
||||
<BlurView
|
||||
tint="dark"
|
||||
intensity={75}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
borderTopColor: 'rgba(255,255,255,0.2)',
|
||||
borderTopWidth: 0.5,
|
||||
shadowColor: '#000',
|
||||
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
|
||||
style={{
|
||||
height: '100%',
|
||||
paddingBottom: 10,
|
||||
paddingBottom: 20,
|
||||
paddingTop: 12,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
|
|
@ -380,9 +407,6 @@ const MainTabs = () => {
|
|||
case 'Library':
|
||||
iconName = 'play-box-multiple';
|
||||
break;
|
||||
case 'Addons':
|
||||
iconName = 'puzzle';
|
||||
break;
|
||||
case 'Settings':
|
||||
iconName = 'cog';
|
||||
break;
|
||||
|
|
@ -442,9 +466,6 @@ const MainTabs = () => {
|
|||
case 'Library':
|
||||
iconName = 'play-box-multiple';
|
||||
break;
|
||||
case 'Addons':
|
||||
iconName = 'puzzle';
|
||||
break;
|
||||
case 'Settings':
|
||||
iconName = 'cog';
|
||||
break;
|
||||
|
|
@ -459,8 +480,8 @@ const MainTabs = () => {
|
|||
backgroundColor: 'transparent',
|
||||
borderTopWidth: 0,
|
||||
elevation: 0,
|
||||
height: 75,
|
||||
paddingBottom: 10,
|
||||
height: 85,
|
||||
paddingBottom: 20,
|
||||
paddingTop: 12,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
|
|
@ -469,20 +490,38 @@ const MainTabs = () => {
|
|||
marginTop: 0,
|
||||
},
|
||||
tabBarBackground: () => (
|
||||
<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%',
|
||||
}}
|
||||
/>
|
||||
Platform.OS === 'ios' ? (
|
||||
<BlurView
|
||||
tint="dark"
|
||||
intensity={75}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
borderTopColor: 'rgba(255,255,255,0.2)',
|
||||
borderTopWidth: 0.5,
|
||||
shadowColor: '#000',
|
||||
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,
|
||||
headerShown: route.name === 'Home',
|
||||
|
|
@ -509,13 +548,6 @@ const MainTabs = () => {
|
|||
tabBarLabel: 'Library'
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Addons"
|
||||
component={AddonsScreen as any}
|
||||
options={{
|
||||
tabBarLabel: 'Addons'
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen as any}
|
||||
|
|
@ -583,6 +615,36 @@ const AppNavigator = () => {
|
|||
name="CatalogSettings"
|
||||
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
|
||||
name="ShowRatings"
|
||||
component={ShowRatingsScreen}
|
||||
|
|
@ -606,6 +668,36 @@ const AppNavigator = () => {
|
|||
name="NotificationSettings"
|
||||
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>
|
||||
</PaperProvider>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
StatusBar,
|
||||
RefreshControl,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
|
|
@ -17,6 +18,7 @@ import { RootStackParamList } from '../navigation/AppNavigator';
|
|||
import { Meta, stremioService } from '../services/stremioService';
|
||||
import { colors } from '../styles';
|
||||
import { Image } from 'expo-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type CatalogScreenProps = {
|
||||
|
|
@ -24,7 +26,7 @@ type CatalogScreenProps = {
|
|||
navigation: StackNavigationProp<RootStackParamList, 'Catalog'>;
|
||||
};
|
||||
|
||||
// Consistent spacing variables
|
||||
// Constants for layout
|
||||
const SPACING = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
|
|
@ -33,11 +35,13 @@ const SPACING = {
|
|||
xl: 24,
|
||||
};
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
// Screen dimensions and grid layout
|
||||
const { width } = Dimensions.get('window');
|
||||
const NUM_COLUMNS = 3;
|
||||
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 { addonId, type, id, name, genreFilter } = route.params;
|
||||
|
|
@ -47,7 +51,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Force dark mode instead of using color scheme
|
||||
// Force dark mode
|
||||
const isDarkMode = true;
|
||||
|
||||
const loadItems = useCallback(async (pageNum: number, shouldRefresh: boolean = false) => {
|
||||
|
|
@ -160,9 +164,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
|
||||
useEffect(() => {
|
||||
loadItems(1);
|
||||
// Set the header title
|
||||
navigation.setOptions({ title: name || `${type} catalog` });
|
||||
}, [loadItems, navigation, name, type]);
|
||||
}, [loadItems]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setPage(1);
|
||||
|
|
@ -185,7 +187,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
activeOpacity={0.7}
|
||||
>
|
||||
<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}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
|
|
@ -209,8 +211,9 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.centered}>
|
||||
<MaterialIcons name="search-off" size={56} color={colors.mediumGray} />
|
||||
<Text style={styles.emptyText}>
|
||||
No content found for the selected genre
|
||||
No content found
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
|
|
@ -223,6 +226,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
|
||||
const renderErrorState = () => (
|
||||
<View style={styles.centered}>
|
||||
<MaterialIcons name="error-outline" size={56} color={colors.mediumGray} />
|
||||
<Text style={styles.errorText}>
|
||||
{error}
|
||||
</Text>
|
||||
|
|
@ -238,13 +242,24 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
const renderLoadingState = () => (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>Loading content...</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<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()}
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
|
@ -253,7 +268,17 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
if (error && items.length === 0) {
|
||||
return (
|
||||
<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()}
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
|
@ -261,7 +286,18 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
|
||||
return (
|
||||
<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 ? (
|
||||
<FlatList
|
||||
data={items}
|
||||
|
|
@ -287,6 +323,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
}
|
||||
contentContainerStyle={styles.list}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
) : renderEmptyState()}
|
||||
</SafeAreaView>
|
||||
|
|
@ -298,29 +335,60 @@ const styles = StyleSheet.create({
|
|||
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,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 34,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
list: {
|
||||
padding: SPACING.md,
|
||||
padding: SPACING.lg,
|
||||
paddingTop: SPACING.sm,
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
item: {
|
||||
width: ITEM_WIDTH,
|
||||
marginBottom: SPACING.md,
|
||||
borderRadius: 8,
|
||||
marginBottom: SPACING.lg,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.elevation2,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
poster: {
|
||||
width: '100%',
|
||||
aspectRatio: 2/3,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.transparentLight,
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
backgroundColor: colors.elevation3,
|
||||
},
|
||||
itemContent: {
|
||||
padding: SPACING.xs,
|
||||
padding: SPACING.sm,
|
||||
},
|
||||
title: {
|
||||
marginTop: SPACING.xs,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
|
|
@ -329,7 +397,7 @@ const styles = StyleSheet.create({
|
|||
releaseInfo: {
|
||||
fontSize: 12,
|
||||
marginTop: SPACING.xs,
|
||||
color: colors.lightGray,
|
||||
color: colors.mediumGray,
|
||||
},
|
||||
footer: {
|
||||
padding: SPACING.lg,
|
||||
|
|
@ -358,14 +426,21 @@ const styles = StyleSheet.create({
|
|||
color: colors.white,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: SPACING.md,
|
||||
marginTop: SPACING.md,
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.white,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: SPACING.md,
|
||||
marginTop: SPACING.md,
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
loadingText: {
|
||||
color: colors.white,
|
||||
fontSize: 16,
|
||||
marginTop: SPACING.lg,
|
||||
}
|
||||
});
|
||||
|
||||
export default CatalogScreen;
|
||||
|
|
@ -7,6 +7,9 @@ import {
|
|||
Switch,
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
StatusBar,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
|
@ -29,13 +32,25 @@ interface CatalogSettingsStorage {
|
|||
_lastUpdate: number;
|
||||
}
|
||||
|
||||
interface GroupedCatalogs {
|
||||
[addonId: string]: {
|
||||
name: string;
|
||||
catalogs: CatalogSetting[];
|
||||
expanded: boolean;
|
||||
enabledCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
const CatalogSettingsScreen = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [settings, setSettings] = useState<CatalogSetting[]>([]);
|
||||
const [groupedSettings, setGroupedSettings] = useState<GroupedCatalogs>({});
|
||||
const navigation = useNavigation();
|
||||
const { refreshCatalogs } = useCatalogContext();
|
||||
const isDarkMode = true; // Force dark mode
|
||||
|
||||
// Load saved settings and available catalogs
|
||||
const loadSettings = useCallback(async () => {
|
||||
|
|
@ -61,37 +76,17 @@ const CatalogSettingsScreen = () => {
|
|||
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
||||
|
||||
// Format catalog name
|
||||
let displayName = catalog.name;
|
||||
let displayName = catalog.name || catalog.id;
|
||||
|
||||
// Clean up the name and ensure type is included
|
||||
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
|
||||
// If catalog is a movie or series catalog, make that clear
|
||||
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, {
|
||||
addonId: addon.id,
|
||||
catalogId: catalog.id,
|
||||
type: catalog.type,
|
||||
name: `${addon.name} - ${displayName}`,
|
||||
enabled: savedCatalogs[settingKey] ?? true // Enable by default
|
||||
name: displayName,
|
||||
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
|
||||
const sortedCatalogs = availableCatalogs.sort((a, b) => {
|
||||
const [addonNameA] = a.name.split(' - ');
|
||||
const [addonNameB] = b.name.split(' - ');
|
||||
// Group settings by addon name
|
||||
const grouped: GroupedCatalogs = {};
|
||||
|
||||
availableCatalogs.forEach(setting => {
|
||||
const addon = addons.find(a => a.id === setting.addonId);
|
||||
if (!addon) return;
|
||||
|
||||
if (addonNameA !== addonNameB) {
|
||||
return addonNameA.localeCompare(addonNameB);
|
||||
if (!grouped[setting.addonId]) {
|
||||
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) {
|
||||
logger.error('Failed to load catalog settings:', error);
|
||||
} finally {
|
||||
|
|
@ -137,85 +144,158 @@ const CatalogSettingsScreen = () => {
|
|||
};
|
||||
|
||||
// Toggle individual catalog
|
||||
const toggleCatalog = (setting: CatalogSetting) => {
|
||||
const newSettings = settings.map(s => {
|
||||
if (s.addonId === setting.addonId &&
|
||||
s.type === setting.type &&
|
||||
s.catalogId === setting.catalogId) {
|
||||
return { ...s, enabled: !s.enabled };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
const toggleCatalog = (addonId: string, index: number) => {
|
||||
const newSettings = [...settings];
|
||||
const catalogsForAddon = groupedSettings[addonId].catalogs;
|
||||
const setting = catalogsForAddon[index];
|
||||
|
||||
const updatedSetting = {
|
||||
...setting,
|
||||
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);
|
||||
setGroupedSettings(newGroupedSettings);
|
||||
saveSettings(newSettings);
|
||||
};
|
||||
|
||||
// Toggle expansion of a group
|
||||
const toggleExpansion = (addonId: string) => {
|
||||
setGroupedSettings(prev => ({
|
||||
...prev,
|
||||
[addonId]: {
|
||||
...prev[addonId],
|
||||
expanded: !prev[addonId].expanded
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
<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}>Catalogs</Text>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
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>
|
||||
<Text style={styles.headerTitle}>Catalog Settings</Text>
|
||||
</View>
|
||||
<Text style={styles.headerTitle}>Catalogs</Text>
|
||||
|
||||
<ScrollView style={styles.scrollView}>
|
||||
<Text style={styles.description}>
|
||||
Choose which catalogs to show on your home screen. Changes will take effect immediately.
|
||||
</Text>
|
||||
|
||||
{Object.entries(groupedSettings).map(([addonId, addonCatalogs]) => (
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
|
||||
{Object.entries(groupedSettings).map(([addonId, group]) => (
|
||||
<View key={addonId} style={styles.addonSection}>
|
||||
<Text style={styles.addonTitle}>
|
||||
{addonCatalogs[0].name.split(' - ')[0]}
|
||||
{group.name.toUpperCase()}
|
||||
</Text>
|
||||
{addonCatalogs.map((setting) => (
|
||||
<View key={`${setting.addonId}:${setting.type}:${setting.catalogId}`} style={styles.catalogItem}>
|
||||
<Text style={styles.catalogName}>
|
||||
{setting.name.split(' - ')[1]}
|
||||
</Text>
|
||||
<Switch
|
||||
value={setting.enabled}
|
||||
onValueChange={() => toggleCatalog(setting)}
|
||||
trackColor={{ false: colors.mediumGray, true: colors.primary }}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={styles.card}>
|
||||
<TouchableOpacity
|
||||
style={styles.groupHeader}
|
||||
onPress={() => toggleExpansion(addonId)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.groupTitle}>Catalogs</Text>
|
||||
<View style={styles.groupHeaderRight}>
|
||||
<Text style={styles.enabledCount}>
|
||||
{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 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>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
|
|
@ -225,35 +305,77 @@ const styles = StyleSheet.create({
|
|||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
|
||||
},
|
||||
backButton: {
|
||||
marginRight: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
},
|
||||
backText: {
|
||||
fontSize: 17,
|
||||
fontWeight: '400',
|
||||
color: colors.primary,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.text,
|
||||
fontSize: 34,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
description: {
|
||||
padding: 16,
|
||||
fontSize: 14,
|
||||
color: colors.mediumGray,
|
||||
scrollContent: {
|
||||
paddingBottom: 32,
|
||||
},
|
||||
addonSection: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
addonTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: colors.text,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.mediumGray,
|
||||
marginHorizontal: 16,
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -261,14 +383,33 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
catalogInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
catalogName: {
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
fontSize: 15,
|
||||
color: colors.white,
|
||||
marginBottom: 2,
|
||||
},
|
||||
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 {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Dimensions,
|
||||
ScrollView,
|
||||
Platform,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { useNavigation } 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 { catalogService, StreamingContent, CatalogContent } from '../services/catalogService';
|
||||
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 { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { logger } from '../utils/logger';
|
||||
import { BlurView } from 'expo-blur';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
|
|
@ -65,28 +67,207 @@ const COMMON_GENRES = [
|
|||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
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 { width } = Dimensions.get('window');
|
||||
const itemWidth = (width - 60) / 4; // 4 items per row with spacing
|
||||
// Memoized child components
|
||||
const CategoryButton = React.memo(({
|
||||
category,
|
||||
isSelected,
|
||||
onPress
|
||||
}: {
|
||||
category: Category;
|
||||
isSelected: boolean;
|
||||
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: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
backgroundColor: colors.darkBackground,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -96,66 +277,88 @@ const DiscoverScreen = () => {
|
|||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
color: colors.white,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
searchButton: {
|
||||
padding: 4,
|
||||
marginLeft: 16,
|
||||
padding: 10,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
categoryContainer: {
|
||||
paddingVertical: 12,
|
||||
paddingVertical: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
borderBottomColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
categoriesContent: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 12,
|
||||
gap: 12,
|
||||
paddingHorizontal: 20,
|
||||
gap: 16,
|
||||
},
|
||||
categoryButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
marginHorizontal: 4,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.lightGray,
|
||||
backgroundColor: 'transparent',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
flexDirection: 'row',
|
||||
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: {
|
||||
marginRight: 4,
|
||||
selectedCategoryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
categoryText: {
|
||||
color: colors.mediumGray,
|
||||
fontWeight: '500',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
},
|
||||
selectedCategoryText: {
|
||||
color: colors.white,
|
||||
fontWeight: '700',
|
||||
},
|
||||
genreContainer: {
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
paddingTop: 20,
|
||||
paddingBottom: 12,
|
||||
zIndex: 10,
|
||||
},
|
||||
genresScrollView: {
|
||||
paddingHorizontal: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
genreButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
marginRight: 8,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.lightGray,
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 10,
|
||||
marginRight: 12,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
selectedGenreButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
genreText: {
|
||||
color: colors.mediumGray,
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
},
|
||||
selectedGenreText: {
|
||||
color: colors.white,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
|
|
@ -165,34 +368,36 @@ const DiscoverScreen = () => {
|
|||
paddingVertical: 8,
|
||||
},
|
||||
catalogContainer: {
|
||||
marginBottom: 24,
|
||||
marginBottom: 32,
|
||||
},
|
||||
catalogHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
catalogTitleContainer: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
catalogTitleBar: {
|
||||
width: 32,
|
||||
height: 3,
|
||||
backgroundColor: colors.primary,
|
||||
marginTop: 6,
|
||||
borderRadius: 2,
|
||||
},
|
||||
catalogTitle: {
|
||||
fontSize: 18,
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
marginBottom: 2,
|
||||
},
|
||||
titleUnderline: {
|
||||
height: 2,
|
||||
width: 40,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 2,
|
||||
},
|
||||
seeAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
seeAllText: {
|
||||
color: colors.primary,
|
||||
|
|
@ -200,18 +405,17 @@ const DiscoverScreen = () => {
|
|||
fontSize: 14,
|
||||
},
|
||||
contentItem: {
|
||||
width: itemWidth,
|
||||
marginHorizontal: 5,
|
||||
marginHorizontal: 0,
|
||||
},
|
||||
posterContainer: {
|
||||
borderRadius: 8,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.transparentLight,
|
||||
elevation: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
elevation: 5,
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
poster: {
|
||||
aspectRatio: 2/3,
|
||||
|
|
@ -222,21 +426,23 @@ const DiscoverScreen = () => {
|
|||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 8,
|
||||
padding: 16,
|
||||
justifyContent: 'flex-end',
|
||||
height: '45%',
|
||||
},
|
||||
contentTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
marginBottom: 2,
|
||||
marginBottom: 4,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
contentYear: {
|
||||
fontSize: 10,
|
||||
color: colors.mediumGray,
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
|
|
@ -245,15 +451,27 @@ const DiscoverScreen = () => {
|
|||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 100,
|
||||
paddingTop: 80,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.mediumGray,
|
||||
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(() => {
|
||||
loadContent(selectedCategory, selectedGenre);
|
||||
}, [selectedCategory, selectedGenre]);
|
||||
|
|
@ -316,204 +534,97 @@ const DiscoverScreen = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCategoryPress = (category: Category) => {
|
||||
const handleCategoryPress = useCallback((category: Category) => {
|
||||
if (category.id !== selectedCategory.id) {
|
||||
setSelectedCategory(category);
|
||||
setSelectedGenre('All'); // Reset to All when changing category
|
||||
}
|
||||
};
|
||||
}, [selectedCategory]);
|
||||
|
||||
const handleGenrePress = (genre: string) => {
|
||||
const handleGenrePress = useCallback((genre: string) => {
|
||||
if (genre !== selectedGenre) {
|
||||
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]);
|
||||
|
||||
const renderContentItem = useCallback(({ item }: { item: StreamingContent }) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
const handleSearchPress = useCallback(() => {
|
||||
navigation.navigate('Search');
|
||||
}, [navigation]);
|
||||
|
||||
const renderCatalog = useCallback(({ item }: { item: GenreCatalog }) => {
|
||||
// Only display the first 4 items in the row
|
||||
const displayItems = item.items.slice(0, 4);
|
||||
|
||||
return (
|
||||
<View style={styles.catalogContainer}>
|
||||
<View style={styles.catalogHeader}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.catalogTitle}>{item.genre}</Text>
|
||||
<View style={styles.titleUnderline} />
|
||||
</View>
|
||||
<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]);
|
||||
// Memoize rendering functions
|
||||
const renderCatalogItem = useCallback(({ item }: { item: GenreCatalog }) => (
|
||||
<CatalogSection
|
||||
catalog={item}
|
||||
selectedCategory={selectedCategory}
|
||||
navigation={navigation}
|
||||
/>
|
||||
), [selectedCategory, navigation]);
|
||||
|
||||
// Memoize list key extractor
|
||||
const catalogKeyExtractor = useCallback((item: GenreCatalog) => item.genre, []);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar
|
||||
barStyle="light-content"
|
||||
backgroundColor={colors.darkBackground}
|
||||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Header Section */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerContent}>
|
||||
<Text style={styles.headerTitle}>
|
||||
Discover
|
||||
</Text>
|
||||
<Text style={styles.headerTitle}>Discover</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleSearchPress}
|
||||
style={styles.searchButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={24}
|
||||
color={colors.white}
|
||||
style={{ opacity: 0.7 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Categories Section */}
|
||||
<View style={styles.categoryContainer}>
|
||||
<View style={styles.categoriesContent}>
|
||||
{CATEGORIES.map((category) => (
|
||||
<View key={category.id}>
|
||||
{renderCategory({ item: category })}
|
||||
</View>
|
||||
<CategoryButton
|
||||
key={category.id}
|
||||
category={category}
|
||||
isSelected={selectedCategory.id === category.id}
|
||||
onPress={() => handleCategoryPress(category)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Genres Section */}
|
||||
<View style={styles.genreContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
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>
|
||||
</View>
|
||||
|
||||
{/* Content Section */}
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
|
|
@ -521,12 +632,14 @@ const DiscoverScreen = () => {
|
|||
) : catalogs.length > 0 ? (
|
||||
<FlatList
|
||||
data={catalogs}
|
||||
renderItem={renderCatalog}
|
||||
keyExtractor={(item) => item.genre}
|
||||
renderItem={renderCatalogItem}
|
||||
keyExtractor={catalogKeyExtractor}
|
||||
contentContainerStyle={styles.catalogsContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialNumToRender={3}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
/>
|
||||
) : (
|
||||
<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 { logger } from '../utils/logger';
|
||||
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
|
||||
interface Category {
|
||||
|
|
@ -119,6 +122,8 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
|
|||
|
||||
const menuStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
}));
|
||||
|
||||
const menuOptions = [
|
||||
|
|
@ -193,7 +198,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
|
|||
<MaterialIcons
|
||||
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
|
||||
size={24}
|
||||
color={isDarkMode ? '#FFFFFF' : '#000000'}
|
||||
color={colors.primary}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.menuOptionText,
|
||||
|
|
@ -279,7 +284,7 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
|
|||
source={{ uri: localItem.poster }}
|
||||
style={styles.poster}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
transition={300}
|
||||
cachePolicy="memory-disk"
|
||||
recyclingKey={`poster-${localItem.id}`}
|
||||
onLoadStart={() => {
|
||||
|
|
@ -303,12 +308,12 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
|
|||
)}
|
||||
{isWatched && (
|
||||
<View style={styles.watchedIndicator}>
|
||||
<MaterialIcons name="check-circle" size={24} color="#00C853" />
|
||||
<MaterialIcons name="check-circle" size={22} color={colors.success} />
|
||||
</View>
|
||||
)}
|
||||
{localItem.inLibrary && (
|
||||
<View style={styles.libraryBadge}>
|
||||
<MaterialIcons name="bookmark" size={16} color="#FFFFFF" />
|
||||
<MaterialIcons name="bookmark" size={16} color={colors.white} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
|
@ -333,114 +338,75 @@ const SAMPLE_CATEGORIES: Category[] = [
|
|||
|
||||
const SkeletonCatalog = () => (
|
||||
<View style={styles.catalogContainer}>
|
||||
<View style={styles.catalogHeader}>
|
||||
<View style={[styles.skeletonBox, { width: 150, height: 24 }]} />
|
||||
<View style={[styles.skeletonBox, { width: 80, height: 20 }]} />
|
||||
<View style={styles.loadingPlaceholder}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.catalogList}>
|
||||
{[1, 2, 3, 4].map((_, index) => (
|
||||
<View key={index} style={[styles.contentItem, styles.skeletonPoster]} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
const SkeletonFeatured = () => (
|
||||
<View style={styles.featuredContainer}>
|
||||
<View style={[styles.skeletonBox, styles.skeletonFeatured]}>
|
||||
<LinearGradient
|
||||
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 style={styles.featuredLoadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>Loading featured content...</Text>
|
||||
</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 navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
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 { 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(() => {
|
||||
if (allFeaturedContent.length === 0) return;
|
||||
setShowHeroSection(settings.showHeroSection);
|
||||
setFeaturedContentSource(settings.featuredContentSource);
|
||||
}, [settings]);
|
||||
|
||||
const rotateContent = () => {
|
||||
currentIndexRef.current = (currentIndexRef.current + 1) % allFeaturedContent.length;
|
||||
setFeaturedContent(allFeaturedContent[currentIndexRef.current]);
|
||||
};
|
||||
|
||||
const intervalId = setInterval(rotateContent, 15000); // 15 seconds
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [allFeaturedContent]);
|
||||
|
||||
// Cleanup function for ongoing operations
|
||||
const cleanup = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
// If featured content source changes, refresh featured content with debouncing
|
||||
useEffect(() => {
|
||||
if (showHeroSection) {
|
||||
// Clear any existing timeout
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set a new timeout to debounce the refresh
|
||||
refreshTimeoutRef.current = setTimeout(() => {
|
||||
refreshFeatured();
|
||||
refreshTimeoutRef.current = null;
|
||||
}, 300);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
||||
// Cleanup the timeout on unmount
|
||||
return () => {
|
||||
cleanup();
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [cleanup]);
|
||||
}, [featuredContentSource, showHeroSection, refreshFeatured]);
|
||||
|
||||
useEffect(() => {
|
||||
StatusBar.setTranslucent(true);
|
||||
|
|
@ -451,11 +417,8 @@ const HomeScreen = () => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Pre-warm the metadata screen
|
||||
useEffect(() => {
|
||||
// Pre-warm the navigation
|
||||
navigation.addListener('beforeRemove', () => {});
|
||||
|
||||
return () => {
|
||||
navigation.removeListener('beforeRemove', () => {});
|
||||
};
|
||||
|
|
@ -465,7 +428,6 @@ const HomeScreen = () => {
|
|||
if (!content.length) return;
|
||||
|
||||
try {
|
||||
setLoadingImages(true);
|
||||
const imagePromises = content.map(item => {
|
||||
const imagesToLoad = [
|
||||
item.poster,
|
||||
|
|
@ -481,167 +443,30 @@ const HomeScreen = () => {
|
|||
});
|
||||
|
||||
await Promise.all(imagePromises);
|
||||
setImagesPreloaded(true);
|
||||
} catch (error) {
|
||||
console.error('Error preloading images:', error);
|
||||
} finally {
|
||||
setLoadingImages(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadFeaturedContent = useCallback(async () => {
|
||||
const handleRefresh = useCallback(async () => {
|
||||
try {
|
||||
const trendingResults = await tmdbService.getTrending('movie', 'day');
|
||||
const refreshTasks = [
|
||||
refreshCatalogs(),
|
||||
continueWatchingRef.current?.refresh(),
|
||||
];
|
||||
|
||||
if (trendingResults.length > 0) {
|
||||
const formattedContent: StreamingContent[] = trendingResults
|
||||
.filter(item => item.title || item.name) // Filter out items without a 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: 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]);
|
||||
// Only refresh featured content if hero section is enabled
|
||||
if (showHeroSection) {
|
||||
refreshTasks.push(refreshFeatured());
|
||||
}
|
||||
} 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;
|
||||
|
||||
// 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;
|
||||
await Promise.all(refreshTasks);
|
||||
} 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);
|
||||
}).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]);
|
||||
|
||||
// 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);
|
||||
};
|
||||
}, [refreshFeatured, refreshCatalogs, showHeroSection]);
|
||||
|
||||
const handleContentPress = useCallback((id: string, type: string) => {
|
||||
// Immediate navigation without any delays
|
||||
navigation.navigate('Metadata', { id, type });
|
||||
}, [navigation]);
|
||||
|
||||
|
|
@ -659,22 +484,18 @@ const HomeScreen = () => {
|
|||
});
|
||||
}, [featuredContent, navigation]);
|
||||
|
||||
// Add a function to refresh the Continue Watching section
|
||||
const refreshContinueWatching = useCallback(() => {
|
||||
if (continueWatchingRef.current) {
|
||||
continueWatchingRef.current.refresh();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update the event listener for video playback completion
|
||||
useEffect(() => {
|
||||
const handlePlaybackComplete = () => {
|
||||
refreshContinueWatching();
|
||||
};
|
||||
|
||||
// Listen for playback complete events
|
||||
const unsubscribe = navigation.addListener('focus', () => {
|
||||
// When returning to HomeScreen, refresh Continue Watching
|
||||
refreshContinueWatching();
|
||||
});
|
||||
|
||||
|
|
@ -690,8 +511,15 @@ const HomeScreen = () => {
|
|||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={handleSaveToLibrary}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => {
|
||||
if (featuredContent) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: featuredContent.id,
|
||||
type: featuredContent.type
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={styles.featuredContainer}
|
||||
>
|
||||
<ImageBackground
|
||||
|
|
@ -702,14 +530,14 @@ const HomeScreen = () => {
|
|||
<LinearGradient
|
||||
colors={[
|
||||
'transparent',
|
||||
'rgba(0,0,0,0.2)',
|
||||
'rgba(0,0,0,0.8)',
|
||||
'rgba(0,0,0,0.1)',
|
||||
'rgba(0,0,0,0.7)',
|
||||
colors.darkBackground,
|
||||
]}
|
||||
locations={[0, 0.4, 0.7, 1]}
|
||||
locations={[0, 0.3, 0.7, 1]}
|
||||
style={styles.featuredGradient}
|
||||
>
|
||||
<Animated.View style={styles.featuredContentContainer} entering={FadeIn.duration(500)}>
|
||||
<Animated.View style={styles.featuredContentContainer} entering={FadeIn.duration(600)}>
|
||||
{featuredContent.logo ? (
|
||||
<ExpoImage
|
||||
source={{ uri: featuredContent.logo }}
|
||||
|
|
@ -720,8 +548,13 @@ const HomeScreen = () => {
|
|||
<Text style={styles.featuredTitleText}>{featuredContent.name}</Text>
|
||||
)}
|
||||
<View style={styles.genreContainer}>
|
||||
{featuredContent.genres?.slice(0, 3).map((genre, index) => (
|
||||
<Text key={index} style={styles.genreText}>{genre}</Text>
|
||||
{featuredContent.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>
|
||||
<View style={styles.featuredButtons}>
|
||||
|
|
@ -758,15 +591,10 @@ const HomeScreen = () => {
|
|||
style={styles.infoButton}
|
||||
onPress={async () => {
|
||||
if (featuredContent) {
|
||||
// Convert TMDB ID to Stremio ID
|
||||
const tmdbId = featuredContent.id.replace('tmdb:', '');
|
||||
const stremioId = await catalogService.getStremioId(featuredContent.type, tmdbId);
|
||||
if (stremioId) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: stremioId,
|
||||
type: featuredContent.type
|
||||
});
|
||||
}
|
||||
navigation.navigate('Metadata', {
|
||||
id: featuredContent.id,
|
||||
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 (
|
||||
<ContentItem
|
||||
item={item}
|
||||
onPress={handleContentPress}
|
||||
/>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300).delay(100 + (index * 40))}
|
||||
>
|
||||
<ContentItem
|
||||
item={item}
|
||||
onPress={handleContentPress}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}, [handleContentPress]);
|
||||
|
||||
const renderCatalog = ({ item }: { item: CatalogContent }) => {
|
||||
return (
|
||||
<View style={styles.catalogContainer}>
|
||||
<Animated.View
|
||||
style={styles.catalogContainer}
|
||||
entering={FadeIn.duration(400).delay(50)}
|
||||
>
|
||||
<View style={styles.catalogHeader}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.catalogTitle}>{item.name}</Text>
|
||||
|
|
@ -820,30 +655,30 @@ const HomeScreen = () => {
|
|||
|
||||
<FlatList
|
||||
data={item.items}
|
||||
renderItem={renderContentItem}
|
||||
renderItem={({ item, index }) => renderContentItem({ item, index })}
|
||||
keyExtractor={(item) => `${item.id}-${item.type}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.catalogList}
|
||||
snapToInterval={POSTER_WIDTH + 10}
|
||||
snapToInterval={POSTER_WIDTH + 12}
|
||||
decelerationRate="fast"
|
||||
snapToAlignment="start"
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 12 }} />}
|
||||
initialNumToRender={4}
|
||||
maxToRenderPerBatch={4}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
getItemLayout={(data, index) => ({
|
||||
length: POSTER_WIDTH + 10,
|
||||
offset: (POSTER_WIDTH + 10) * index,
|
||||
length: POSTER_WIDTH + 12,
|
||||
offset: (POSTER_WIDTH + 12) * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading && !refreshing) {
|
||||
if (isLoading && !isRefreshing) {
|
||||
return (
|
||||
<View style={[styles.container]}>
|
||||
<StatusBar
|
||||
|
|
@ -851,15 +686,10 @@ const HomeScreen = () => {
|
|||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<SkeletonFeatured />
|
||||
{[1, 2, 3].map((_, index) => (
|
||||
<SkeletonCatalog key={index} />
|
||||
))}
|
||||
</ScrollView>
|
||||
<View style={styles.loadingMainContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>Loading your content...</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -873,38 +703,48 @@ const HomeScreen = () => {
|
|||
/>
|
||||
<ScrollView
|
||||
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}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Featured Content */}
|
||||
{renderFeaturedContent()}
|
||||
{showHeroSection && renderFeaturedContent()}
|
||||
|
||||
{/* This Week Section */}
|
||||
<ThisWeekSection />
|
||||
<Animated.View entering={FadeIn.duration(400).delay(150)}>
|
||||
<ThisWeekSection />
|
||||
</Animated.View>
|
||||
|
||||
{/* Continue Watching Section */}
|
||||
<ContinueWatchingSection ref={continueWatchingRef} />
|
||||
<Animated.View entering={FadeIn.duration(400).delay(250)}>
|
||||
<ContinueWatchingSection ref={continueWatchingRef} />
|
||||
</Animated.View>
|
||||
|
||||
{/* Catalogs */}
|
||||
{catalogs.length > 0 ? (
|
||||
<FlatList
|
||||
data={catalogs}
|
||||
renderItem={renderCatalog}
|
||||
keyExtractor={(item, index) => `${item.addon}-${item.id}-${index}`}
|
||||
scrollEnabled={false}
|
||||
removeClippedSubviews={false}
|
||||
initialNumToRender={3}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
/>
|
||||
catalogs.map((catalog, index) => (
|
||||
<View key={`${catalog.addon}-${catalog.id}-${index}`}>
|
||||
{renderCatalog({ item: catalog })}
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyCatalog}>
|
||||
<Text style={{ color: colors.textDark }}>
|
||||
No content available. Pull down to refresh.
|
||||
</Text>
|
||||
</View>
|
||||
!catalogsLoading && (
|
||||
<View style={styles.emptyCatalog}>
|
||||
<MaterialIcons name="movie-filter" size={40} color={colors.textDark} />
|
||||
<Text style={{ color: colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
|
||||
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>
|
||||
</View>
|
||||
|
|
@ -912,7 +752,7 @@ const HomeScreen = () => {
|
|||
};
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const POSTER_WIDTH = (width - 40) / 2.7;
|
||||
const POSTER_WIDTH = (width - 50) / 3;
|
||||
|
||||
const styles = StyleSheet.create<any>({
|
||||
container: {
|
||||
|
|
@ -920,7 +760,7 @@ const styles = StyleSheet.create<any>({
|
|||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 32,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
|
|
@ -929,11 +769,10 @@ const styles = StyleSheet.create<any>({
|
|||
},
|
||||
featuredContainer: {
|
||||
width: '100%',
|
||||
height: height * 0.65,
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
height: height * 0.6,
|
||||
marginTop: Platform.OS === 'ios' ? 85 : 75,
|
||||
marginBottom: 8,
|
||||
position: 'relative',
|
||||
paddingTop: 56,
|
||||
},
|
||||
featuredBanner: {
|
||||
width: '100%',
|
||||
|
|
@ -950,7 +789,7 @@ const styles = StyleSheet.create<any>({
|
|||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
gap: 12,
|
||||
},
|
||||
featuredLogo: {
|
||||
width: width * 0.7,
|
||||
|
|
@ -972,21 +811,22 @@ const styles = StyleSheet.create<any>({
|
|||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 0,
|
||||
marginBottom: 16,
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
},
|
||||
genreText: {
|
||||
color: colors.white,
|
||||
fontSize: 13,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
opacity: 0.9,
|
||||
},
|
||||
genreDot: {
|
||||
color: colors.white,
|
||||
fontSize: 13,
|
||||
marginHorizontal: 4,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
opacity: 0.6,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
featuredButtons: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -994,16 +834,16 @@ const styles = StyleSheet.create<any>({
|
|||
justifyContent: 'space-evenly',
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
maxHeight: 60,
|
||||
paddingTop: 12,
|
||||
maxHeight: 65,
|
||||
paddingTop: 16,
|
||||
},
|
||||
playButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 100,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 30,
|
||||
backgroundColor: colors.white,
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
|
|
@ -1019,8 +859,8 @@ const styles = StyleSheet.create<any>({
|
|||
alignItems: 'center',
|
||||
padding: 0,
|
||||
gap: 6,
|
||||
width: 40,
|
||||
height: 41,
|
||||
width: 44,
|
||||
height: 44,
|
||||
flex: null,
|
||||
},
|
||||
infoButton: {
|
||||
|
|
@ -1029,8 +869,8 @@ const styles = StyleSheet.create<any>({
|
|||
alignItems: 'center',
|
||||
padding: 0,
|
||||
gap: 4,
|
||||
width: 40,
|
||||
height: 39,
|
||||
width: 44,
|
||||
height: 44,
|
||||
flex: null,
|
||||
},
|
||||
playButtonText: {
|
||||
|
|
@ -1052,14 +892,14 @@ const styles = StyleSheet.create<any>({
|
|||
catalogContainer: {
|
||||
marginBottom: 24,
|
||||
paddingTop: 0,
|
||||
marginTop: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
catalogHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
titleContainer: {
|
||||
position: 'relative',
|
||||
|
|
@ -1096,14 +936,14 @@ const styles = StyleSheet.create<any>({
|
|||
},
|
||||
catalogList: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 12,
|
||||
paddingTop: 6,
|
||||
},
|
||||
contentItem: {
|
||||
width: POSTER_WIDTH,
|
||||
aspectRatio: 2/3,
|
||||
margin: 0,
|
||||
borderRadius: 12,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
elevation: 8,
|
||||
|
|
@ -1112,12 +952,12 @@ const styles = StyleSheet.create<any>({
|
|||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
poster: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 12,
|
||||
borderRadius: 16,
|
||||
},
|
||||
imdbLogo: {
|
||||
width: 35,
|
||||
|
|
@ -1147,7 +987,7 @@ const styles = StyleSheet.create<any>({
|
|||
},
|
||||
skeletonBox: {
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 12,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
skeletonFeatured: {
|
||||
|
|
@ -1161,12 +1001,12 @@ const styles = StyleSheet.create<any>({
|
|||
skeletonPoster: {
|
||||
backgroundColor: colors.elevation1,
|
||||
marginHorizontal: 4,
|
||||
borderRadius: 12,
|
||||
borderRadius: 16,
|
||||
},
|
||||
contentItemContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 12,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
|
|
@ -1197,11 +1037,11 @@ const styles = StyleSheet.create<any>({
|
|||
borderRadius: 2,
|
||||
alignSelf: 'center',
|
||||
marginTop: 12,
|
||||
marginBottom: 8,
|
||||
marginBottom: 10,
|
||||
},
|
||||
menuContainer: {
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingBottom: Platform.select({ ios: 40, android: 24 }),
|
||||
...Platform.select({
|
||||
ios: {
|
||||
|
|
@ -1224,7 +1064,7 @@ const styles = StyleSheet.create<any>({
|
|||
menuPoster: {
|
||||
width: 60,
|
||||
height: 90,
|
||||
borderRadius: 8,
|
||||
borderRadius: 12,
|
||||
},
|
||||
menuTitleContainer: {
|
||||
flex: 1,
|
||||
|
|
@ -1280,7 +1120,7 @@ const styles = StyleSheet.create<any>({
|
|||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
borderRadius: 16,
|
||||
},
|
||||
featuredImage: {
|
||||
width: '100%',
|
||||
|
|
@ -1289,6 +1129,8 @@ const styles = StyleSheet.create<any>({
|
|||
featuredContentContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
featuredTitleText: {
|
||||
color: colors.highEmphasis,
|
||||
|
|
@ -1301,6 +1143,51 @@ const styles = StyleSheet.create<any>({
|
|||
textAlign: 'center',
|
||||
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;
|
||||
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 { Image } from 'expo-image';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
import type { StreamingContent } from '../services/catalogService';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
|
@ -81,7 +82,7 @@ const SkeletonLoader = () => {
|
|||
return (
|
||||
<View style={styles.skeletonContainer}>
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<View key={index} style={{ width: itemWidth }}>
|
||||
<View key={index} style={{ width: itemWidth, margin: 8 }}>
|
||||
{renderSkeletonItem()}
|
||||
</View>
|
||||
))}
|
||||
|
|
@ -135,13 +136,32 @@ const LibraryScreen = () => {
|
|||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width: itemWidth }]}
|
||||
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.posterContainer}>
|
||||
<Image
|
||||
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||
style={styles.poster}
|
||||
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 && (
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View
|
||||
|
|
@ -156,7 +176,7 @@ const LibraryScreen = () => {
|
|||
<View style={styles.badgeContainer}>
|
||||
<MaterialIcons
|
||||
name="live-tv"
|
||||
size={12}
|
||||
size={14}
|
||||
color={colors.white}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
|
|
@ -164,17 +184,6 @@ const LibraryScreen = () => {
|
|||
</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>
|
||||
);
|
||||
|
||||
|
|
@ -185,25 +194,21 @@ const LibraryScreen = () => {
|
|||
style={[
|
||||
styles.filterButton,
|
||||
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)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={iconName}
|
||||
size={20}
|
||||
color={isActive ? colors.primary : (isDarkMode ? colors.white : colors.mediumGray)}
|
||||
size={22}
|
||||
color={isActive ? colors.white : colors.mediumGray}
|
||||
style={styles.filterIcon}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? '600' : '500',
|
||||
color: isActive ? colors.primary : colors.white
|
||||
}}
|
||||
style={[
|
||||
styles.filterText,
|
||||
isActive && styles.filterTextActive
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
|
@ -212,10 +217,11 @@ const LibraryScreen = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.black }]}>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar
|
||||
barStyle="light-content"
|
||||
backgroundColor={colors.black}
|
||||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
|
||||
<View style={styles.header}>
|
||||
|
|
@ -236,21 +242,21 @@ const LibraryScreen = () => {
|
|||
<View style={styles.emptyContainer}>
|
||||
<MaterialIcons
|
||||
name="video-library"
|
||||
size={64}
|
||||
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||
size={80}
|
||||
color={colors.mediumGray}
|
||||
style={{ opacity: 0.7 }}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.emptyText,
|
||||
{ color: isDarkMode ? colors.white : colors.black }
|
||||
]}>
|
||||
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 style={styles.emptyText}>Your library is empty</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Add content to your library to keep track of what you're watching
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.exploreButton}
|
||||
onPress={() => navigation.navigate('Discover')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.exploreButtonText}>Explore Content</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
|
|
@ -258,8 +264,13 @@ const LibraryScreen = () => {
|
|||
renderItem={renderItem}
|
||||
keyExtractor={item => item.id}
|
||||
numColumns={2}
|
||||
contentContainerStyle={styles.listContent}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
initialNumToRender={6}
|
||||
maxToRenderPerBatch={6}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
|
|
@ -269,14 +280,12 @@ const LibraryScreen = () => {
|
|||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
backgroundColor: colors.darkBackground,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -287,90 +296,94 @@ const styles = StyleSheet.create({
|
|||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
color: colors.white,
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
filtersContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
gap: 12,
|
||||
backgroundColor: colors.black,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.05)',
|
||||
zIndex: 10,
|
||||
},
|
||||
filterButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.darkGray,
|
||||
backgroundColor: 'transparent',
|
||||
gap: 6,
|
||||
minWidth: 100,
|
||||
justifyContent: 'center',
|
||||
marginHorizontal: 4,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
filterButtonActive: {
|
||||
backgroundColor: colors.primary + '20',
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
filterIcon: {
|
||||
marginRight: 2,
|
||||
marginRight: 8,
|
||||
},
|
||||
filterText: {
|
||||
fontSize: 14,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: colors.mediumGray,
|
||||
},
|
||||
filterTextActive: {
|
||||
color: colors.primary,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 8,
|
||||
listContainer: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
skeletonContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 32,
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
itemContainer: {
|
||||
marginHorizontal: 8,
|
||||
marginBottom: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
posterContainer: {
|
||||
position: 'relative',
|
||||
borderRadius: 12,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
aspectRatio: 2/3,
|
||||
marginBottom: 8,
|
||||
backgroundColor: colors.darkBackground,
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
poster: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
itemTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
lineHeight: 20,
|
||||
},
|
||||
lastWatched: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
opacity: 0.7,
|
||||
posterGradient: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 16,
|
||||
justifyContent: 'flex-end',
|
||||
height: '45%',
|
||||
},
|
||||
progressBarContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
height: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
progressBar: {
|
||||
|
|
@ -379,9 +392,9 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
badgeContainer: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.75)',
|
||||
top: 10,
|
||||
right: 10,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
|
|
@ -390,9 +403,31 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
badgeText: {
|
||||
color: colors.white,
|
||||
fontSize: 12,
|
||||
fontSize: 10,
|
||||
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: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
|
|
@ -400,30 +435,34 @@ const styles = StyleSheet.create({
|
|||
paddingHorizontal: 32,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
fontSize: 15,
|
||||
color: colors.mediumGray,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
opacity: 0.7,
|
||||
marginBottom: 24,
|
||||
},
|
||||
skeletonContainer: {
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
skeletonTitle: {
|
||||
height: 20,
|
||||
borderRadius: 4,
|
||||
marginTop: 8,
|
||||
width: '80%',
|
||||
exploreButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 24,
|
||||
elevation: 3,
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
exploreButtonText: {
|
||||
color: colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -20,10 +20,11 @@ import { LinearGradient } from 'expo-linear-gradient';
|
|||
import { Image } from 'expo-image';
|
||||
import { colors } from '../styles/colors';
|
||||
import { useMetadata } from '../hooks/useMetadata';
|
||||
import { CastSection } from '../components/metadata/CastSection';
|
||||
import { SeriesContent } from '../components/metadata/SeriesContent';
|
||||
import { MovieContent } from '../components/metadata/MovieContent';
|
||||
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
||||
import { CastSection as OriginalCastSection } from '../components/metadata/CastSection';
|
||||
import { SeriesContent as OriginalSeriesContent } from '../components/metadata/SeriesContent';
|
||||
import { MovieContent as OriginalMovieContent } from '../components/metadata/MovieContent';
|
||||
import { MoreLikeThisSection as OriginalMoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
||||
import { RatingsSection as OriginalRatingsSection } from '../components/metadata/RatingsSection';
|
||||
import { StreamingContent } from '../services/catalogService';
|
||||
import { GroupedStreams } from '../types/streams';
|
||||
import { TMDBEpisode } from '../services/tmdbService';
|
||||
|
|
@ -40,6 +41,7 @@ import Animated, {
|
|||
withSpring,
|
||||
FadeIn,
|
||||
runOnJS,
|
||||
Layout,
|
||||
} from 'react-native-reanimated';
|
||||
import { RouteProp } 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 { storageService } from '../services/storageService';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useGenres } from '../contexts/GenreContext';
|
||||
|
||||
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
|
||||
const springConfig = {
|
||||
damping: 20,
|
||||
|
|
@ -60,6 +70,116 @@ const springConfig = {
|
|||
// Add debug log for 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 route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
|
@ -84,6 +204,9 @@ const MetadataScreen = () => {
|
|||
setMetadata,
|
||||
} = useMetadata({ id, type });
|
||||
|
||||
// Get genres from context
|
||||
const { genreMap, loadingGenres } = useGenres();
|
||||
|
||||
const contentRef = useRef<ScrollView>(null);
|
||||
const [lastScrollTop, setLastScrollTop] = useState(0);
|
||||
const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false);
|
||||
|
|
@ -106,17 +229,40 @@ const MetadataScreen = () => {
|
|||
const watchProgressOpacity = useSharedValue(0);
|
||||
const watchProgressScaleY = useSharedValue(0);
|
||||
|
||||
// Add new animated value for logo scale
|
||||
const logoScale = useSharedValue(0);
|
||||
|
||||
// Add new animated value for creator fade-in
|
||||
const creatorOpacity = useSharedValue(0);
|
||||
// Add animated value for logo
|
||||
const logoOpacity = useSharedValue(0);
|
||||
|
||||
// Debug log for route params
|
||||
// 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
|
||||
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"
|
||||
const parts = episodeId.split(':');
|
||||
if (parts.length === 3) {
|
||||
|
|
@ -274,7 +420,7 @@ const MetadataScreen = () => {
|
|||
logger.error('[MetadataScreen] Error loading watch progress:', error);
|
||||
setWatchProgress(null);
|
||||
}
|
||||
}, [id, type, episodeId, episodes]);
|
||||
}, [id, type, episodeId, episodes, getEpisodeDetails]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
|
|
@ -328,7 +474,7 @@ const MetadataScreen = () => {
|
|||
damping: 18
|
||||
});
|
||||
}
|
||||
}, [watchProgress]);
|
||||
}, [watchProgress, watchProgressOpacity, watchProgressScaleY]);
|
||||
|
||||
// Add animated style for watch progress
|
||||
const watchProgressAnimatedStyle = useAnimatedStyle(() => {
|
||||
|
|
@ -351,123 +497,33 @@ const MetadataScreen = () => {
|
|||
// Add animated style for logo
|
||||
const logoAnimatedStyle = useAnimatedStyle(() => {
|
||||
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(() => {
|
||||
if (metadata?.logo) {
|
||||
logoScale.value = withSpring(1, {
|
||||
damping: 18,
|
||||
stiffness: 120,
|
||||
mass: 0.5
|
||||
logoOpacity.value = withTiming(1, {
|
||||
duration: 500,
|
||||
easing: Easing.out(Easing.ease)
|
||||
});
|
||||
} else {
|
||||
// Optional: Reset scale if logo disappears?
|
||||
// logoScale.value = withTiming(0, { duration: 100 });
|
||||
logoOpacity.value = withTiming(0, {
|
||||
duration: 200,
|
||||
easing: Easing.in(Easing.ease)
|
||||
});
|
||||
}
|
||||
}, [metadata?.logo]);
|
||||
}, [metadata?.logo, logoOpacity]);
|
||||
|
||||
// Add animated style for creator fade-in
|
||||
const creatorFadeInStyle = useAnimatedStyle(() => {
|
||||
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>
|
||||
);
|
||||
// Update the watch progress render function - Now uses WatchProgressDisplay component
|
||||
// const renderWatchProgress = () => { ... }; // Removed old inline function
|
||||
|
||||
// Handler functions
|
||||
const handleShowStreams = useCallback(() => {
|
||||
|
|
@ -500,18 +556,19 @@ const MetadataScreen = () => {
|
|||
navigation.navigate('Streams', { id, type, episodeId });
|
||||
}, [navigation, id, type, episodes, episodeId, watchProgress]);
|
||||
|
||||
const handleSelectCastMember = (castMember: any) => {
|
||||
logger.log('Cast member selected:', castMember);
|
||||
};
|
||||
const handleSelectCastMember = useCallback((castMember: any) => {
|
||||
// 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}`;
|
||||
navigation.navigate('Streams', {
|
||||
id,
|
||||
type,
|
||||
episodeId
|
||||
});
|
||||
};
|
||||
}, [navigation, id, type]); // Added dependencies
|
||||
|
||||
// Animated styles
|
||||
const containerAnimatedStyle = useAnimatedStyle(() => ({
|
||||
|
|
@ -613,6 +670,31 @@ const MetadataScreen = () => {
|
|||
navigation.goBack();
|
||||
}, [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) {
|
||||
return (
|
||||
<SafeAreaView
|
||||
|
|
@ -726,40 +808,45 @@ const MetadataScreen = () => {
|
|||
locations={[0, 0.4, 0.65, 0.8, 0.9, 1]}
|
||||
style={styles.heroGradient}
|
||||
>
|
||||
<Animated.View entering={FadeInDown.delay(100).springify()} style={styles.heroContent}>
|
||||
<View style={styles.heroContent}>
|
||||
{/* Title */}
|
||||
{metadata.logo ? (
|
||||
<Animated.View style={logoAnimatedStyle}>
|
||||
<Image
|
||||
source={{ uri: metadata.logo }}
|
||||
style={styles.titleLogo}
|
||||
contentFit="contain"
|
||||
/>
|
||||
<View style={styles.logoContainer}>
|
||||
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
|
||||
{metadata.logo ? (
|
||||
<Image
|
||||
source={{ uri: metadata.logo }}
|
||||
style={styles.titleLogo}
|
||||
contentFit="contain"
|
||||
transition={300}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.heroTitle}>{metadata.name}</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Text style={styles.titleText}>{metadata.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Watch Progress */}
|
||||
{renderWatchProgress()}
|
||||
<WatchProgressDisplay
|
||||
watchProgress={watchProgress}
|
||||
type={type as 'movie' | 'series'}
|
||||
getEpisodeDetails={getEpisodeDetails}
|
||||
animatedStyle={watchProgressAnimatedStyle}
|
||||
/>
|
||||
|
||||
{/* Genre Tags */}
|
||||
{metadata.genres && metadata.genres.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{renderGenres}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<ActionButtons />
|
||||
</Animated.View>
|
||||
<ActionButtons
|
||||
handleShowStreams={handleShowStreams}
|
||||
toggleLibrary={toggleLibrary}
|
||||
inLibrary={inLibrary}
|
||||
type={type as 'movie' | 'series'}
|
||||
id={id}
|
||||
navigation={navigation}
|
||||
playButtonText={getPlayButtonText()}
|
||||
/>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</ImageBackground>
|
||||
</Animated.View>
|
||||
|
|
@ -789,12 +876,18 @@ const MetadataScreen = () => {
|
|||
)}
|
||||
</View>
|
||||
|
||||
{/* Add RatingsSection right under the main metadata */}
|
||||
{id && (
|
||||
<RatingsSection
|
||||
imdbId={id}
|
||||
type={type === 'series' ? 'show' : 'movie'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Creator/Director Info */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.creatorContainer,
|
||||
creatorFadeInStyle,
|
||||
]}
|
||||
entering={FadeIn.duration(500).delay(200)}
|
||||
style={styles.creatorContainer}
|
||||
>
|
||||
{metadata.directors && metadata.directors.length > 0 && (
|
||||
<View style={styles.creatorSection}>
|
||||
|
|
@ -812,11 +905,29 @@ const MetadataScreen = () => {
|
|||
|
||||
{/* Description */}
|
||||
{metadata.description && (
|
||||
<View style={styles.descriptionContainer}>
|
||||
<Text style={styles.description}>
|
||||
{`${metadata.description}`}
|
||||
<Animated.View
|
||||
style={styles.descriptionContainer}
|
||||
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>
|
||||
</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 */}
|
||||
|
|
@ -940,22 +1051,33 @@ const styles = StyleSheet.create({
|
|||
genreContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
gap: 4,
|
||||
},
|
||||
genreText: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
},
|
||||
genreDot: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 14,
|
||||
marginHorizontal: 8,
|
||||
color: colors.text,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
opacity: 0.6,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
titleLogoContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
titleLogo: {
|
||||
width: width * 0.65,
|
||||
|
|
@ -963,7 +1085,7 @@ const styles = StyleSheet.create({
|
|||
marginBottom: 0,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
titleText: {
|
||||
heroTitle: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 28,
|
||||
fontWeight: '900',
|
||||
|
|
@ -1015,18 +1137,13 @@ const styles = StyleSheet.create({
|
|||
showMoreButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
backgroundColor: colors.elevation1,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: 8,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
showMoreText: {
|
||||
color: colors.highEmphasis,
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
marginRight: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -1084,37 +1201,6 @@ const styles = StyleSheet.create({
|
|||
fontWeight: '600',
|
||||
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: {
|
||||
marginBottom: 2,
|
||||
paddingHorizontal: 16,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
Dimensions,
|
||||
Pressable
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
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 { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { stremioService } from '../services/stremioService';
|
||||
import { useCatalogContext } from '../contexts/CatalogContext';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
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 {
|
||||
title: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
icon: string;
|
||||
renderControl: () => React.ReactNode;
|
||||
isLast?: boolean;
|
||||
|
|
@ -46,48 +63,110 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
isDarkMode
|
||||
}) => {
|
||||
return (
|
||||
<View
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.settingItem,
|
||||
styles.settingItem,
|
||||
!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
|
||||
style={styles.settingTouchable}
|
||||
onPress={onPress}
|
||||
android_ripple={{
|
||||
color: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
|
||||
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}>
|
||||
<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>
|
||||
<Text style={[styles.settingDescription, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
|
||||
{description}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text style={[styles.settingDescription, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.settingControl}>
|
||||
{renderControl()}
|
||||
</View>
|
||||
</Pressable>
|
||||
</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 SettingsScreen: React.FC = () => {
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const systemColorScheme = useColorScheme();
|
||||
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
|
||||
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(() => {
|
||||
Alert.alert(
|
||||
|
|
@ -108,205 +187,143 @@ const SettingsScreen: React.FC = () => {
|
|||
);
|
||||
}, [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 }) => (
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: isDarkMode ? colors.elevation2 : colors.surfaceVariant, true: `${colors.primary}80` }}
|
||||
thumbColor={value ? colors.primary : (isDarkMode ? colors.white : colors.white)}
|
||||
ios_backgroundColor={isDarkMode ? colors.elevation2 : colors.surfaceVariant}
|
||||
style={Platform.select({ ios: { transform: [{ scale: 0.8 }] } })}
|
||||
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)'}
|
||||
/>
|
||||
);
|
||||
|
||||
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 : colors.lightBackground }
|
||||
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
|
||||
]}>
|
||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
||||
<View style={[styles.header, {
|
||||
borderBottomColor: isDarkMode ? colors.border : 'rgba(0,0,0,0.08)'
|
||||
}]}>
|
||||
<View style={styles.headerContent}>
|
||||
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
|
||||
Settings
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
|
||||
Settings
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{renderSectionHeader('Playback')}
|
||||
<SettingItem
|
||||
title="External Player"
|
||||
description="Use external video player when available"
|
||||
icon="open-in-new"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={() => (
|
||||
<CustomSwitch
|
||||
value={settings.useExternalPlayer}
|
||||
onValueChange={(value) => updateSetting('useExternalPlayer', value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SectionHeader title="USER & ACCOUNT" isDarkMode={isDarkMode} />
|
||||
<SettingsCard isDarkMode={isDarkMode}>
|
||||
<SettingItem
|
||||
title="Trakt"
|
||||
description="Not Connected"
|
||||
icon="person"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => Alert.alert('Trakt', 'Trakt integration coming soon')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="iCloud Sync"
|
||||
description="Enabled"
|
||||
icon="cloud"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
isLast={true}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{renderSectionHeader('Content')}
|
||||
<SettingItem
|
||||
title="Catalog Settings"
|
||||
description="Customize which catalogs appear on your home screen"
|
||||
icon="view-list"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={() => (
|
||||
<View style={[styles.actionButton, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.actionButtonText}>Configure</Text>
|
||||
</View>
|
||||
)}
|
||||
onPress={() => navigation.navigate('CatalogSettings')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Calendar & Upcoming"
|
||||
description="View and manage your upcoming episode schedule"
|
||||
icon="calendar-today"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={() => (
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={24}
|
||||
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||
style={styles.chevronIcon}
|
||||
/>
|
||||
)}
|
||||
onPress={() => navigation.navigate('Calendar')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Notifications"
|
||||
description="Configure notifications for new episodes"
|
||||
icon="notifications"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={() => (
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={24}
|
||||
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||
style={styles.chevronIcon}
|
||||
/>
|
||||
)}
|
||||
onPress={() => navigation.navigate('NotificationSettings')}
|
||||
/>
|
||||
<SectionHeader title="CONTENT" isDarkMode={isDarkMode} />
|
||||
<SettingsCard isDarkMode={isDarkMode}>
|
||||
<SettingItem
|
||||
title="Addons"
|
||||
description={addonCount + " installed"}
|
||||
icon="extension"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('Addons')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Catalogs"
|
||||
description={`${catalogCount} ${catalogCount === 1 ? 'catalog' : 'catalogs'} enabled`}
|
||||
icon="view-list"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('CatalogSettings')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Home Screen"
|
||||
description="Customize home layout and content"
|
||||
icon="home"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('HomeScreenSettings')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Folders"
|
||||
description="0 created"
|
||||
icon="folder"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Ratings Source"
|
||||
description={mdblistKeySet ? "MDBList API Configured" : "MDBList API Not Set"}
|
||||
icon="info-outline"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('MDBListSettings')}
|
||||
/>
|
||||
<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')}
|
||||
<SettingItem
|
||||
title="Manage Addons"
|
||||
description="Configure and update your addons"
|
||||
icon="extension"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={() => (
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={24}
|
||||
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||
style={styles.chevronIcon}
|
||||
/>
|
||||
)}
|
||||
onPress={() => navigation.navigate('Addons')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Check TMDB Addon"
|
||||
description="Verify TMDB Embed Streams addon installation"
|
||||
icon="bug-report"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={() => (
|
||||
<View style={[styles.actionButton, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.actionButtonText}>Check</Text>
|
||||
</View>
|
||||
)}
|
||||
onPress={() => {
|
||||
// Check if the addon is installed
|
||||
const installedAddons = stremioService.getInstalledAddons();
|
||||
const tmdbAddon = installedAddons.find(addon => addon.id === 'org.tmdbembedapi');
|
||||
|
||||
if (tmdbAddon) {
|
||||
// Addon is installed, check its configuration
|
||||
Alert.alert(
|
||||
'TMDB Embed Streams Addon',
|
||||
`Addon is installed:\n\nName: ${tmdbAddon.name}\nID: ${tmdbAddon.id}\nURL: ${tmdbAddon.url}\n\nResources: ${JSON.stringify(tmdbAddon.resources)}\n\nTypes: ${JSON.stringify(tmdbAddon.types)}`,
|
||||
[
|
||||
{
|
||||
text: 'Reinstall',
|
||||
onPress: async () => {
|
||||
try {
|
||||
// Remove and reinstall the addon
|
||||
stremioService.removeAddon('org.tmdbembedapi');
|
||||
await stremioService.installAddon('https://http-addon-production.up.railway.app/manifest.json');
|
||||
Alert.alert('Success', 'Addon was reinstalled successfully');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', `Failed to reinstall addon: ${error}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ text: 'Close', style: 'cancel' }
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// Addon is not installed, offer to install it
|
||||
Alert.alert(
|
||||
'TMDB Embed Streams Addon',
|
||||
'Addon is not installed. Would you like to install it now?',
|
||||
[
|
||||
{
|
||||
text: 'Install',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await stremioService.installAddon('https://http-addon-production.up.railway.app/manifest.json');
|
||||
Alert.alert('Success', 'Addon was installed successfully');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', `Failed to install addon: ${error}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ text: 'Cancel', style: 'cancel' }
|
||||
]
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Reset All Settings"
|
||||
description="Restore default settings"
|
||||
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}
|
||||
/>
|
||||
<SectionHeader title="PLAYBACK" isDarkMode={isDarkMode} />
|
||||
<SettingsCard isDarkMode={isDarkMode}>
|
||||
<SettingItem
|
||||
title="Video Player"
|
||||
description="Infuse"
|
||||
icon="play-arrow"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Auto-Filtering"
|
||||
description="Disabled"
|
||||
icon="tune"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
isLast={true}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
|
@ -319,21 +336,12 @@ const styles = StyleSheet.create({
|
|||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
fontSize: 34,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
color: colors.white,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
|
|
@ -342,84 +350,69 @@ const styles = StyleSheet.create({
|
|||
paddingBottom: 32,
|
||||
},
|
||||
sectionHeader: {
|
||||
padding: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
sectionHeaderText: {
|
||||
fontSize: 13,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
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: {
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 4,
|
||||
borderRadius: 16,
|
||||
overflow: Platform.OS === 'android' ? 'hidden' : 'visible',
|
||||
},
|
||||
settingItemBorder: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
settingTouchable: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 0.5,
|
||||
minHeight: 44,
|
||||
},
|
||||
settingItemBorder: {
|
||||
// Border styling handled directly in the component with borderBottomWidth
|
||||
},
|
||||
settingIconContainer: {
|
||||
marginRight: 16,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
marginRight: 12,
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
settingContent: {
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
marginRight: 8,
|
||||
},
|
||||
settingTitleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
letterSpacing: 0.15,
|
||||
fontSize: 15,
|
||||
fontWeight: '400',
|
||||
flex: 1,
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0.25,
|
||||
opacity: 0.7,
|
||||
textAlign: 'right',
|
||||
flexShrink: 1,
|
||||
maxWidth: '60%',
|
||||
},
|
||||
settingControl: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minWidth: 50,
|
||||
},
|
||||
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,
|
||||
paddingLeft: 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 { logger } from '../utils/logger';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// 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 TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
|
||||
const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key';
|
||||
|
||||
// Types for TMDB responses
|
||||
export interface TMDBEpisode {
|
||||
|
|
@ -40,6 +43,7 @@ export interface TMDBShow {
|
|||
last_air_date: string;
|
||||
number_of_seasons: number;
|
||||
number_of_episodes: number;
|
||||
genres?: { id: number; name: string }[];
|
||||
seasons: {
|
||||
id: number;
|
||||
name: string;
|
||||
|
|
@ -69,8 +73,13 @@ export interface TMDBTrendingResult {
|
|||
export class TMDBService {
|
||||
private static instance: TMDBService;
|
||||
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 {
|
||||
if (!TMDBService.instance) {
|
||||
|
|
@ -79,13 +88,54 @@ export class TMDBService {
|
|||
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 {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
'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 {
|
||||
return `${showName.toLowerCase()}_s${seasonNumber}_e${episodeNumber}`;
|
||||
}
|
||||
|
|
@ -96,13 +146,13 @@ export class TMDBService {
|
|||
async searchTVShow(query: string): Promise<TMDBShow[]> {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/search/tv`, {
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
query,
|
||||
include_adult: false,
|
||||
language: 'en-US',
|
||||
page: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return response.data.results;
|
||||
} catch (error) {
|
||||
|
|
@ -117,10 +167,10 @@ export class TMDBService {
|
|||
async getTVShowDetails(tmdbId: number): Promise<TMDBShow | null> {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/tv/${tmdbId}`, {
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language: 'en-US',
|
||||
},
|
||||
}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
|
@ -141,7 +191,8 @@ export class TMDBService {
|
|||
const response = await axios.get(
|
||||
`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}/episode/${episodeNumber}/external_ids`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams(),
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
|
|
@ -195,10 +246,10 @@ export class TMDBService {
|
|||
async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string): Promise<TMDBSeason | null> {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}`, {
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language: 'en-US',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const season = response.data;
|
||||
|
|
@ -254,10 +305,10 @@ export class TMDBService {
|
|||
const response = await axios.get(
|
||||
`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}/episode/${episodeNumber}`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language: 'en-US',
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
|
|
@ -295,11 +346,11 @@ export class TMDBService {
|
|||
const baseImdbId = imdbId.split(':')[0];
|
||||
|
||||
const response = await axios.get(`${BASE_URL}/find/${baseImdbId}`, {
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
external_source: 'imdb_id',
|
||||
language: 'en-US',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Check TV results first
|
||||
|
|
@ -402,10 +453,10 @@ export class TMDBService {
|
|||
async getCredits(tmdbId: number, type: string) {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/${type === 'series' ? 'tv' : 'movie'}/${tmdbId}/credits`, {
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language: 'en-US',
|
||||
},
|
||||
}),
|
||||
});
|
||||
return {
|
||||
cast: response.data.cast || [],
|
||||
|
|
@ -420,10 +471,10 @@ export class TMDBService {
|
|||
async getPersonDetails(personId: number) {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/person/${personId}`, {
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language: 'en-US',
|
||||
},
|
||||
}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
|
@ -440,7 +491,8 @@ export class TMDBService {
|
|||
const response = await axios.get(
|
||||
`${BASE_URL}/tv/${tmdbId}/external_ids`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams(),
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
|
|
@ -451,14 +503,14 @@ export class TMDBService {
|
|||
}
|
||||
|
||||
async getRecommendations(type: 'movie' | 'tv', tmdbId: string): Promise<any[]> {
|
||||
if (!API_KEY) {
|
||||
if (!this.apiKey) {
|
||||
logger.error('TMDB API key not set');
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/${type}/${tmdbId}/recommendations`, {
|
||||
headers: this.getHeaders(),
|
||||
params: { language: 'en-US' }
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({ language: 'en-US' })
|
||||
});
|
||||
return response.data.results || [];
|
||||
} catch (error) {
|
||||
|
|
@ -470,13 +522,13 @@ export class TMDBService {
|
|||
async searchMulti(query: string): Promise<any[]> {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/search/multi`, {
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
query,
|
||||
include_adult: false,
|
||||
language: 'en-US',
|
||||
page: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return response.data.results;
|
||||
} catch (error) {
|
||||
|
|
@ -485,25 +537,189 @@ export class TMDBService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get movie details by TMDB ID
|
||||
*/
|
||||
async getMovieDetails(movieId: string): Promise<any> {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/movie/${movieId}`, {
|
||||
headers: this.getHeaders(),
|
||||
params: { language: 'en-US' }
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language: 'en-US',
|
||||
append_to_response: 'external_ids' // Append external IDs
|
||||
}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching movie details:', error);
|
||||
logger.error('Failed to get movie details:', error);
|
||||
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> {
|
||||
try {
|
||||
// Different endpoints for movies and TV shows
|
||||
const endpoint = type === 'movie' ? 'movie' : 'tv';
|
||||
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) {
|
||||
|
|
@ -537,10 +753,10 @@ export class TMDBService {
|
|||
async getTrending(type: 'movie' | 'tv', timeWindow: 'day' | 'week'): Promise<TMDBTrendingResult[]> {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/trending/${type}/${timeWindow}`, {
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language: 'en-US',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Get external IDs for each trending item
|
||||
|
|
@ -551,7 +767,8 @@ export class TMDBService {
|
|||
const externalIdsResponse = await axios.get(
|
||||
`${BASE_URL}/${type}/${item.id}/external_ids`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams(),
|
||||
}
|
||||
);
|
||||
return {
|
||||
|
|
@ -571,6 +788,42 @@ export class TMDBService {
|
|||
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();
|
||||
|
|
|
|||
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;
|
||||
}
|
||||