Compare commits
358 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf3a344f3 | ||
|
|
14e8e90ee3 | ||
|
|
d52c202518 | ||
|
|
c728f4ea8d | ||
|
|
c20c2713d0 | ||
|
|
d398c73214 | ||
|
|
9e6b455323 | ||
|
|
5a2271c64e | ||
|
|
eb6fcf639f | ||
|
|
a85cc93026 | ||
|
|
56fd18a8e9 | ||
|
|
82d0ebb714 | ||
|
|
df5772d40b | ||
|
|
3030d5961d | ||
|
|
6974768457 | ||
|
|
d31cd2fcdc | ||
|
|
b916bdbcca | ||
|
|
67d53cf5ce | ||
|
|
175d6a173e | ||
|
|
b7140e15a5 | ||
|
|
76310dae1b | ||
|
|
01a041aebf | ||
|
|
031c0c8772 | ||
|
|
fd1e303403 | ||
|
|
45b63cb33f | ||
|
|
c9dfecb68c | ||
|
|
aa6406eae0 | ||
|
|
26e4c6db88 | ||
|
|
2439bd1cd8 | ||
|
|
1fdcdd02bf | ||
|
|
bb94a49662 | ||
|
|
2ebec55bbc | ||
|
|
5fe23c7ad1 | ||
|
|
b6a5c108de | ||
|
|
83ce7cf44d | ||
|
|
28632d192f | ||
|
|
2a265bf716 | ||
|
|
b06800860c | ||
|
|
75702d823f | ||
|
|
f865b737e6 | ||
|
|
2169354f0d | ||
|
|
8dc1217c36 | ||
|
|
0a1511f09f | ||
|
|
73030f150a | ||
|
|
a1f4702647 | ||
|
|
2ddfe63fa4 | ||
|
|
79ffe92864 | ||
|
|
e5178c9414 | ||
|
|
f779febc32 | ||
|
|
5afd3d6b08 | ||
|
|
6005574019 | ||
|
|
645dcecaca | ||
|
|
1686138499 | ||
|
|
cd1ed27f1e | ||
|
|
3b210b06d5 | ||
|
|
0f9c1b03a5 | ||
|
|
217244c367 | ||
|
|
852868cf89 | ||
|
|
a52a2ccc31 | ||
|
|
210ae6b0ee | ||
|
|
c6e55429e4 | ||
|
|
07b27dd485 | ||
|
|
5166dbd446 | ||
|
|
0722923a78 | ||
|
|
a85698b009 | ||
|
|
9b2b619121 | ||
|
|
ac097f6513 | ||
|
|
a383289457 | ||
|
|
e76b44cff1 | ||
|
|
0f9f6bbe5d | ||
|
|
c48670fa74 | ||
|
|
c530619039 | ||
|
|
5e221e7e97 | ||
|
|
65909a5f2e | ||
|
|
bbdd4c0504 | ||
|
|
9924d26ff6 | ||
|
|
b10aab6057 | ||
|
|
ccad48fbb4 | ||
|
|
91e9549ec6 | ||
|
|
066bf6f15d | ||
|
|
56df30a4da | ||
|
|
27ce25f5c5 | ||
|
|
334d0b1863 | ||
|
|
437645d5fd | ||
|
|
280536e93c | ||
|
|
611b37c847 | ||
|
|
5e3198c9c6 | ||
|
|
6ef047db3c | ||
|
|
cdab715463 | ||
|
|
96ac361c8e | ||
|
|
ed4950cd1f | ||
|
|
afddf4bf2d | ||
|
|
9c37ad8b94 | ||
|
|
9877f513e2 | ||
|
|
f4b5082827 | ||
|
|
1627928fb2 | ||
|
|
6ff5aa9e02 | ||
|
|
20601cd7ba | ||
|
|
2d6b4afa2d | ||
|
|
4ce14ec4cc | ||
|
|
0f1d736716 | ||
|
|
edeb6ebe3c | ||
|
|
ab7f008bbb | ||
|
|
1e60af1ffb | ||
|
|
4dd1fca0a7 | ||
|
|
81b97da75e | ||
|
|
6a7d6a1458 | ||
|
|
2835ede747 | ||
|
|
59f77ac831 | ||
|
|
3e63efc178 | ||
|
|
4aa22cc1c3 | ||
|
|
4fdda9a184 | ||
|
|
5bd9f41104 | ||
|
|
486ea63a8a | ||
|
|
0919a40c75 | ||
|
|
3de2fb4809 | ||
|
|
3d5a9ebf42 | ||
|
|
be3e111e63 | ||
|
|
8a0bed7238 | ||
|
|
d2556b6c36 | ||
|
|
506ca4f95c | ||
|
|
5b2c57d5c7 | ||
|
|
7c2b1ac73d | ||
|
|
a55669d16f | ||
|
|
656062bc25 | ||
|
|
b42401a909 | ||
|
|
2c6c110265 | ||
|
|
e7b3458f34 | ||
|
|
e0ad949141 | ||
|
|
28d27128d1 | ||
|
|
ebbe715581 | ||
|
|
af138944b5 | ||
|
|
4603d1dc2a | ||
|
|
e323906083 | ||
|
|
6cb115ed74 | ||
|
|
0149068126 | ||
|
|
7894258a26 | ||
|
|
775242255a | ||
|
|
faa4f341e6 | ||
|
|
a079649563 | ||
|
|
63359532a3 | ||
|
|
5d42a828d2 | ||
|
|
2da03d4931 | ||
|
|
4235e327fc | ||
|
|
0d3454cd24 | ||
|
|
5850650713 | ||
|
|
47f3cb4b71 | ||
|
|
2b802079a0 | ||
|
|
0d416f724c | ||
|
|
a6a0a8b1b1 | ||
|
|
dd1a3ed496 | ||
|
|
9f3831e733 | ||
|
|
fd1107a5a3 | ||
|
|
a9a78d5565 | ||
|
|
a794e27235 | ||
|
|
9bb9d6548a | ||
|
|
3625ca9edc | ||
|
|
3293b57537 | ||
|
|
867458b52f | ||
|
|
8daca53be3 | ||
|
|
4174fd2add | ||
|
|
d3041f99cc | ||
|
|
6acfa2971b | ||
|
|
7271ed39a0 | ||
|
|
639e84bb88 | ||
|
|
36ad45cfbc | ||
|
|
c0540db282 | ||
|
|
7d6008b0a9 | ||
|
|
af96d30122 | ||
|
|
bf75cca438 | ||
|
|
3285ecbe04 | ||
|
|
6906ad99b7 | ||
|
|
f3c5289013 | ||
|
|
be9473adf7 | ||
|
|
ec28f73df9 | ||
|
|
d19f4713a2 | ||
|
|
2e79c34068 | ||
|
|
c7e5696974 | ||
|
|
154d034e8f | ||
|
|
916eeaef4c | ||
|
|
ad18e30de7 | ||
|
|
d4917fefc9 | ||
|
|
67b16c27f3 | ||
|
|
f15fe80d3a | ||
|
|
fbb44b14dd | ||
|
|
103bcdd4cc | ||
|
|
5e04ebca18 | ||
|
|
b00812333a | ||
|
|
9012bfdea9 | ||
|
|
9e5877173e | ||
|
|
b165c3223d | ||
|
|
79213ad573 | ||
|
|
7df42903c6 | ||
|
|
42d4290acd | ||
|
|
8c449215a6 | ||
|
|
183d30c720 | ||
|
|
53a572ecac | ||
|
|
4173786b12 | ||
|
|
44abb9f635 | ||
|
|
fd6e29a8ec | ||
|
|
832e5368be | ||
|
|
e543d72879 | ||
|
|
b4b8648e25 | ||
|
|
ff2bca18a5 | ||
|
|
cf5cc2d8f9 | ||
|
|
a30fa604d7 | ||
|
|
18e90397d9 | ||
|
|
97f558faf4 | ||
|
|
69dacb0ede | ||
|
|
95e7d44035 | ||
|
|
d39a485d24 | ||
|
|
4f0a673f87 | ||
|
|
8618dcda74 | ||
|
|
cc8be32cac | ||
|
|
f65eb8fe7e | ||
|
|
7c1a69d136 | ||
|
|
7fdd4c4383 | ||
|
|
43cd14a025 | ||
|
|
5662ee908d | ||
|
|
de7fcb4d4d | ||
|
|
f6dea03c05 | ||
|
|
6e2ddd2dda | ||
|
|
2d97cad1dc | ||
|
|
1d9a3b645b | ||
|
|
a89c7f5c5c | ||
|
|
91af3a4021 | ||
|
|
7a5ecd3009 | ||
|
|
aed4fed56f | ||
|
|
7885df341e | ||
|
|
2921b3eb1f | ||
|
|
579b0a77b3 | ||
|
|
063f8a8c1b | ||
|
|
985d01d5a9 | ||
|
|
9f461f7091 | ||
|
|
0b4db84f30 | ||
|
|
7e7804b6d4 | ||
|
|
eee6f81fca | ||
|
|
9375fab06c | ||
|
|
d2987ce0cc | ||
|
|
a61c1e6456 | ||
|
|
0a1e008d5f | ||
|
|
7f9e9ff5db | ||
|
|
39498f78b7 | ||
|
|
8588aca948 | ||
|
|
3f63461d45 | ||
|
|
f5e9a3977b | ||
|
|
aa62cc78f0 | ||
|
|
d6bb2869c5 | ||
|
|
74764bbbe0 | ||
|
|
441e8d8656 | ||
|
|
52dd075b6a | ||
|
|
1821bf1230 | ||
|
|
ab720ddae7 | ||
|
|
b4cecee191 | ||
|
|
614597d1bd | ||
|
|
0165b1f987 | ||
|
|
8b3a1b57bf | ||
|
|
c421e46724 | ||
|
|
1f3b9413cd | ||
|
|
6855a89792 | ||
|
|
811701ebae | ||
|
|
18fa11fd88 | ||
|
|
767fd2ff87 | ||
|
|
68c5b09e3a | ||
|
|
d2f9b7586a | ||
|
|
4753b2a57a | ||
|
|
5119822c31 | ||
|
|
09d0483ee3 | ||
|
|
034fd8a9aa | ||
|
|
b3ec4e0c01 | ||
|
|
f0f71afd67 | ||
|
|
3cea291901 | ||
|
|
9504d48607 | ||
|
|
19438ff1d5 | ||
|
|
967b90b98e | ||
|
|
0d6d69e0a8 | ||
|
|
a50f8de913 | ||
|
|
32df7d79ad | ||
|
|
759215da8c | ||
|
|
894469ae0e | ||
|
|
79b0cfc990 | ||
|
|
a8dfe30546 | ||
|
|
dda34b6982 | ||
|
|
599e31c11f | ||
|
|
ffc4200b96 | ||
|
|
d23c48cc0c | ||
|
|
aaf0b498f8 | ||
|
|
bda3732a83 | ||
|
|
b2a9708856 | ||
|
|
6e3f79a231 | ||
|
|
395b01d22b | ||
|
|
d9aaa045fd | ||
|
|
d6f2cb7592 | ||
|
|
5804959ddf | ||
|
|
af572f8b29 | ||
|
|
104d0f4516 | ||
|
|
b061c1f756 | ||
|
|
89f99dba85 | ||
|
|
ea25526ded | ||
|
|
2d5b1263b5 | ||
|
|
371aacd734 | ||
|
|
af1b0b03d8 | ||
|
|
baee619d73 | ||
|
|
b3f5ba4260 | ||
|
|
7ec9c3591e | ||
|
|
407514301b | ||
|
|
35abf985a9 | ||
|
|
374bc8e2d3 | ||
|
|
48300bf767 | ||
|
|
78553d8323 | ||
|
|
8c0b47975c | ||
|
|
601a4a0f1d | ||
|
|
59cb902658 | ||
|
|
d876b7618c | ||
|
|
60cdf9fe86 | ||
|
|
619333c328 | ||
|
|
80d75a528f | ||
|
|
4ac45a041a | ||
|
|
51064a65b2 | ||
|
|
2e61617f83 | ||
|
|
dbbee06a55 | ||
|
|
181cdaecb5 | ||
|
|
c3fbe31fd4 | ||
|
|
f05366ae45 | ||
|
|
1c53e65b26 | ||
|
|
c01528b309 | ||
|
|
53dd480231 | ||
|
|
3c35b99759 | ||
|
|
8a34bf6678 | ||
|
|
8b5a707daa | ||
|
|
bf22e559c5 | ||
|
|
9e7543df02 | ||
|
|
52065a1462 | ||
|
|
01953af578 | ||
|
|
e160bf6fe0 | ||
|
|
ff9d2c52be | ||
|
|
3801e80dd9 | ||
|
|
1307a71b4c | ||
|
|
2c9072299e | ||
|
|
6bdc998496 | ||
|
|
1b990aa6ec | ||
|
|
057c709b41 | ||
|
|
d457db5053 | ||
|
|
22d8fe311a | ||
|
|
e9796ee966 | ||
|
|
6c201e285a | ||
|
|
6c326e1378 | ||
|
|
725c8aa9b7 | ||
|
|
7a2f340c22 | ||
|
|
c4af2e8eea | ||
|
|
63a7051b86 | ||
|
|
9f75bfdeed | ||
|
|
083da01463 | ||
|
|
14980f2bfd | ||
|
|
6c08b459bf | ||
|
|
6d1ba14ab4 | ||
|
|
bbf035ebae | ||
|
|
a4725c24bc |
|
|
@ -17,3 +17,8 @@ EXPO_PUBLIC_MOVIEBOX_TMDB_API_KEY=your_tmdb_api_key_for_moviebox
|
|||
EXPO_PUBLIC_TRAKT_CLIENT_ID=your_trakt_client_id
|
||||
EXPO_PUBLIC_TRAKT_CLIENT_SECRET=your_trakt_client_secret
|
||||
EXPO_PUBLIC_TRAKT_REDIRECT_URI=stremioexpo://auth/trakt
|
||||
|
||||
# Skip Intro API (IntroDB)
|
||||
# Fetches intro timestamps for TV shows to enable skip intro functionality
|
||||
EXPO_PUBLIC_INTRODB_API_URL=https://api.introdb.app
|
||||
EXPO_PUBLIC_DISCORD_USER_API=
|
||||
19
.gitignore
vendored
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
# dependencies
|
||||
node_modules/
|
||||
# Un-ignore specific react-native-video source files we patch
|
||||
!node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
|
||||
!node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
|
||||
!node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
|
||||
|
||||
# Expo
|
||||
|
|
@ -31,9 +34,9 @@ yarn-error.*
|
|||
*.pem
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
.env*.local
|
||||
.env
|
||||
# Sentry
|
||||
ios/sentry.properties
|
||||
android/sentry.properties
|
||||
|
|
@ -51,6 +54,7 @@ android/build/
|
|||
android/.gradle/
|
||||
android/app/libs/*.aar
|
||||
!android/app/libs/lib-decoder-ffmpeg-release.aar
|
||||
!android/app/libs/libmpv-release.aar
|
||||
HEATING_OPTIMIZATIONS.md
|
||||
# sliderreadme.md
|
||||
.cursor/mcp.json
|
||||
|
|
@ -70,7 +74,7 @@ sliderreadme.md
|
|||
bottomsheet.md
|
||||
fastimage.md
|
||||
|
||||
# Backup directories
|
||||
## Backup directories
|
||||
backup_sdk54_upgrade/
|
||||
SDK54_UPGRADE_SUMMARY.md
|
||||
SDK54_UPGRADE_SUMMARY.md
|
||||
|
|
@ -80,8 +84,17 @@ bottomnav.md
|
|||
mmkv.md
|
||||
fix-android-scroll-lag-summary.md
|
||||
server/cache-server
|
||||
server/campaign-manager
|
||||
carousal.md
|
||||
node_modules
|
||||
expofs.md
|
||||
ios/sentry.properties
|
||||
android/sentry.properties
|
||||
Stremio addons refer
|
||||
trakt-docs
|
||||
trakt-docss
|
||||
|
||||
# Removed submodules (kept locally)
|
||||
libmpv-android/
|
||||
mpv-android/
|
||||
mpvKt/
|
||||
75
App.tsx
|
|
@ -13,15 +13,17 @@ import {
|
|||
Platform,
|
||||
LogBox
|
||||
} from 'react-native';
|
||||
import './src/i18n'; // Initialize i18n
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Provider as PaperProvider } from 'react-native-paper';
|
||||
import { enableScreens, enableFreeze } from 'react-native-screens';
|
||||
import AppNavigator, {
|
||||
import AppNavigator, {
|
||||
CustomNavigationDarkTheme,
|
||||
CustomDarkTheme
|
||||
} from './src/navigation/AppNavigator';
|
||||
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
|
||||
import 'react-native-reanimated';
|
||||
import { CatalogProvider } from './src/contexts/CatalogContext';
|
||||
import { GenreProvider } from './src/contexts/GenreContext';
|
||||
|
|
@ -41,6 +43,7 @@ import { aiService } from './src/services/aiService';
|
|||
import { AccountProvider, useAccount } from './src/contexts/AccountContext';
|
||||
import { ToastProvider } from './src/contexts/ToastContext';
|
||||
import { mmkvStorage } from './src/services/mmkvStorage';
|
||||
import { CampaignManager } from './src/components/promotions/CampaignManager';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
|
||||
|
|
@ -82,12 +85,12 @@ const ThemedApp = () => {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const engine = (global as any).HermesInternal ? 'Hermes' : 'JSC';
|
||||
console.log('JS Engine:', engine);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}, []);
|
||||
const { currentTheme } = useTheme();
|
||||
const [isAppReady, setIsAppReady] = useState(false);
|
||||
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState<boolean | null>(null);
|
||||
|
||||
|
||||
// Update popup functionality
|
||||
const {
|
||||
showUpdatePopup,
|
||||
|
|
@ -100,7 +103,7 @@ const ThemedApp = () => {
|
|||
|
||||
// GitHub major/minor release overlay
|
||||
const githubUpdate = useGithubMajorUpdate();
|
||||
|
||||
|
||||
// Check onboarding status and initialize services
|
||||
useEffect(() => {
|
||||
const initializeApp = async () => {
|
||||
|
|
@ -108,28 +111,28 @@ const ThemedApp = () => {
|
|||
// Check onboarding status
|
||||
const onboardingCompleted = await mmkvStorage.getItem('hasCompletedOnboarding');
|
||||
setHasCompletedOnboarding(onboardingCompleted === 'true');
|
||||
|
||||
|
||||
// Initialize update service
|
||||
await UpdateService.initialize();
|
||||
|
||||
|
||||
// Initialize memory monitoring service to prevent OutOfMemoryError
|
||||
memoryMonitorService; // Just accessing it starts the monitoring
|
||||
console.log('Memory monitoring service initialized');
|
||||
|
||||
|
||||
// Initialize AI service
|
||||
await aiService.initialize();
|
||||
console.log('AI service initialized');
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing app:', error);
|
||||
// Default to showing onboarding if we can't check
|
||||
setHasCompletedOnboarding(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
initializeApp();
|
||||
}, []);
|
||||
|
||||
|
||||
// Create custom themes based on current theme
|
||||
const customDarkTheme = {
|
||||
...CustomDarkTheme,
|
||||
|
|
@ -138,7 +141,7 @@ const ThemedApp = () => {
|
|||
primary: currentTheme.colors.primary,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const customNavigationTheme = {
|
||||
...CustomNavigationDarkTheme,
|
||||
colors: {
|
||||
|
|
@ -153,17 +156,30 @@ const ThemedApp = () => {
|
|||
const handleSplashComplete = () => {
|
||||
setIsAppReady(true);
|
||||
};
|
||||
|
||||
|
||||
// Navigation reference
|
||||
const navigationRef = React.useRef<any>(null);
|
||||
|
||||
// Don't render anything until we know the onboarding status
|
||||
const shouldShowApp = isAppReady && hasCompletedOnboarding !== null;
|
||||
const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding';
|
||||
|
||||
|
||||
return (
|
||||
<AccountProvider>
|
||||
<PaperProvider theme={customDarkTheme}>
|
||||
<NavigationContainer
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
theme={customNavigationTheme}
|
||||
linking={undefined}
|
||||
linking={{
|
||||
prefixes: ['nuvio://'],
|
||||
config: {
|
||||
screens: {
|
||||
ScraperSettings: {
|
||||
path: 'repo',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DownloadsProvider>
|
||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
|
|
@ -186,6 +202,7 @@ const ThemedApp = () => {
|
|||
onDismiss={githubUpdate.onDismiss}
|
||||
onLater={githubUpdate.onLater}
|
||||
/>
|
||||
<CampaignManager />
|
||||
</View>
|
||||
</DownloadsProvider>
|
||||
</NavigationContainer>
|
||||
|
|
@ -197,19 +214,21 @@ const ThemedApp = () => {
|
|||
function App(): React.JSX.Element {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<GenreProvider>
|
||||
<CatalogProvider>
|
||||
<TraktProvider>
|
||||
<ThemeProvider>
|
||||
<TrailerProvider>
|
||||
<ToastProvider>
|
||||
<ThemedApp />
|
||||
</ToastProvider>
|
||||
</TrailerProvider>
|
||||
</ThemeProvider>
|
||||
</TraktProvider>
|
||||
</CatalogProvider>
|
||||
</GenreProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<GenreProvider>
|
||||
<CatalogProvider>
|
||||
<TraktProvider>
|
||||
<ThemeProvider>
|
||||
<TrailerProvider>
|
||||
<ToastProvider>
|
||||
<ThemedApp />
|
||||
</ToastProvider>
|
||||
</TrailerProvider>
|
||||
</ThemeProvider>
|
||||
</TraktProvider>
|
||||
</CatalogProvider>
|
||||
</GenreProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
65
README.md
|
|
@ -1,16 +1,15 @@
|
|||
<!-- Improved compatibility of back to top link -->
|
||||
<a id="readme-top"></a>
|
||||
|
||||
<!-- PROJECT SHIELDS -->
|
||||
[![Contributors][contributors-shield]][contributors-url]
|
||||
[![Forks][forks-shield]][forks-url]
|
||||
[![Stargazers][stars-shield]][stars-url]
|
||||
[![Issues][issues-shield]][issues-url]
|
||||
[![License][license-shield]][license-url]
|
||||
|
||||
<!-- PROJECT LOGO -->
|
||||
<br />
|
||||
<div align="center">
|
||||
<a id="readme-top"></a>
|
||||
|
||||
[![Contributors][contributors-shield]][contributors-url]
|
||||
[![Forks][forks-shield]][forks-url]
|
||||
[![Stargazers][stars-shield]][stars-url]
|
||||
[![Issues][issues-shield]][issues-url]
|
||||
[![License][license-shield]][license-url]
|
||||
|
||||
<br />
|
||||
<br />
|
||||
<img src="assets/titlelogo.png" alt="Nuvio Logo" width="120" />
|
||||
<h1 align="center">🎬 Nuvio Media Hub</h1>
|
||||
<p align="center">
|
||||
|
|
@ -22,11 +21,10 @@
|
|||
<a href="#getting-started"><strong>Get Started »</strong></a>
|
||||
<br />
|
||||
<br />
|
||||
<a href="#demo">View Screenshots</a>
|
||||
·
|
||||
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=bug&template=bug_report.md">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=enhancement&template=feature_request.md">Request Feature</a>
|
||||
|
||||
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=bug&template=bug_report.md">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=enhancement&template=feature_request.md">Request Feature</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -38,11 +36,13 @@
|
|||
<a href="#about-the-project">About The Project</a>
|
||||
</li>
|
||||
<li><a href="#installation">Installation</a></li>
|
||||
<li><a href="#demo">Screenshots</a></li>
|
||||
|
||||
<li><a href="#getting-started">Getting Started</a></li>
|
||||
<li><a href="#contributing">Contributing</a></li>
|
||||
<li><a href="#support">Support</a></li>
|
||||
<li><a href="#support">Support</a></li>
|
||||
<li><a href="#license">License</a></li>
|
||||
<li><a href="#legal">Legal</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
<li><a href="#acknowledgments">Acknowledgments</a></li>
|
||||
<li><a href="#built-with">Built With</a></li>
|
||||
|
|
@ -66,6 +66,9 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
|
|||
|
||||
### iOS
|
||||
|
||||
#### TestFlight (Recommended)
|
||||
<img src="https://upload.wikimedia.org/wikipedia/fr/b/bc/TestFlight-icon.png" width="24" height="24" align="left"> [](https://testflight.apple.com/join/QkKMGRqp)
|
||||
|
||||
#### AltStore
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/2/20/AltStore_logo.png" width="24" height="24" align="left"> [](https://tinyurl.com/NuvioAltstore)
|
||||
|
||||
|
|
@ -76,20 +79,12 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
|
|||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
<!-- DEMO / SCREENSHOTS -->
|
||||
## Demo
|
||||
<a id="demo"></a>
|
||||
|
||||
| Home | Details |
|
||||
|:----:|:-------:|
|
||||
|  |  |
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
<!-- GETTING STARTED -->
|
||||
## Getting Started
|
||||
|
||||
Follow the steps below to run the app locally for development.
|
||||
Follow the steps below to run the app locally for development. For detailed setup and troubleshooting, see [Project Documentation](docs/DOCUMENTATION.md).
|
||||
|
||||
### Development Build
|
||||
|
||||
|
|
@ -145,6 +140,14 @@ Distributed under the GNU GPLv3 License. See `LICENSE` for more information.
|
|||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
## Legal
|
||||
|
||||
For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://tapframe.github.io/NuvioStreaming/#legal)**.
|
||||
|
||||
**Disclaimer:** Nuvio functions solely as a client-side interface for browsing metadata and playing media files provided by user-installed extensions. It does not host, store, or distribute any media content.
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
## Contact
|
||||
|
||||
**Project Links:**
|
||||
|
|
@ -174,6 +177,16 @@ Distributed under the GNU GPLv3 License. See `LICENSE` for more information.
|
|||
React Native • Expo • TypeScript
|
||||
</p>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#tapframe/NuvioStreaming&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=tapframe/NuvioStreaming&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=tapframe/NuvioStreaming&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=tapframe/NuvioStreaming&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ android {
|
|||
applicationId 'com.nuvio.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 25
|
||||
versionName "1.2.10"
|
||||
versionCode 33
|
||||
versionName "1.3.5"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ android {
|
|||
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
def baseVersionCode = 25 // Current versionCode 25 from defaultConfig
|
||||
def baseVersionCode = 33 // Current versionCode 33 from defaultConfig
|
||||
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
|
||||
def versionCode = baseVersionCode * 100 // Base multiplier
|
||||
|
|
@ -246,6 +246,9 @@ dependencies {
|
|||
|
||||
// Include only FFmpeg decoder AAR to avoid duplicates with Maven Media3
|
||||
implementation files("libs/lib-decoder-ffmpeg-release.aar")
|
||||
|
||||
// MPV Player library
|
||||
implementation files("libs/libmpv-release.aar")
|
||||
|
||||
// Google Cast Framework
|
||||
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"
|
||||
|
|
|
|||
BIN
android/app/libs/libmpv-release.aar
Normal file
|
|
@ -1,4 +1,5 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
|
@ -20,7 +21,7 @@
|
|||
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ERROR_RECOVERY_ONLY"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="30000"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://ota.nuvioapp.space/api/manifest"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|locale|layoutDirection" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import com.facebook.react.defaults.DefaultReactNativeHost
|
|||
|
||||
import expo.modules.ApplicationLifecycleDispatcher
|
||||
import expo.modules.ReactNativeHostWrapper
|
||||
import com.nuvio.app.mpv.MpvPackage
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
|
|
@ -24,7 +25,7 @@ class MainApplication : Application(), ReactApplication {
|
|||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
add(com.nuvio.app.mpv.MpvPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||
|
|
|
|||
615
android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
package com.nuvio.app.mpv
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.TextureView
|
||||
import dev.jdtech.mpv.MPVLib
|
||||
|
||||
import com.facebook.react.bridge.LifecycleEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
|
||||
class MPVView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener, MPVLib.EventObserver {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MPVView"
|
||||
}
|
||||
|
||||
private var isMpvInitialized = false
|
||||
private var pendingDataSource: String? = null
|
||||
private var isPaused: Boolean = true
|
||||
private var surface: Surface? = null
|
||||
private var httpHeaders: Map<String, String>? = null
|
||||
|
||||
// Decoder mode setting: 'auto', 'sw', 'hw', 'hw+' (default: auto)
|
||||
var decoderMode: String = "auto"
|
||||
|
||||
// GPU mode setting: 'gpu', 'gpu-next' (default: gpu)
|
||||
var gpuMode: String = "gpu"
|
||||
|
||||
// Flag to track if onLoad has been fired (prevents multiple fires for HLS streams)
|
||||
private var hasLoadEventFired: Boolean = false
|
||||
|
||||
// Event listener for React Native
|
||||
var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null
|
||||
var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null
|
||||
var onEndCallback: (() -> Unit)? = null
|
||||
var onErrorCallback: ((message: String) -> Unit)? = null
|
||||
var onTracksChangedCallback: ((audioTracks: List<Map<String, Any>>, subtitleTracks: List<Map<String, Any>>) -> Unit)? = null
|
||||
|
||||
private var resumeOnForeground = false
|
||||
private val lifeCycleListener = object : LifecycleEventListener {
|
||||
override fun onHostPause() {
|
||||
resumeOnForeground = !isPaused;
|
||||
if(resumeOnForeground) {
|
||||
Log.d(TAG, "App backgrounded — pausing MPV")
|
||||
setPaused(true)
|
||||
}
|
||||
}
|
||||
override fun onHostResume() {
|
||||
if(resumeOnForeground) {
|
||||
setPaused(false)
|
||||
resumeOnForeground = false
|
||||
}
|
||||
}
|
||||
override fun onHostDestroy() {}
|
||||
}
|
||||
init {
|
||||
surfaceTextureListener = this
|
||||
isOpaque = false
|
||||
(context as? ReactContext)?.addLifecycleEventListener(lifeCycleListener)
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
Log.d(TAG, "Surface texture available: ${width}x${height}")
|
||||
try {
|
||||
surface = Surface(surfaceTexture)
|
||||
|
||||
MPVLib.create(context.applicationContext)
|
||||
initOptions()
|
||||
MPVLib.init()
|
||||
MPVLib.attachSurface(surface!!)
|
||||
MPVLib.addObserver(this)
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
|
||||
observeProperties()
|
||||
isMpvInitialized = true
|
||||
|
||||
// If a data source was set before surface was ready, load it now
|
||||
// Headers are already applied in initOptions() before init()
|
||||
pendingDataSource?.let { url ->
|
||||
loadFile(url)
|
||||
pendingDataSource = null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize MPV", e)
|
||||
onErrorCallback?.invoke("MPV initialization failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
Log.d(TAG, "Surface texture size changed: ${width}x${height}")
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
Log.d(TAG, "Surface texture destroyed")
|
||||
(context as? ReactContext)?.removeLifecycleEventListener(lifeCycleListener)
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.removeObserver(this)
|
||||
MPVLib.detachSurface()
|
||||
MPVLib.destroy()
|
||||
isMpvInitialized = false
|
||||
}
|
||||
surface?.release()
|
||||
surface = null
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||
// Called when the SurfaceTexture is updated via updateTexImage()
|
||||
}
|
||||
|
||||
private fun initOptions() {
|
||||
MPVLib.setOptionString("profile", "fast")
|
||||
|
||||
// GPU rendering mode (gpu or gpu-next)
|
||||
MPVLib.setOptionString("vo", gpuMode)
|
||||
MPVLib.setOptionString("gpu-context", "android")
|
||||
MPVLib.setOptionString("opengl-es", "yes")
|
||||
|
||||
// Decoder mode mapping (same as mpvKt)
|
||||
val hwdecValue = when (decoderMode) {
|
||||
"auto" -> "auto-copy" // Best balance: HW decode, copy to CPU for filters
|
||||
"sw" -> "no" // Software decoding only
|
||||
"hw" -> "mediacodec-copy" // HW decode with copy (safer)
|
||||
"hw+" -> "mediacodec" // Full HW decode (fastest, may have issues)
|
||||
else -> "auto-copy"
|
||||
}
|
||||
Log.d(TAG, "Decoder mode: $decoderMode, hwdec value: $hwdecValue, GPU mode: $gpuMode")
|
||||
MPVLib.setOptionString("hwdec", hwdecValue)
|
||||
// Note: Not setting hwdec-codecs explicitly - let mpv use defaults
|
||||
|
||||
MPVLib.setOptionString("target-colorspace-hint", "yes")
|
||||
|
||||
// HDR and Dolby Vision support
|
||||
// target-prim: Signal target display primaries (auto = passthrough when display supports)
|
||||
MPVLib.setOptionString("target-prim", "auto")
|
||||
// target-trc: Signal target transfer characteristics (auto = passthrough when display supports)
|
||||
MPVLib.setOptionString("target-trc", "auto")
|
||||
// tone-mapping: How to handle HDR/DV content on SDR displays (auto = best automatic choice)
|
||||
MPVLib.setOptionString("tone-mapping", "auto")
|
||||
// hdr-compute-peak: Compute peak brightness for better tone mapping
|
||||
MPVLib.setOptionString("hdr-compute-peak", "auto")
|
||||
// Allow DV Profile 5 (HEVC with RPU) to be decoded by hardware decoder
|
||||
MPVLib.setOptionString("vd-lavc-o", "strict=-2")
|
||||
|
||||
// Workaround for https://github.com/mpv-player/mpv/issues/14651
|
||||
MPVLib.setOptionString("vd-lavc-film-grain", "cpu")
|
||||
|
||||
MPVLib.setOptionString("ao", "audiotrack,opensles")
|
||||
|
||||
// Limit demuxer cache based on Android version (like mpvKt)
|
||||
val cacheMegs = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) 64 else 32
|
||||
MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}")
|
||||
MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}")
|
||||
MPVLib.setOptionString("cache", "yes")
|
||||
MPVLib.setOptionString("cache-secs", "30")
|
||||
|
||||
MPVLib.setOptionString("network-timeout", "60")
|
||||
MPVLib.setOptionString("ytdl", "no")
|
||||
|
||||
applyHttpHeadersAsOptions()
|
||||
|
||||
MPVLib.setOptionString("tls-verify", "no")
|
||||
MPVLib.setOptionString("http-reconnect", "yes")
|
||||
MPVLib.setOptionString("stream-reconnect", "yes")
|
||||
|
||||
MPVLib.setOptionString("demuxer-lavf-o", "live_start_index=0,prefer_x_start=1,http_persistent=0")
|
||||
MPVLib.setOptionString("demuxer-seekable-cache", "yes")
|
||||
MPVLib.setOptionString("force-seekable", "yes")
|
||||
|
||||
MPVLib.setOptionString("sub-auto", "fuzzy")
|
||||
MPVLib.setOptionString("sub-visibility", "yes")
|
||||
MPVLib.setOptionString("sub-font-size", "48")
|
||||
MPVLib.setOptionString("sub-pos", "100")
|
||||
MPVLib.setOptionString("sub-color", "#FFFFFFFF")
|
||||
MPVLib.setOptionString("sub-border-size", "3")
|
||||
MPVLib.setOptionString("sub-border-color", "#FF000000")
|
||||
MPVLib.setOptionString("sub-shadow-offset", "2")
|
||||
MPVLib.setOptionString("sub-shadow-color", "#80000000")
|
||||
|
||||
MPVLib.setOptionString("osd-fonts-dir", "/system/fonts")
|
||||
MPVLib.setOptionString("sub-fonts-dir", "/system/fonts")
|
||||
MPVLib.setOptionString("sub-font", "Roboto")
|
||||
MPVLib.setOptionString("embeddedfonts", "yes")
|
||||
|
||||
MPVLib.setOptionString("sub-codepage", "auto")
|
||||
|
||||
MPVLib.setOptionString("blend-subtitles", "no")
|
||||
MPVLib.setOptionString("sub-use-margins", "yes")
|
||||
MPVLib.setOptionString("sub-ass-override", "force")
|
||||
MPVLib.setOptionString("sub-scale", "1.0")
|
||||
MPVLib.setOptionString("sub-fix-timing", "yes")
|
||||
|
||||
MPVLib.setOptionString("osc", "no")
|
||||
MPVLib.setOptionString("osd-level", "1")
|
||||
|
||||
MPVLib.setOptionString("sid", "auto")
|
||||
|
||||
MPVLib.setOptionString("terminal", "no")
|
||||
MPVLib.setOptionString("input-default-bindings", "no")
|
||||
}
|
||||
|
||||
private fun observeProperties() {
|
||||
// MPV format constants (from MPVLib source)
|
||||
val MPV_FORMAT_NONE = 0
|
||||
val MPV_FORMAT_FLAG = 3
|
||||
val MPV_FORMAT_INT64 = 4
|
||||
val MPV_FORMAT_DOUBLE = 5
|
||||
|
||||
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("duration/full", MPV_FORMAT_DOUBLE) // Use /full for complete HLS duration
|
||||
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("eof-reached", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("video-params/aspect", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("width", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("height", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("track-list", MPV_FORMAT_NONE)
|
||||
|
||||
// Observe subtitle properties for debugging
|
||||
MPVLib.observeProperty("sid", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("sub-visibility", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("sub-text", MPV_FORMAT_NONE)
|
||||
}
|
||||
|
||||
private fun loadFile(url: String) {
|
||||
Log.d(TAG, "Loading file: $url")
|
||||
// Reset load event flag for new file
|
||||
hasLoadEventFired = false
|
||||
MPVLib.command(arrayOf("loadfile", url))
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
fun setDataSource(url: String) {
|
||||
if (isMpvInitialized) {
|
||||
// Headers were already set during initialization in initOptions()
|
||||
loadFile(url)
|
||||
} else {
|
||||
pendingDataSource = url
|
||||
}
|
||||
}
|
||||
|
||||
fun setHeaders(headers: Map<String, String>?) {
|
||||
httpHeaders = headers
|
||||
Log.d(TAG, "Headers set: $headers")
|
||||
}
|
||||
|
||||
private fun applyHttpHeadersAsOptions() {
|
||||
// Always set user-agent (this works reliably)
|
||||
val userAgent = httpHeaders?.get("User-Agent")
|
||||
?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
Log.d(TAG, "Setting User-Agent: $userAgent")
|
||||
MPVLib.setOptionString("user-agent", userAgent)
|
||||
|
||||
// Additionally, set other headers via http-header-fields if present
|
||||
// This is needed for streams that require Referer, Origin, Cookie, etc.
|
||||
httpHeaders?.let { headers ->
|
||||
val otherHeaders = headers.filterKeys { it != "User-Agent" }
|
||||
if (otherHeaders.isNotEmpty()) {
|
||||
// Format as comma-separated "Key: Value" pairs
|
||||
val headerString = otherHeaders.map { (key, value) -> "$key: $value" }.joinToString(",")
|
||||
Log.d(TAG, "Setting additional headers: $headerString")
|
||||
MPVLib.setOptionString("http-header-fields", headerString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setPaused(paused: Boolean) {
|
||||
isPaused = paused
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.setPropertyBoolean("pause", paused)
|
||||
}
|
||||
}
|
||||
|
||||
fun seekTo(positionSeconds: Double) {
|
||||
Log.d(TAG, "seekTo called: positionSeconds=$positionSeconds, isMpvInitialized=$isMpvInitialized")
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Executing MPV seek command: seek $positionSeconds absolute")
|
||||
MPVLib.command(arrayOf("seek", positionSeconds.toString(), "absolute"))
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.setPropertyDouble("speed", speed)
|
||||
}
|
||||
}
|
||||
|
||||
fun setVolume(volume: Double) {
|
||||
if (isMpvInitialized) {
|
||||
// MPV volume is 0-100
|
||||
MPVLib.setPropertyDouble("volume", volume * 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
if (isMpvInitialized) {
|
||||
if (trackId == -1) {
|
||||
MPVLib.setPropertyString("aid", "no")
|
||||
} else {
|
||||
MPVLib.setPropertyInt("aid", trackId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized")
|
||||
if (isMpvInitialized) {
|
||||
if (trackId == -1) {
|
||||
Log.d(TAG, "Disabling subtitles (sid=no)")
|
||||
MPVLib.setPropertyString("sid", "no")
|
||||
MPVLib.setPropertyString("sub-visibility", "no")
|
||||
} else {
|
||||
Log.d(TAG, "Setting subtitle track to: $trackId")
|
||||
MPVLib.setPropertyInt("sid", trackId)
|
||||
// Ensure subtitles are visible
|
||||
MPVLib.setPropertyString("sub-visibility", "yes")
|
||||
|
||||
// Debug: Verify the subtitle was set correctly
|
||||
val currentSid = MPVLib.getPropertyInt("sid")
|
||||
val subVisibility = MPVLib.getPropertyString("sub-visibility")
|
||||
val subDelay = MPVLib.getPropertyDouble("sub-delay")
|
||||
val subScale = MPVLib.getPropertyDouble("sub-scale")
|
||||
Log.d(TAG, "After setting - sid=$currentSid, sub-visibility=$subVisibility, sub-delay=$subDelay, sub-scale=$subScale")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setResizeMode(mode: String) {
|
||||
Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized")
|
||||
if (isMpvInitialized) {
|
||||
when (mode) {
|
||||
"contain" -> {
|
||||
// Letterbox - show entire video with black bars
|
||||
MPVLib.setPropertyDouble("panscan", 0.0)
|
||||
MPVLib.setPropertyString("keepaspect", "yes")
|
||||
}
|
||||
"cover" -> {
|
||||
// Fill/crop - zoom to fill, cropping edges
|
||||
MPVLib.setPropertyDouble("panscan", 1.0)
|
||||
MPVLib.setPropertyString("keepaspect", "yes")
|
||||
}
|
||||
"stretch" -> {
|
||||
// Stretch - disable aspect ratio
|
||||
MPVLib.setPropertyDouble("panscan", 0.0)
|
||||
MPVLib.setPropertyString("keepaspect", "no")
|
||||
}
|
||||
else -> {
|
||||
// Default to contain
|
||||
MPVLib.setPropertyDouble("panscan", 0.0)
|
||||
MPVLib.setPropertyString("keepaspect", "yes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subtitle Styling Methods
|
||||
|
||||
fun setSubtitleSize(size: Int) {
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting subtitle size: $size")
|
||||
MPVLib.setPropertyInt("sub-font-size", size)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleColor(color: String) {
|
||||
if (isMpvInitialized) {
|
||||
// MPV expects color in #AARRGGBB format, but we receive #RRGGBB
|
||||
// Convert to MPV format with full opacity
|
||||
val mpvColor = if (color.length == 7) "#FF${color.substring(1)}" else color
|
||||
Log.d(TAG, "Setting subtitle color: $mpvColor")
|
||||
MPVLib.setPropertyString("sub-color", mpvColor)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleBackgroundColor(color: String, opacity: Float) {
|
||||
if (isMpvInitialized) {
|
||||
// Convert opacity (0-1) to hex (00-FF)
|
||||
val alphaHex = (opacity * 255).toInt().coerceIn(0, 255).let {
|
||||
String.format("%02X", it)
|
||||
}
|
||||
// MPV format: #AARRGGBB
|
||||
val baseColor = if (color.startsWith("#")) color.substring(1) else color
|
||||
val mpvColor = "#${alphaHex}${baseColor.takeLast(6)}"
|
||||
Log.d(TAG, "Setting subtitle background: $mpvColor (opacity: $opacity)")
|
||||
MPVLib.setPropertyString("sub-back-color", mpvColor)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleBorderSize(size: Int) {
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting subtitle border size: $size")
|
||||
MPVLib.setPropertyInt("sub-border-size", size)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleBorderColor(color: String) {
|
||||
if (isMpvInitialized) {
|
||||
val mpvColor = if (color.length == 7) "#FF${color.substring(1)}" else color
|
||||
Log.d(TAG, "Setting subtitle border color: $mpvColor")
|
||||
MPVLib.setPropertyString("sub-border-color", mpvColor)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleShadow(enabled: Boolean, offset: Int) {
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting subtitle shadow: enabled=$enabled, offset=$offset")
|
||||
if (enabled) {
|
||||
MPVLib.setPropertyInt("sub-shadow-offset", offset)
|
||||
MPVLib.setPropertyString("sub-shadow-color", "#80000000")
|
||||
} else {
|
||||
MPVLib.setPropertyInt("sub-shadow-offset", 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitlePosition(pos: Int) {
|
||||
if (isMpvInitialized) {
|
||||
// sub-pos: 0=top, 100=bottom, can go beyond 100 for more offset
|
||||
// UI sends bottomOffset (0=at bottom, higher=more up from bottom)
|
||||
// Convert: MPV pos = 100 - (bottomOffset / screenHeightFactor)
|
||||
// Simplified: just pass pos directly, UI should convert
|
||||
Log.d(TAG, "Setting subtitle position: $pos")
|
||||
MPVLib.setPropertyInt("sub-pos", pos)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleDelay(delaySec: Double) {
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting subtitle delay: $delaySec seconds")
|
||||
MPVLib.setPropertyDouble("sub-delay", delaySec)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleScale(scale: Double) {
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting subtitle scale: $scale")
|
||||
MPVLib.setPropertyDouble("sub-scale", scale)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleAlignment(align: String) {
|
||||
if (isMpvInitialized) {
|
||||
// MPV sub-justify values: left, center, right, auto
|
||||
val mpvAlign = when (align) {
|
||||
"left" -> "left"
|
||||
"right" -> "right"
|
||||
"center" -> "center"
|
||||
else -> "center"
|
||||
}
|
||||
Log.d(TAG, "Setting subtitle alignment: $mpvAlign")
|
||||
MPVLib.setPropertyString("sub-justify", mpvAlign)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleBold(bold: Boolean) {
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting subtitle bold: $bold")
|
||||
MPVLib.setPropertyString("sub-bold", if (bold) "yes" else "no")
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleItalic(italic: Boolean) {
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting subtitle italic: $italic")
|
||||
MPVLib.setPropertyString("sub-italic", if (italic) "yes" else "no")
|
||||
}
|
||||
}
|
||||
|
||||
// MPVLib.EventObserver implementation
|
||||
|
||||
override fun eventProperty(property: String) {
|
||||
Log.d(TAG, "Property changed: $property")
|
||||
when (property) {
|
||||
"track-list" -> {
|
||||
// Parse track list and notify React Native
|
||||
parseAndSendTracks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAndSendTracks() {
|
||||
try {
|
||||
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||
Log.d(TAG, "Track count: $trackCount")
|
||||
|
||||
val audioTracks = mutableListOf<Map<String, Any>>()
|
||||
val subtitleTracks = mutableListOf<Map<String, Any>>()
|
||||
|
||||
for (i in 0 until trackCount) {
|
||||
val type = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||
val id = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val title = MPVLib.getPropertyString("track-list/$i/title") ?: ""
|
||||
val lang = MPVLib.getPropertyString("track-list/$i/lang") ?: ""
|
||||
val codec = MPVLib.getPropertyString("track-list/$i/codec") ?: ""
|
||||
|
||||
val trackName = when {
|
||||
title.isNotEmpty() -> title
|
||||
lang.isNotEmpty() -> lang.uppercase()
|
||||
else -> "Track $id"
|
||||
}
|
||||
|
||||
val track = mapOf(
|
||||
"id" to id,
|
||||
"name" to trackName,
|
||||
"language" to lang,
|
||||
"codec" to codec
|
||||
)
|
||||
|
||||
when (type) {
|
||||
"audio" -> {
|
||||
Log.d(TAG, "Found audio track: $track")
|
||||
audioTracks.add(track)
|
||||
}
|
||||
"sub" -> {
|
||||
Log.d(TAG, "Found subtitle track: $track")
|
||||
subtitleTracks.add(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sending tracks - Audio: ${audioTracks.size}, Subtitles: ${subtitleTracks.size}")
|
||||
onTracksChangedCallback?.invoke(audioTracks, subtitleTracks)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing tracks", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Long) {
|
||||
Log.d(TAG, "Property $property = $value (Long)")
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Double) {
|
||||
Log.d(TAG, "Property $property = $value (Double)")
|
||||
when (property) {
|
||||
"time-pos" -> {
|
||||
val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0
|
||||
onProgressCallback?.invoke(value, duration)
|
||||
}
|
||||
"duration/full", "duration" -> {
|
||||
// Only fire onLoad once when video dimensions are available
|
||||
// For HLS streams, duration updates incrementally as segments are fetched
|
||||
if (!hasLoadEventFired) {
|
||||
val width = MPVLib.getPropertyInt("width") ?: 0
|
||||
val height = MPVLib.getPropertyInt("height") ?: 0
|
||||
// Wait until we have valid dimensions before firing onLoad
|
||||
if (width > 0 && height > 0 && value > 0) {
|
||||
hasLoadEventFired = true
|
||||
Log.d(TAG, "Firing onLoad event: duration=$value, width=$width, height=$height")
|
||||
onLoadCallback?.invoke(value, width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) {
|
||||
Log.d(TAG, "Property $property = $value (Boolean)")
|
||||
when (property) {
|
||||
"eof-reached" -> {
|
||||
if (value) {
|
||||
onEndCallback?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: String) {
|
||||
Log.d(TAG, "Property $property = $value (String)")
|
||||
}
|
||||
|
||||
override fun event(eventId: Int) {
|
||||
Log.d(TAG, "Event: $eventId")
|
||||
// MPV event constants (from MPVLib source)
|
||||
val MPV_EVENT_FILE_LOADED = 8
|
||||
val MPV_EVENT_END_FILE = 7
|
||||
|
||||
when (eventId) {
|
||||
MPV_EVENT_FILE_LOADED -> {
|
||||
// File is loaded, start playback if not paused
|
||||
if (!isPaused) {
|
||||
MPVLib.setPropertyBoolean("pause", false)
|
||||
}
|
||||
}
|
||||
MPV_EVENT_END_FILE -> {
|
||||
Log.d(TAG, "MPV_EVENT_END_FILE")
|
||||
|
||||
// Heuristic: If duration is effectively 0 at end of file, it's a load error
|
||||
val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0
|
||||
val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0
|
||||
val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false
|
||||
|
||||
Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached")
|
||||
|
||||
if (duration < 1.0 && !eofReached) {
|
||||
val customError = "Unable to play media. Source may be unreachable."
|
||||
Log.e(TAG, "Playback error detected (heuristic): $customError")
|
||||
onErrorCallback?.invoke(customError)
|
||||
} else {
|
||||
onEndCallback?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package com.nuvio.app.mpv
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class MpvPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return listOf(MpvPlayerViewManager(reactContext))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
package com.nuvio.app.mpv
|
||||
|
||||
import android.graphics.Color
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.common.MapBuilder
|
||||
import com.facebook.react.uimanager.SimpleViewManager
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.uimanager.annotations.ReactProp
|
||||
import com.facebook.react.uimanager.events.RCTEventEmitter
|
||||
|
||||
class MpvPlayerViewManager(
|
||||
private val reactContext: ReactApplicationContext
|
||||
) : SimpleViewManager<MPVView>() {
|
||||
|
||||
companion object {
|
||||
const val REACT_CLASS = "MpvPlayer"
|
||||
|
||||
// Commands
|
||||
const val COMMAND_SEEK = 1
|
||||
const val COMMAND_SET_AUDIO_TRACK = 2
|
||||
const val COMMAND_SET_SUBTITLE_TRACK = 3
|
||||
}
|
||||
|
||||
override fun getName(): String = REACT_CLASS
|
||||
|
||||
override fun createViewInstance(context: ThemedReactContext): MPVView {
|
||||
val view = MPVView(context)
|
||||
// Note: Do NOT set background color - it will block the SurfaceView content
|
||||
|
||||
// Set up event callbacks
|
||||
view.onLoadCallback = { duration, width, height ->
|
||||
val event = Arguments.createMap().apply {
|
||||
putDouble("duration", duration)
|
||||
putInt("width", width)
|
||||
putInt("height", height)
|
||||
}
|
||||
sendEvent(context, view.id, "onLoad", event)
|
||||
}
|
||||
|
||||
view.onProgressCallback = { position, duration ->
|
||||
val event = Arguments.createMap().apply {
|
||||
putDouble("currentTime", position)
|
||||
putDouble("duration", duration)
|
||||
}
|
||||
sendEvent(context, view.id, "onProgress", event)
|
||||
}
|
||||
|
||||
view.onEndCallback = {
|
||||
sendEvent(context, view.id, "onEnd", Arguments.createMap())
|
||||
}
|
||||
|
||||
view.onErrorCallback = { message ->
|
||||
val event = Arguments.createMap().apply {
|
||||
putString("error", message)
|
||||
}
|
||||
sendEvent(context, view.id, "onError", event)
|
||||
}
|
||||
|
||||
view.onTracksChangedCallback = { audioTracks, subtitleTracks ->
|
||||
val event = Arguments.createMap().apply {
|
||||
val audioArray = Arguments.createArray()
|
||||
audioTracks.forEach { track ->
|
||||
val trackMap = Arguments.createMap().apply {
|
||||
putInt("id", track["id"] as Int)
|
||||
putString("name", track["name"] as String)
|
||||
putString("language", track["language"] as String)
|
||||
putString("codec", track["codec"] as String)
|
||||
}
|
||||
audioArray.pushMap(trackMap)
|
||||
}
|
||||
putArray("audioTracks", audioArray)
|
||||
|
||||
val subtitleArray = Arguments.createArray()
|
||||
subtitleTracks.forEach { track ->
|
||||
val trackMap = Arguments.createMap().apply {
|
||||
putInt("id", track["id"] as Int)
|
||||
putString("name", track["name"] as String)
|
||||
putString("language", track["language"] as String)
|
||||
putString("codec", track["codec"] as String)
|
||||
}
|
||||
subtitleArray.pushMap(trackMap)
|
||||
}
|
||||
putArray("subtitleTracks", subtitleArray)
|
||||
}
|
||||
sendEvent(context, view.id, "onTracksChanged", event)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
private fun sendEvent(context: ThemedReactContext, viewId: Int, eventName: String, params: com.facebook.react.bridge.WritableMap) {
|
||||
context.getJSModule(RCTEventEmitter::class.java)
|
||||
.receiveEvent(viewId, eventName, params)
|
||||
}
|
||||
|
||||
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any> {
|
||||
return MapBuilder.builder<String, Any>()
|
||||
.put("onLoad", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onLoad")))
|
||||
.put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress")))
|
||||
.put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd")))
|
||||
.put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError")))
|
||||
.put("onTracksChanged", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onTracksChanged")))
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getCommandsMap(): Map<String, Int> {
|
||||
return MapBuilder.of(
|
||||
"seek", COMMAND_SEEK,
|
||||
"setAudioTrack", COMMAND_SET_AUDIO_TRACK,
|
||||
"setSubtitleTrack", COMMAND_SET_SUBTITLE_TRACK
|
||||
)
|
||||
}
|
||||
|
||||
override fun receiveCommand(view: MPVView, commandId: String?, args: ReadableArray?) {
|
||||
android.util.Log.d("MpvPlayerViewManager", "receiveCommand: $commandId, args: $args")
|
||||
when (commandId) {
|
||||
"seek" -> {
|
||||
val position = args?.getDouble(0)
|
||||
android.util.Log.d("MpvPlayerViewManager", "Seek command received: position=$position")
|
||||
position?.let { view.seekTo(it) }
|
||||
}
|
||||
"setAudioTrack" -> {
|
||||
args?.getInt(0)?.let { view.setAudioTrack(it) }
|
||||
}
|
||||
"setSubtitleTrack" -> {
|
||||
args?.getInt(0)?.let { view.setSubtitleTrack(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// React Props
|
||||
|
||||
@ReactProp(name = "source")
|
||||
fun setSource(view: MPVView, source: String?) {
|
||||
source?.let { view.setDataSource(it) }
|
||||
}
|
||||
|
||||
@ReactProp(name = "paused")
|
||||
fun setPaused(view: MPVView, paused: Boolean) {
|
||||
view.setPaused(paused)
|
||||
}
|
||||
|
||||
@ReactProp(name = "volume", defaultFloat = 1.0f)
|
||||
fun setVolume(view: MPVView, volume: Float) {
|
||||
view.setVolume(volume.toDouble())
|
||||
}
|
||||
|
||||
@ReactProp(name = "rate", defaultFloat = 1.0f)
|
||||
fun setRate(view: MPVView, rate: Float) {
|
||||
view.setSpeed(rate.toDouble())
|
||||
}
|
||||
|
||||
// Handle backgroundColor prop to prevent crash from React Native style system
|
||||
@ReactProp(name = "backgroundColor", customType = "Color")
|
||||
fun setBackgroundColor(view: MPVView, color: Int?) {
|
||||
// Intentionally ignoring - background color would block the TextureView content
|
||||
// Leave the view transparent
|
||||
}
|
||||
|
||||
@ReactProp(name = "resizeMode")
|
||||
fun setResizeMode(view: MPVView, resizeMode: String?) {
|
||||
view.setResizeMode(resizeMode ?: "contain")
|
||||
}
|
||||
|
||||
@ReactProp(name = "headers")
|
||||
fun setHeaders(view: MPVView, headers: com.facebook.react.bridge.ReadableMap?) {
|
||||
if (headers != null) {
|
||||
val headerMap = mutableMapOf<String, String>()
|
||||
val iterator = headers.keySetIterator()
|
||||
while (iterator.hasNextKey()) {
|
||||
val key = iterator.nextKey()
|
||||
headers.getString(key)?.let { value ->
|
||||
headerMap[key] = value
|
||||
}
|
||||
}
|
||||
view.setHeaders(headerMap)
|
||||
} else {
|
||||
view.setHeaders(null)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactProp(name = "decoderMode")
|
||||
fun setDecoderMode(view: MPVView, decoderMode: String?) {
|
||||
view.decoderMode = decoderMode ?: "auto"
|
||||
}
|
||||
|
||||
@ReactProp(name = "gpuMode")
|
||||
fun setGpuMode(view: MPVView, gpuMode: String?) {
|
||||
view.gpuMode = gpuMode ?: "gpu"
|
||||
}
|
||||
|
||||
// Subtitle Styling Props
|
||||
|
||||
@ReactProp(name = "subtitleSize", defaultInt = 48)
|
||||
fun setSubtitleSize(view: MPVView, size: Int) {
|
||||
view.setSubtitleSize(size)
|
||||
}
|
||||
|
||||
@ReactProp(name = "subtitleColor")
|
||||
fun setSubtitleColor(view: MPVView, color: String?) {
|
||||
view.setSubtitleColor(color ?: "#FFFFFF")
|
||||
}
|
||||
|
||||
@ReactProp(name = "subtitleBackgroundOpacity", defaultFloat = 0.0f)
|
||||
fun setSubtitleBackgroundOpacity(view: MPVView, opacity: Float) {
|
||||
// Black background with user-specified opacity
|
||||
view.setSubtitleBackgroundColor("#000000", opacity)
|
||||
}
|
||||
|
||||
@ReactProp(name = "subtitleBorderSize", defaultInt = 3)
|
||||
fun setSubtitleBorderSize(view: MPVView, size: Int) {
|
||||
view.setSubtitleBorderSize(size)
|
||||
}
|
||||
|
||||
@ReactProp(name = "subtitleBorderColor")
|
||||
fun setSubtitleBorderColor(view: MPVView, color: String?) {
|
||||
view.setSubtitleBorderColor(color ?: "#000000")
|
||||
}
|
||||
|
||||
@ReactProp(name = "subtitleShadowEnabled", defaultBoolean = true)
|
||||
fun setSubtitleShadowEnabled(view: MPVView, enabled: Boolean) {
|
||||
view.setSubtitleShadow(enabled, if (enabled) 2 else 0)
|
||||
}
|
||||
|
||||
@ReactProp(name = "subtitlePosition", defaultInt = 100)
|
||||
fun setSubtitlePosition(view: MPVView, pos: Int) {
|
||||
view.setSubtitlePosition(pos)
|
||||
}
|
||||
|
||||
@ReactProp(name = "subtitleDelay", defaultFloat = 0.0f)
|
||||
fun setSubtitleDelay(view: MPVView, delay: Float) {
|
||||
view.setSubtitleDelay(delay.toDouble())
|
||||
}
|
||||
|
||||
@ReactProp(name = "subtitleAlignment")
|
||||
fun setSubtitleAlignment(view: MPVView, align: String?) {
|
||||
view.setSubtitleAlignment(align ?: "center")
|
||||
}
|
||||
}
|
||||
|
|
@ -3,5 +3,5 @@
|
|||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
|
||||
<string name="expo_runtime_version">1.2.10</string>
|
||||
<string name="expo_runtime_version">1.3.5</string>
|
||||
</resources>
|
||||
|
|
@ -7,6 +7,7 @@ buildscript {
|
|||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
castFrameworkVersion = "22.1.0"
|
||||
ndkVersion = "29.0.14206865" // Required for libmpv AAR built with NDK r29
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
defaults.url=https://sentry.io/
|
||||
defaults.org=tapframe
|
||||
defaults.project=react-native
|
||||
auth.token=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
|
||||
auth.token=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
|
||||
|
|
|
|||
22
app.json
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Nuvio",
|
||||
"slug": "nuvio",
|
||||
"version": "1.2.10",
|
||||
"version": "1.3.5",
|
||||
"orientation": "default",
|
||||
"backgroundColor": "#020404",
|
||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||
|
|
@ -16,9 +16,8 @@
|
|||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"requireFullScreen": true,
|
||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||
"buildNumber": "25",
|
||||
"buildNumber": "33",
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
|
|
@ -49,10 +48,10 @@
|
|||
"permissions": [
|
||||
"INTERNET",
|
||||
"WAKE_LOCK",
|
||||
"WRITE_SETTINGS"
|
||||
"android.permission.WRITE_SETTINGS"
|
||||
],
|
||||
"package": "com.nuvio.app",
|
||||
"versionCode": 25,
|
||||
"versionCode": 33,
|
||||
"architectures": [
|
||||
"arm64-v8a",
|
||||
"armeabi-v7a",
|
||||
|
|
@ -83,13 +82,6 @@
|
|||
"username": "nayifleo"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-libvlc-player",
|
||||
{
|
||||
"localNetworkPermission": "Allow $(PRODUCT_NAME) to access your local network",
|
||||
"supportsBackgroundPlayback": true
|
||||
}
|
||||
],
|
||||
"react-native-bottom-tabs",
|
||||
[
|
||||
"react-native-google-cast",
|
||||
|
|
@ -103,8 +95,8 @@
|
|||
"enabled": true,
|
||||
"checkAutomatically": "ON_ERROR_RECOVERY",
|
||||
"fallbackToCacheTimeout": 30000,
|
||||
"url": "https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest"
|
||||
"url": "https://ota.nuvioapp.space/api/manifest"
|
||||
},
|
||||
"runtimeVersion": "1.2.10"
|
||||
"runtimeVersion": "1.3.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 166 KiB |
BIN
assets/nuviotext.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
|
@ -1,6 +1,40 @@
|
|||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { Platform, Animated, TouchableWithoutFeedback, View } from 'react-native';
|
||||
import Video, { VideoRef, SelectedTrack, BufferingStrategyType, ResizeMode } from 'react-native-video';
|
||||
import RNImmersiveMode from 'react-native-immersive-mode';
|
||||
|
||||
// Subtitle style configuration interface - matches ExoPlayer's SubtitleStyle
|
||||
export interface SubtitleStyleConfig {
|
||||
// Font size in SP (scale-independent pixels) for subtitle text
|
||||
// Default: -1 (uses system default)
|
||||
fontSize?: number;
|
||||
|
||||
// Padding values in pixels
|
||||
paddingTop?: number;
|
||||
paddingBottom?: number;
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
|
||||
// Opacity of subtitles (0.0 to 1.0)
|
||||
// 0 = hidden, 1 = fully visible
|
||||
opacity?: number;
|
||||
|
||||
// Whether subtitles should follow video position when video is resized
|
||||
// true = subtitles stay within video bounds
|
||||
// false = subtitles can extend beyond video bounds
|
||||
subtitlesFollowVideo?: boolean;
|
||||
}
|
||||
|
||||
// Default subtitle style configuration
|
||||
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
|
||||
fontSize: 18,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 60,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
opacity: 1,
|
||||
subtitlesFollowVideo: true,
|
||||
};
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string;
|
||||
|
|
@ -11,6 +45,8 @@ interface VideoPlayerProps {
|
|||
selectedAudioTrack?: SelectedTrack;
|
||||
selectedTextTrack?: SelectedTrack;
|
||||
resizeMode?: ResizeMode;
|
||||
// Subtitle customization - pass custom subtitle styling
|
||||
subtitleStyle?: SubtitleStyleConfig;
|
||||
onProgress?: (data: { currentTime: number; playableDuration: number }) => void;
|
||||
onLoad?: (data: { duration: number }) => void;
|
||||
onError?: (error: any) => void;
|
||||
|
|
@ -28,6 +64,7 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
selectedAudioTrack,
|
||||
selectedTextTrack,
|
||||
resizeMode = 'contain' as ResizeMode,
|
||||
subtitleStyle: customSubtitleStyle,
|
||||
onProgress,
|
||||
onLoad,
|
||||
onError,
|
||||
|
|
@ -40,6 +77,34 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const [lastSeekTime, setLastSeekTime] = useState<number>(0);
|
||||
|
||||
// Merge custom subtitle style with defaults
|
||||
const subtitleStyle = useMemo(() => ({
|
||||
...DEFAULT_SUBTITLE_STYLE,
|
||||
...customSubtitleStyle,
|
||||
}), [customSubtitleStyle]);
|
||||
|
||||
// Enable immersive mode when video player mounts, disable when it unmounts
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
RNImmersiveMode.setBarMode('Bottom');
|
||||
RNImmersiveMode.fullLayout(true);
|
||||
} catch (error) {
|
||||
console.log('Immersive mode error:', error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Restore navigation bar when video player unmounts
|
||||
try {
|
||||
RNImmersiveMode.setBarMode('Normal');
|
||||
RNImmersiveMode.fullLayout(false);
|
||||
} catch (error) {
|
||||
console.log('Immersive mode cleanup error:', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Only render on Android
|
||||
if (Platform.OS !== 'android') {
|
||||
return null;
|
||||
|
|
@ -109,13 +174,21 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
rate={1.0}
|
||||
repeat={false}
|
||||
reportBandwidth={true}
|
||||
textTracks={[]}
|
||||
useTextureView={false}
|
||||
useTextureView={true}
|
||||
disableFocus={false}
|
||||
minLoadRetryCount={3}
|
||||
automaticallyWaitsToMinimizeStalling={true}
|
||||
hideShutterView={false}
|
||||
shutterColor="#000000"
|
||||
subtitleStyle={{
|
||||
fontSize: subtitleStyle.fontSize,
|
||||
paddingTop: subtitleStyle.paddingTop,
|
||||
paddingBottom: subtitleStyle.paddingBottom,
|
||||
paddingLeft: subtitleStyle.paddingLeft,
|
||||
paddingRight: subtitleStyle.paddingRight,
|
||||
opacity: subtitleStyle.opacity,
|
||||
subtitlesFollowVideo: subtitleStyle.subtitlesFollowVideo,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
244
docs/DOCUMENTATION.md
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# Nuvio Streaming Project Documentation
|
||||
|
||||
This document provides a comprehensive, step-by-step guide on how to build, run, and develop the Nuvio Streaming application for both Android and iOS platforms. It covers prerequisites, initial setup, prebuilding, and native execution.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Project Setup](#project-setup)
|
||||
3. [Understanding Prebuild](#understanding-prebuild)
|
||||
4. [Running on Android](#running-on-android)
|
||||
5. [Running on iOS](#running-on-ios)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
7. [Useful Commands](#useful-commands)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure your development environment is correctly set up.
|
||||
|
||||
### General Tools
|
||||
- **Node.js**: Install the Long Term Support (LTS) version (v18 or newer recommended). [Download Node.js](https://nodejs.org/)
|
||||
- **Git**: For version control. [Download Git](https://git-scm.com/)
|
||||
- **Watchman** (macOS users): Highly recommended for better file watching performance.
|
||||
```bash
|
||||
brew install watchman
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
**All environment variables are optional for development.**
|
||||
The app is designed to run "out of the box" without a `.env` file. Features requiring API keys (like Trakt syncing) will simply be disabled or use default fallbacks.
|
||||
|
||||
3. **Setup (Optional)**:
|
||||
If you wish to enable specific features, create a `.env` file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**Recommended Variables:**
|
||||
* `EXPO_PUBLIC_TRAKT_CLIENT_ID` (etc): Enables Trakt integration.
|
||||
|
||||
### For Android Development
|
||||
|
||||
1. **Java Development Kit (JDK)**: Install JDK 11 or newer (JDK 17 is often recommended for modern React Native).
|
||||
- [OpenJDK](https://openjdk.org/) or [Azul Zulu](https://www.azul.com/downloads/).
|
||||
- Ensure your `JAVA_HOME` environment variable is set.
|
||||
2. **Android Studio**:
|
||||
- Install [Android Studio](https://developer.android.com/studio).
|
||||
- During installation, ensure the **Android SDK**, **Android SDK Platform-Tools**, and **Android Virtual Device** are selected.
|
||||
- Set up your `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) environment variable pointing to your SDK location.
|
||||
|
||||
### For iOS Development (macOS only)
|
||||
|
||||
1. **Xcode**: Install the latest version of Xcode from the Mac App Store.
|
||||
2. **Xcode Command Line Tools**:
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
3. **CocoaPods**: Required for managing iOS dependencies.
|
||||
```bash
|
||||
sudo gem install cocoapods
|
||||
```
|
||||
*Note: On Apple Silicon (M1/M2/M3) Macs, you might need to use Homebrew to install Ruby or manage Cocoapods differently if you encounter issues.*
|
||||
|
||||
---
|
||||
|
||||
## Project Setup
|
||||
|
||||
1. **Clone the Repository**
|
||||
```bash
|
||||
git clone https://github.com/tapframe/NuvioStreaming.git
|
||||
cd NuvioStreaming
|
||||
```
|
||||
|
||||
2. **Install Dependencies**
|
||||
Install the project dependencies using `npm`.
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
*Note: If you encounter peer dependency conflicts, you can try `npm install --legacy-peer-deps`, but typically `npm install` should work if the `package.json` is well-maintained.*
|
||||
|
||||
---
|
||||
|
||||
## Understanding Prebuild
|
||||
|
||||
This project is built with **Expo**. Since it may use native modules that are not included in the standard Expo Go client (Custom Dev Client), we often need to "prebuild" the project to generate the native `android` and `ios` directories.
|
||||
|
||||
**What `npx expo prebuild` does:**
|
||||
- It generates the native `android` and `ios` project directories based on your configuration in `app.json` / `app.config.js`.
|
||||
- It applies any Config Plugins specified.
|
||||
- It prepares the project to be built locally using Android Studio or Xcode tools (Gradle/Podfile).
|
||||
|
||||
You typically run this command before compiling the native app if you have made changes to the native configuration (e.g., icons, splash screens, permissions in `app.json`).
|
||||
|
||||
```bash
|
||||
npx expo prebuild
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> **Important:** Running `npx expo prebuild --clean` will delete the `android` and `ios` directories.
|
||||
> If you have manually modified files in these directories (that are not covered by Expo config plugins), they will be lost.
|
||||
> **Recommendation:** Immediately after running prebuild, use `git status` to see what changed. If important files were deleted or reset, use `git checkout <path/to/file>` to revert them to your custom version.
|
||||
> Example:
|
||||
> ```bash
|
||||
> git checkout android/build.gradle
|
||||
> ```
|
||||
|
||||
To prebuild for a specific platform:
|
||||
```bash
|
||||
npx expo prebuild --platform android
|
||||
npx expo prebuild --platform ios
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running on Android
|
||||
|
||||
Follow these steps to build and run the app on an Android Emulator or connected physical device.
|
||||
|
||||
**Step 1: Start an Emulator or Connect a Device**
|
||||
- **Emulator**: Open Android Studio, go to "Device Manager", and start a virtual device.
|
||||
- **Physical Device**: Connect it via USB, enable **Developer Options** and **USB Debugging**. Verify connection with `adb devices`.
|
||||
|
||||
**Step 2: Generate Native Directories (Prebuild)**
|
||||
If you haven't done so (or if you cleaned the project):
|
||||
```bash
|
||||
npx expo prebuild --platform android
|
||||
```
|
||||
|
||||
**Step 3: Compile and Run**
|
||||
Run the following command to build the Android app and launch it on your device/emulator:
|
||||
```bash
|
||||
npx expo run:android
|
||||
```
|
||||
*This command will start the Metro bundler in a new window/tab and begin the Gradle build process.*
|
||||
|
||||
**Alternative: Open in Android Studio**
|
||||
If you prefer identifying build errors in the IDE:
|
||||
1. Run `npx expo prebuild --platform android`.
|
||||
2. Open Android Studio.
|
||||
3. Select "Open an existing Android Studio Project" and choose the `android` folder inside `NuvioStreaming`.
|
||||
4. Wait for Gradle sync to complete, then press the **Run** (green play) button.
|
||||
|
||||
---
|
||||
|
||||
## Running on iOS
|
||||
|
||||
**Note:** iOS development requires a Mac with Xcode.
|
||||
|
||||
**Step 1: Generate Native Directories (Prebuild)**
|
||||
```bash
|
||||
npx expo prebuild --platform ios
|
||||
```
|
||||
*This will generate the `ios` folder and automatically run `pod install` inside it.*
|
||||
|
||||
**Step 2: Compile and Run**
|
||||
Run the following command to build the iOS app and launch it on the iOS Simulator:
|
||||
```bash
|
||||
npx expo run:ios
|
||||
```
|
||||
*To run on a specific simulator device:*
|
||||
```bash
|
||||
npx expo run:ios --device "iPhone 15 Pro"
|
||||
```
|
||||
|
||||
**Step 3: Running on a Physical iOS Device**
|
||||
1. You need an Apple Developer Account (a free account works for local testing, but requires re-signing every 7 days).
|
||||
2. Open the project in Xcode:
|
||||
```bash
|
||||
xcode-open ios/nuvio.xcworkspace
|
||||
```
|
||||
*(Or simple open `ios/nuvio.xcworkspace` in Xcode manually)*.
|
||||
3. In Xcode, select your project target, go to the **Signing & Capabilities** tab.
|
||||
4. Select your **Team**.
|
||||
5. Connect your device via USB.
|
||||
6. Select your device from the build target dropdown (top bar).
|
||||
7. Press **Cmd + R** to build and run.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "CocoaPods not found" or Pod install errors
|
||||
If `npx expo run:ios` fails during pod installation:
|
||||
```bash
|
||||
cd ios
|
||||
pod install
|
||||
cd ..
|
||||
```
|
||||
If you are on an Apple Silicon Mac and have issues:
|
||||
```bash
|
||||
cd ios
|
||||
arch -x86_64 pod install
|
||||
cd ..
|
||||
```
|
||||
|
||||
### Build Failures after changing dependencies
|
||||
If you install a new library that includes native code, you must rebuild the native app.
|
||||
1. Stop the Metro server.
|
||||
2. Run the platform-specific run command again:
|
||||
```bash
|
||||
npx expo run:android
|
||||
# or
|
||||
npx expo run:ios
|
||||
```
|
||||
|
||||
### General Clean Up
|
||||
If things are acting weird (stale cache, weird build errors), try cleaning the project:
|
||||
|
||||
**1. Clear Metro Cache:**
|
||||
```bash
|
||||
npx expo start -c
|
||||
```
|
||||
|
||||
**2. Clean Native Directories (Drastic Measure):**
|
||||
WARNING: This deletes the `android` and `ios` folders. Only do this if you can regenerate them with `prebuild`.
|
||||
```bash
|
||||
rm -rf android ios
|
||||
npx expo prebuild
|
||||
```
|
||||
*Note: If you have manual changes in `android` or `ios` folders that usually shouldn't be there in a managed workflow, they will be lost. Ensure all native config is configured via Config Plugins in `app.json`.*
|
||||
|
||||
### "SDK location not found" (Android)
|
||||
Create a `local.properties` file in the `android` directory with the path to your SDK:
|
||||
```properties
|
||||
# android/local.properties
|
||||
sdk.dir=/Users/YOUR_USERNAME/Library/Android/sdk
|
||||
```
|
||||
(Replace `YOUR_USERNAME` with your actual username).
|
||||
|
||||
---
|
||||
|
||||
## Useful Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm start` or `npx expo start` | Starts the Metro Bundler (development server). |
|
||||
| `npx expo start --clear` | Starts the bundler with a clear cache. |
|
||||
| `npx expo prebuild` | Generates native `android` and `ios` code. |
|
||||
| `npx expo prebuild --clean` | Deletes existing native folders and regenerates them. |
|
||||
| `npx expo run:android` | Builds and opens the app on Android. |
|
||||
| `npx expo run:ios` | Builds and opens the app on iOS. |
|
||||
| `npx expo install <package>` | Installs a library compatible with your Expo SDK version. |
|
||||
2299
index.html
|
|
@ -1,55 +0,0 @@
|
|||
//
|
||||
// KSPlayerManager.m
|
||||
// Nuvio
|
||||
//
|
||||
// Created by KSPlayer integration
|
||||
//
|
||||
|
||||
#import <React/RCTViewManager.h>
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTEventEmitter.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(KSPlayerViewManager, RCTViewManager)
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(source, NSDictionary)
|
||||
RCT_EXPORT_VIEW_PROPERTY(paused, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(volume, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(rate, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(audioTrack, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(textTrack, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(allowsExternalPlayback, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(usesExternalPlaybackWhileExternalScreenIsActive, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(subtitleBottomOffset, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(subtitleFontSize, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString)
|
||||
|
||||
// Event properties
|
||||
RCT_EXPORT_VIEW_PROPERTY(onLoad, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onProgress, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onBuffering, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onEnd, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onError, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onBufferingProgress, RCTDirectEventBlock)
|
||||
|
||||
RCT_EXTERN_METHOD(seek:(nonnull NSNumber *)node toTime:(nonnull NSNumber *)time)
|
||||
RCT_EXTERN_METHOD(setSource:(nonnull NSNumber *)node source:(nonnull NSDictionary *)source)
|
||||
RCT_EXTERN_METHOD(setPaused:(nonnull NSNumber *)node paused:(BOOL)paused)
|
||||
RCT_EXTERN_METHOD(setVolume:(nonnull NSNumber *)node volume:(nonnull NSNumber *)volume)
|
||||
RCT_EXTERN_METHOD(setPlaybackRate:(nonnull NSNumber *)node rate:(nonnull NSNumber *)rate)
|
||||
RCT_EXTERN_METHOD(setAudioTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId)
|
||||
RCT_EXTERN_METHOD(setTextTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId)
|
||||
RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(setAllowsExternalPlayback:(nonnull NSNumber *)node allows:(BOOL)allows)
|
||||
RCT_EXTERN_METHOD(setUsesExternalPlaybackWhileExternalScreenIsActive:(nonnull NSNumber *)node uses:(BOOL)uses)
|
||||
RCT_EXTERN_METHOD(getAirPlayState:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)node)
|
||||
|
||||
@end
|
||||
|
||||
@interface RCT_EXTERN_MODULE(KSPlayerModule, RCTEventEmitter)
|
||||
|
||||
RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(getAirPlayState:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)nodeTag)
|
||||
|
||||
@end
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
//
|
||||
// KSPlayerModule.swift
|
||||
// Nuvio
|
||||
//
|
||||
// Created by KSPlayer integration
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import KSPlayer
|
||||
import React
|
||||
|
||||
@objc(KSPlayerModule)
|
||||
class KSPlayerModule: RCTEventEmitter {
|
||||
override static func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func supportedEvents() -> [String]! {
|
||||
return [
|
||||
"KSPlayer-onLoad",
|
||||
"KSPlayer-onProgress",
|
||||
"KSPlayer-onBuffering",
|
||||
"KSPlayer-onEnd",
|
||||
"KSPlayer-onError"
|
||||
]
|
||||
}
|
||||
|
||||
@objc func getTracks(_ nodeTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
||||
DispatchQueue.main.async {
|
||||
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
|
||||
viewManager.getTracks(nodeTag, resolve: resolve, reject: reject)
|
||||
} else {
|
||||
reject("NO_VIEW_MANAGER", "KSPlayerViewManager not found", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getAirPlayState(_ nodeTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
||||
DispatchQueue.main.async {
|
||||
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
|
||||
viewManager.getAirPlayState(nodeTag, resolve: resolve, reject: reject)
|
||||
} else {
|
||||
reject("NO_VIEW_MANAGER", "KSPlayerViewManager not found", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func showAirPlayPicker(_ nodeTag: NSNumber) {
|
||||
print("[KSPlayerModule] showAirPlayPicker called for nodeTag: \(nodeTag)")
|
||||
DispatchQueue.main.async {
|
||||
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
|
||||
print("[KSPlayerModule] Found KSPlayerViewManager, calling showAirPlayPicker")
|
||||
viewManager.showAirPlayPicker(nodeTag)
|
||||
} else {
|
||||
print("[KSPlayerModule] Could not find KSPlayerViewManager")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
//
|
||||
// KSPlayerViewManager.swift
|
||||
// Nuvio
|
||||
//
|
||||
// Created by KSPlayer integration
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import KSPlayer
|
||||
import React
|
||||
|
||||
@objc(KSPlayerViewManager)
|
||||
class KSPlayerViewManager: RCTViewManager {
|
||||
|
||||
// Not needed for RCTViewManager-based views; events are exported via Objective-C externs in KSPlayerManager.m
|
||||
override func view() -> UIView! {
|
||||
let view = KSPlayerView()
|
||||
view.viewManager = self
|
||||
return view
|
||||
}
|
||||
|
||||
override static func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func constantsToExport() -> [AnyHashable : Any]! {
|
||||
return [
|
||||
"EventTypes": [
|
||||
"onLoad": "onLoad",
|
||||
"onProgress": "onProgress",
|
||||
"onBuffering": "onBuffering",
|
||||
"onEnd": "onEnd",
|
||||
"onError": "onError",
|
||||
"onBufferingProgress": "onBufferingProgress"
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
// No-op: events are sent via direct event blocks on the view
|
||||
|
||||
@objc func seek(_ node: NSNumber, toTime time: NSNumber) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.seek(to: TimeInterval(truncating: time))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSource(_ node: NSNumber, source: NSDictionary) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setSource(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setPaused(_ node: NSNumber, paused: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setPaused(paused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setVolume(_ node: NSNumber, volume: NSNumber) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setVolume(Float(truncating: volume))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setPlaybackRate(_ node: NSNumber, rate: NSNumber) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setPlaybackRate(Float(truncating: rate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setAudioTrack(_ node: NSNumber, trackId: NSNumber) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setAudioTrack(Int(truncating: trackId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setTextTrack(_ node: NSNumber, trackId: NSNumber) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setTextTrack(Int(truncating: trackId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getTracks(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
let tracks = view.getAvailableTracks()
|
||||
resolve(tracks)
|
||||
} else {
|
||||
reject("NO_VIEW", "KSPlayerView not found", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AirPlay methods
|
||||
@objc func setAllowsExternalPlayback(_ node: NSNumber, allows: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setAllowsExternalPlayback(allows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setUsesExternalPlaybackWhileExternalScreenIsActive(_ node: NSNumber, uses: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setUsesExternalPlaybackWhileExternalScreenIsActive(uses)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getAirPlayState(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
let airPlayState = view.getAirPlayState()
|
||||
resolve(airPlayState)
|
||||
} else {
|
||||
reject("NO_VIEW", "KSPlayerView not found", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func showAirPlayPicker(_ node: NSNumber) {
|
||||
print("[KSPlayerViewManager] showAirPlayPicker called for node: \(node)")
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
print("[KSPlayerViewManager] Found KSPlayerView, calling showAirPlayPicker")
|
||||
view.showAirPlayPicker()
|
||||
} else {
|
||||
print("[KSPlayerViewManager] Could not find KSPlayerView for node: \(node)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,12 +11,12 @@
|
|||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
2AA769395C1242F225F875AF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E007C0BAC8C453623E81663 /* ExpoModulesProvider.swift */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
564F8559E25775FFA08707DA /* libPods-Nuvio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 18260C6F06D8D6DAF4C1D17E /* libPods-Nuvio.a */; };
|
||||
9FBA88F42E86ECD700892850 /* KSPlayerViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */; };
|
||||
9FBA88F52E86ECD700892850 /* KSPlayerModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */; };
|
||||
9FBA88F62E86ECD700892850 /* KSPlayerManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F02E86ECD700892850 /* KSPlayerManager.m */; };
|
||||
9FBA88F72E86ECD700892850 /* KSPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F22E86ECD700892850 /* KSPlayerView.swift */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
D66ACCC72CB69F1FF14A2585 /* libPods-Nuvio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */; };
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
|
|
@ -24,16 +24,16 @@
|
|||
13B07F961A680F5B00A75B9A /* Nuvio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nuvio.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Nuvio/Images.xcassets; sourceTree = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Nuvio/Info.plist; sourceTree = "<group>"; };
|
||||
18260C6F06D8D6DAF4C1D17E /* libPods-Nuvio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Nuvio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2118C3C63E4B7D66EAC534DE /* Pods-Nuvio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.debug.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Nuvio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
49055D6E250FAFA21141FE49 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Nuvio/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.release.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.release.xcconfig"; sourceTree = "<group>"; };
|
||||
6E007C0BAC8C453623E81663 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Nuvio/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
73BB213C2E9EEAC700EC03F8 /* NuvioRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = NuvioRelease.entitlements; path = Nuvio/NuvioRelease.entitlements; sourceTree = "<group>"; };
|
||||
7F2FA62198C389C99926AA47 /* Pods-Nuvio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.release.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.release.xcconfig"; sourceTree = "<group>"; };
|
||||
9FBA88F02E86ECD700892850 /* KSPlayerManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KSPlayerManager.m; sourceTree = "<group>"; };
|
||||
9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSPlayerModule.swift; sourceTree = "<group>"; };
|
||||
9FBA88F22E86ECD700892850 /* KSPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSPlayerView.swift; sourceTree = "<group>"; };
|
||||
9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSPlayerViewManager.swift; sourceTree = "<group>"; };
|
||||
904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.debug.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9FBA88F02E86ECD700892850 /* KSPlayerManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ../KSPlayer/RNBridge/KSPlayerManager.m; sourceTree = "<group>"; };
|
||||
9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerModule.swift; sourceTree = "<group>"; };
|
||||
9FBA88F22E86ECD700892850 /* KSPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerView.swift; sourceTree = "<group>"; };
|
||||
9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerViewManager.swift; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Nuvio/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
564F8559E25775FFA08707DA /* libPods-Nuvio.a in Frameworks */,
|
||||
D66ACCC72CB69F1FF14A2585 /* libPods-Nuvio.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
18260C6F06D8D6DAF4C1D17E /* libPods-Nuvio.a */,
|
||||
436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -131,8 +131,8 @@
|
|||
D90A3959C97EE9926C513293 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2118C3C63E4B7D66EAC534DE /* Pods-Nuvio.debug.xcconfig */,
|
||||
7F2FA62198C389C99926AA47 /* Pods-Nuvio.release.xcconfig */,
|
||||
904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */,
|
||||
5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -152,15 +152,15 @@
|
|||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Nuvio" */;
|
||||
buildPhases = (
|
||||
4A10611824FCBAA4C1793637 /* [CP] Check Pods Manifest.lock */,
|
||||
3B2D9C1D63379C2F30AC0F2B /* [CP] Check Pods Manifest.lock */,
|
||||
99A79B70155E84EE1FB7F466 /* [Expo] Configure project */,
|
||||
13B07F871A680F5B00A75B9A /* Sources */,
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
9B977D89FE30470F8C59964C /* Upload Debug Symbols to Sentry */,
|
||||
EE80421364369BBCA82253B9 /* [CP] Embed Pods Frameworks */,
|
||||
778B42B39FEE5454E4D24252 /* [CP] Copy Pods Resources */,
|
||||
9F740EE07B5F97C85979C145 /* [CP] Embed Pods Frameworks */,
|
||||
550CD54859274FE505BA4957 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
|
|
@ -234,7 +234,7 @@
|
|||
shellPath = /bin/sh;
|
||||
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n/bin/sh `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'\"` `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
|
||||
};
|
||||
4A10611824FCBAA4C1793637 /* [CP] Check Pods Manifest.lock */ = {
|
||||
3B2D9C1D63379C2F30AC0F2B /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
|
|
@ -256,7 +256,7 @@
|
|||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
778B42B39FEE5454E4D24252 /* [CP] Copy Pods Resources */ = {
|
||||
550CD54859274FE505BA4957 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
|
|
@ -406,21 +406,19 @@
|
|||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
|
||||
};
|
||||
EE80421364369BBCA82253B9 /* [CP] Embed Pods Frameworks */ = {
|
||||
9F740EE07B5F97C85979C145 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Nuvio/Pods-Nuvio-frameworks.sh",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/MobileVLCKit/MobileVLCKit.framework/MobileVLCKit",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MobileVLCKit.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||
|
|
@ -451,13 +449,13 @@
|
|||
/* Begin XCBuildConfiguration section */
|
||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 2118C3C63E4B7D66EAC534DE /* Pods-Nuvio.debug.xcconfig */;
|
||||
baseConfigurationReference = 904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Nuvio/Nuvio.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = NLXTHANK2N;
|
||||
DEVELOPMENT_TEAM = 8QBDZ766S3;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
|
|
@ -478,6 +476,7 @@
|
|||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -488,13 +487,13 @@
|
|||
};
|
||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7F2FA62198C389C99926AA47 /* Pods-Nuvio.release.xcconfig */;
|
||||
baseConfigurationReference = 5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Nuvio/NuvioRelease.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = NLXTHANK2N;
|
||||
DEVELOPMENT_TEAM = 8QBDZ766S3;
|
||||
INFOPLIST_FILE = Nuvio/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
|
@ -508,8 +507,9 @@
|
|||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>25</string>
|
||||
<string>29</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
|
@ -59,6 +59,8 @@
|
|||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Allow $(PRODUCT_NAME) to access your local network</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app does not require microphone access.</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>RCTRootViewBackgroundColor</key>
|
||||
|
|
@ -71,12 +73,19 @@
|
|||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>SplashScreenBackground</string>
|
||||
<key>UIImageName</key>
|
||||
<string>SplashScreenLegacy</string>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
|
||||
<view key="view" userInteractionEnabled="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EXPO-ContainerView" userLabel="ContainerView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLegacy" image="SplashScreenLegacy" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
||||
<rect key="frame" x="0" y="0" width="414" height="736"/>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
<key>EXUpdatesLaunchWaitMs</key>
|
||||
<integer>30000</integer>
|
||||
<key>EXUpdatesRuntimeVersion</key>
|
||||
<string>1.2.10</string>
|
||||
<string>1.2.11</string>
|
||||
<key>EXUpdatesURL</key>
|
||||
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
|
||||
<string>https://ota.nuvioapp.space/api/manifest</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -21,7 +21,7 @@ platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
|
|||
prepare_react_native_project!
|
||||
|
||||
target 'Nuvio' do
|
||||
use_expo_modules!
|
||||
use_expo_modules!(exclude: ['expo-libvlc-player'])
|
||||
|
||||
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
|
||||
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
|
||||
|
|
@ -50,8 +50,9 @@ target 'Nuvio' do
|
|||
)
|
||||
|
||||
# KSPlayer dependencies
|
||||
pod 'KSPlayer', :git => 'https://github.com/kingslay/KSPlayer.git', :branch => 'main'
|
||||
pod 'DisplayCriteria', :git => 'https://github.com/kingslay/KSPlayer.git', :branch => 'main', :modular_headers => true
|
||||
# Use the local checkout so we can patch subtitle rendering (and other behaviors) without forking.
|
||||
pod 'KSPlayer', :path => '../KSPlayer'
|
||||
pod 'DisplayCriteria', :path => '../KSPlayer', :modular_headers => true
|
||||
pod 'FFmpegKit', :git => 'https://github.com/kingslay/FFmpegKit.git', :branch => 'main', :modular_headers => true
|
||||
pod 'Libass', :git => 'https://github.com/kingslay/FFmpegKit.git', :branch => 'main'
|
||||
|
||||
|
|
|
|||
246
ios/Podfile.lock
|
|
@ -1,17 +1,17 @@
|
|||
PODS:
|
||||
- DisplayCriteria (1.1.0)
|
||||
- EASClient (1.0.7):
|
||||
- EASClient (1.0.8):
|
||||
- ExpoModulesCore
|
||||
- EXApplication (7.0.7):
|
||||
- EXApplication (7.0.8):
|
||||
- ExpoModulesCore
|
||||
- EXConstants (18.0.10):
|
||||
- EXConstants (18.0.12):
|
||||
- ExpoModulesCore
|
||||
- EXJSONUtils (0.15.0)
|
||||
- EXManifests (1.0.8):
|
||||
- EXManifests (1.0.10):
|
||||
- ExpoModulesCore
|
||||
- EXNotifications (0.32.12):
|
||||
- EXNotifications (0.32.15):
|
||||
- ExpoModulesCore
|
||||
- Expo (54.0.23):
|
||||
- Expo (54.0.29):
|
||||
- ExpoModulesCore
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
|
|
@ -36,15 +36,15 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-client (6.0.17):
|
||||
- expo-dev-client (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-launcher
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- EXUpdatesInterface
|
||||
- expo-dev-launcher (6.0.17):
|
||||
- expo-dev-launcher (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-launcher/Main (= 6.0.17)
|
||||
- expo-dev-launcher/Main (= 6.0.20)
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
|
|
@ -73,7 +73,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-launcher/Main (6.0.17):
|
||||
- expo-dev-launcher/Main (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-launcher/Unsafe
|
||||
- expo-dev-menu
|
||||
|
|
@ -104,7 +104,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-launcher/Unsafe (6.0.17):
|
||||
- expo-dev-launcher/Unsafe (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
|
|
@ -134,9 +134,9 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-menu (7.0.16):
|
||||
- expo-dev-menu/Main (= 7.0.16)
|
||||
- expo-dev-menu/ReactNativeCompatibles (= 7.0.16)
|
||||
- expo-dev-menu (7.0.18):
|
||||
- expo-dev-menu/Main (= 7.0.18)
|
||||
- expo-dev-menu/ReactNativeCompatibles (= 7.0.18)
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -159,7 +159,7 @@ PODS:
|
|||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-menu-interface (2.0.0)
|
||||
- expo-dev-menu/Main (7.0.16):
|
||||
- expo-dev-menu/Main (7.0.18):
|
||||
- EXManifests
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
|
|
@ -185,7 +185,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-menu/ReactNativeCompatibles (7.0.16):
|
||||
- expo-dev-menu/ReactNativeCompatibles (7.0.18):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -207,38 +207,37 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- ExpoAsset (12.0.9):
|
||||
- ExpoAsset (12.0.11):
|
||||
- ExpoModulesCore
|
||||
- ExpoBlur (15.0.7):
|
||||
- ExpoBlur (15.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoBrightness (14.0.7):
|
||||
- ExpoBrightness (14.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoCrypto (15.0.7):
|
||||
- ExpoClipboard (8.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoDevice (8.0.9):
|
||||
- ExpoCrypto (15.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoDocumentPicker (14.0.7):
|
||||
- ExpoDevice (8.0.10):
|
||||
- ExpoModulesCore
|
||||
- ExpoFileSystem (19.0.17):
|
||||
- ExpoDocumentPicker (14.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoFont (14.0.9):
|
||||
- ExpoFileSystem (19.0.21):
|
||||
- ExpoModulesCore
|
||||
- ExpoGlassEffect (0.1.7):
|
||||
- ExpoFont (14.0.10):
|
||||
- ExpoModulesCore
|
||||
- ExpoHaptics (15.0.7):
|
||||
- ExpoGlassEffect (0.1.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoKeepAwake (15.0.7):
|
||||
- ExpoHaptics (15.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoLibVlcPlayer (2.2.3):
|
||||
- ExpoKeepAwake (15.0.8):
|
||||
- ExpoModulesCore
|
||||
- MobileVLCKit (= 3.6.1b1)
|
||||
- ExpoLinearGradient (15.0.7):
|
||||
- ExpoLinearGradient (15.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoLinking (8.0.8):
|
||||
- ExpoLinking (8.0.10):
|
||||
- ExpoModulesCore
|
||||
- ExpoLocalization (17.0.7):
|
||||
- ExpoLocalization (17.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoModulesCore (3.0.25):
|
||||
- ExpoModulesCore (3.0.29):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -263,7 +262,7 @@ PODS:
|
|||
- Yoga
|
||||
- ExpoRandom (14.0.1):
|
||||
- ExpoModulesCore
|
||||
- ExpoScreenOrientation (9.0.7):
|
||||
- ExpoScreenOrientation (9.0.8):
|
||||
- ExpoModulesCore
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
|
|
@ -286,14 +285,14 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- ExpoSharing (14.0.7):
|
||||
- ExpoSharing (14.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoSystemUI (6.0.8):
|
||||
- ExpoSystemUI (6.0.9):
|
||||
- ExpoModulesCore
|
||||
- ExpoWebBrowser (15.0.9):
|
||||
- ExpoWebBrowser (15.0.10):
|
||||
- ExpoModulesCore
|
||||
- EXStructuredHeaders (5.0.0)
|
||||
- EXUpdates (29.0.12):
|
||||
- EXUpdates (29.0.15):
|
||||
- EASClient
|
||||
- EXManifests
|
||||
- ExpoModulesCore
|
||||
|
|
@ -332,7 +331,7 @@ PODS:
|
|||
- hermes-engine (0.81.4):
|
||||
- hermes-engine/Pre-built (= 0.81.4)
|
||||
- hermes-engine/Pre-built (0.81.4)
|
||||
- ImageColors (2.5.0):
|
||||
- ImageColors (2.5.1):
|
||||
- ExpoModulesCore
|
||||
- KSPlayer (1.1.0):
|
||||
- KSPlayer/Audio (= 1.1.0)
|
||||
|
|
@ -406,8 +405,7 @@ PODS:
|
|||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- MMKVCore (2.2.4)
|
||||
- MobileVLCKit (3.6.1b1)
|
||||
- NitroMmkv (4.0.0):
|
||||
- NitroMmkv (4.1.0):
|
||||
- hermes-engine
|
||||
- MMKVCore (= 2.2.4)
|
||||
- NitroModules
|
||||
|
|
@ -432,7 +430,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- NitroModules (0.31.6):
|
||||
- NitroModules (0.31.10):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -1758,7 +1756,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-bottom-tabs (1.0.2):
|
||||
- react-native-bottom-tabs (1.1.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -1770,7 +1768,7 @@ PODS:
|
|||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- react-native-bottom-tabs/common (= 1.0.2)
|
||||
- react-native-bottom-tabs/common (= 1.1.0)
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
|
|
@ -1782,7 +1780,7 @@ PODS:
|
|||
- ReactNativeDependencies
|
||||
- SwiftUIIntrospect (~> 1.0)
|
||||
- Yoga
|
||||
- react-native-bottom-tabs/common (1.0.2):
|
||||
- react-native-bottom-tabs/common (1.1.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -1904,7 +1902,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-skia (2.3.13):
|
||||
- react-native-skia (2.4.14):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -1973,7 +1971,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-video (6.17.0):
|
||||
- react-native-video (6.18.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -1985,7 +1983,7 @@ PODS:
|
|||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- react-native-video/Video (= 6.17.0)
|
||||
- react-native-video/Video (= 6.18.0)
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
|
|
@ -1996,7 +1994,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-video/Fabric (6.17.0):
|
||||
- react-native-video/Fabric (6.18.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2018,7 +2016,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-video/Video (6.17.0):
|
||||
- react-native-video/Video (6.18.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2463,7 +2461,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNReanimated (4.1.5):
|
||||
- RNReanimated (4.2.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2485,10 +2483,10 @@ PODS:
|
|||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- RNReanimated/reanimated (= 4.1.5)
|
||||
- RNReanimated/reanimated (= 4.2.0)
|
||||
- RNWorklets
|
||||
- Yoga
|
||||
- RNReanimated/reanimated (4.1.5):
|
||||
- RNReanimated/reanimated (4.2.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2510,10 +2508,10 @@ PODS:
|
|||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- RNReanimated/reanimated/apple (= 4.1.5)
|
||||
- RNReanimated/reanimated/apple (= 4.2.0)
|
||||
- RNWorklets
|
||||
- Yoga
|
||||
- RNReanimated/reanimated/apple (4.1.5):
|
||||
- RNReanimated/reanimated/apple (4.2.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2584,7 +2582,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNSentry (7.6.0):
|
||||
- RNSentry (7.7.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2606,9 +2604,9 @@ PODS:
|
|||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Sentry/HybridSDK (= 8.57.2)
|
||||
- Sentry/HybridSDK (= 8.57.3)
|
||||
- Yoga
|
||||
- RNSVG (15.15.0):
|
||||
- RNSVG (15.15.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2629,9 +2627,9 @@ PODS:
|
|||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- RNSVG/common (= 15.15.0)
|
||||
- RNSVG/common (= 15.15.1)
|
||||
- Yoga
|
||||
- RNSVG/common (15.15.0):
|
||||
- RNSVG/common (15.15.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2675,7 +2673,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNWorklets (0.6.1):
|
||||
- RNWorklets (0.7.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2697,9 +2695,9 @@ PODS:
|
|||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- RNWorklets/worklets (= 0.6.1)
|
||||
- RNWorklets/worklets (= 0.7.1)
|
||||
- Yoga
|
||||
- RNWorklets/worklets (0.6.1):
|
||||
- RNWorklets/worklets (0.7.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2721,9 +2719,9 @@ PODS:
|
|||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- RNWorklets/worklets/apple (= 0.6.1)
|
||||
- RNWorklets/worklets/apple (= 0.7.1)
|
||||
- Yoga
|
||||
- RNWorklets/worklets/apple (0.6.1):
|
||||
- RNWorklets/worklets/apple (0.7.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2746,9 +2744,9 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- SDWebImage (5.21.3):
|
||||
- SDWebImage/Core (= 5.21.3)
|
||||
- SDWebImage/Core (5.21.3)
|
||||
- SDWebImage (5.21.5):
|
||||
- SDWebImage/Core (= 5.21.5)
|
||||
- SDWebImage/Core (5.21.5)
|
||||
- SDWebImageAVIFCoder (0.11.1):
|
||||
- libavif/core (>= 0.11.0)
|
||||
- SDWebImage (~> 5.10)
|
||||
|
|
@ -2757,12 +2755,12 @@ PODS:
|
|||
- SDWebImageWebPCoder (0.15.0):
|
||||
- libwebp (~> 1.0)
|
||||
- SDWebImage/Core (~> 5.17)
|
||||
- Sentry/HybridSDK (8.57.2)
|
||||
- Sentry/HybridSDK (8.57.3)
|
||||
- SwiftUIIntrospect (1.3.0)
|
||||
- Yoga (0.0.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- DisplayCriteria (from `https://github.com/kingslay/KSPlayer.git`, branch `main`)
|
||||
- DisplayCriteria (from `../KSPlayer`)
|
||||
- EASClient (from `../node_modules/expo-eas-client/ios`)
|
||||
- EXApplication (from `../node_modules/expo-application/ios`)
|
||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||
|
|
@ -2777,6 +2775,7 @@ DEPENDENCIES:
|
|||
- ExpoAsset (from `../node_modules/expo-asset/ios`)
|
||||
- ExpoBlur (from `../node_modules/expo-blur/ios`)
|
||||
- ExpoBrightness (from `../node_modules/expo-brightness/ios`)
|
||||
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
|
||||
- ExpoCrypto (from `../node_modules/expo-crypto/ios`)
|
||||
- ExpoDevice (from `../node_modules/expo-device/ios`)
|
||||
- ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`)
|
||||
|
|
@ -2785,7 +2784,6 @@ DEPENDENCIES:
|
|||
- ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`)
|
||||
- ExpoHaptics (from `../node_modules/expo-haptics/ios`)
|
||||
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
|
||||
- ExpoLibVlcPlayer (from `../node_modules/expo-libvlc-player/ios`)
|
||||
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
|
||||
- ExpoLinking (from `../node_modules/expo-linking/ios`)
|
||||
- ExpoLocalization (from `../node_modules/expo-localization/ios`)
|
||||
|
|
@ -2802,7 +2800,7 @@ DEPENDENCIES:
|
|||
- FFmpegKit (from `https://github.com/kingslay/FFmpegKit.git`, branch `main`)
|
||||
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
||||
- ImageColors (from `../node_modules/react-native-image-colors/ios`)
|
||||
- KSPlayer (from `https://github.com/kingslay/KSPlayer.git`, branch `main`)
|
||||
- KSPlayer (from `../KSPlayer`)
|
||||
- Libass (from `https://github.com/kingslay/FFmpegKit.git`, branch `main`)
|
||||
- lottie-react-native (from `../node_modules/lottie-react-native`)
|
||||
- NitroMmkv (from `../node_modules/react-native-mmkv`)
|
||||
|
|
@ -2901,7 +2899,6 @@ SPEC REPOS:
|
|||
- libwebp
|
||||
- lottie-ios
|
||||
- MMKVCore
|
||||
- MobileVLCKit
|
||||
- PromisesObjC
|
||||
- ReachabilitySwift
|
||||
- SDWebImage
|
||||
|
|
@ -2913,8 +2910,7 @@ SPEC REPOS:
|
|||
|
||||
EXTERNAL SOURCES:
|
||||
DisplayCriteria:
|
||||
:branch: main
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
:path: "../KSPlayer"
|
||||
EASClient:
|
||||
:path: "../node_modules/expo-eas-client/ios"
|
||||
EXApplication:
|
||||
|
|
@ -2943,6 +2939,8 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/expo-blur/ios"
|
||||
ExpoBrightness:
|
||||
:path: "../node_modules/expo-brightness/ios"
|
||||
ExpoClipboard:
|
||||
:path: "../node_modules/expo-clipboard/ios"
|
||||
ExpoCrypto:
|
||||
:path: "../node_modules/expo-crypto/ios"
|
||||
ExpoDevice:
|
||||
|
|
@ -2959,8 +2957,6 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/expo-haptics/ios"
|
||||
ExpoKeepAwake:
|
||||
:path: "../node_modules/expo-keep-awake/ios"
|
||||
ExpoLibVlcPlayer:
|
||||
:path: "../node_modules/expo-libvlc-player/ios"
|
||||
ExpoLinearGradient:
|
||||
:path: "../node_modules/expo-linear-gradient/ios"
|
||||
ExpoLinking:
|
||||
|
|
@ -2996,8 +2992,7 @@ EXTERNAL SOURCES:
|
|||
ImageColors:
|
||||
:path: "../node_modules/react-native-image-colors/ios"
|
||||
KSPlayer:
|
||||
:branch: main
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
:path: "../KSPlayer"
|
||||
Libass:
|
||||
:branch: main
|
||||
:git: https://github.com/kingslay/FFmpegKit.git
|
||||
|
|
@ -3177,61 +3172,55 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
CHECKOUT OPTIONS:
|
||||
DisplayCriteria:
|
||||
:commit: cbc74996afb55e096bf1ff240f07d1d206ac86df
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
FFmpegKit:
|
||||
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
|
||||
:git: https://github.com/kingslay/FFmpegKit.git
|
||||
KSPlayer:
|
||||
:commit: cbc74996afb55e096bf1ff240f07d1d206ac86df
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
Libass:
|
||||
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
|
||||
:git: https://github.com/kingslay/FFmpegKit.git
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
DisplayCriteria: bb0a90faf14b30848bc50ac0516340ce50164187
|
||||
EASClient: 68127f1248d2b25fdc82dbbfb17be95d1c4700be
|
||||
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
|
||||
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
|
||||
EASClient: 40dd9e740684782610c49becab2643782ea1a20c
|
||||
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
|
||||
EXConstants: 805f35b1b295c542ca6acce836f21a1f9ee104d5
|
||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||
EXManifests: 224345a575fca389073c416297b6348163f28d1a
|
||||
EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506
|
||||
Expo: fb09185d798c2876a4c5ca89a5c6b8b72b6dbecf
|
||||
expo-dev-client: b6e7b4f4063ae44b5e68cc6a8bcc0c79c3037c1a
|
||||
expo-dev-launcher: c8813e0064e8768d676ee490c0f7ef1784d70b98
|
||||
expo-dev-menu: 0a1194185c9eec1da0e507b734180775363be442
|
||||
EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b
|
||||
EXNotifications: 983f04ad4ad879b181179e326bf220541e478386
|
||||
Expo: 8fa2204bf8483fe546b4ec87c90d3ca189afc8db
|
||||
expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d
|
||||
expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3
|
||||
expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d
|
||||
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
||||
ExpoAsset: 9ba6fbd677fb8e241a3899ac00fa735bc911eadf
|
||||
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
|
||||
ExpoBrightness: 32672952bf8b152d0cceaf8ec9f1def3a9a5e0d9
|
||||
ExpoCrypto: c1fbce112d1b6b79652bbe380b4fd4cc91676595
|
||||
ExpoDevice: 148accb4071873d19fba80a2506c58ffa433d620
|
||||
ExpoDocumentPicker: 2200eefc2817f19315fa18f0147e0b80ece86926
|
||||
ExpoFileSystem: b79eadbda7b7f285f378f95f959cc9313a1c9c61
|
||||
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
|
||||
ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
|
||||
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
|
||||
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
|
||||
ExpoLibVlcPlayer: 6b4a27f54f5300550227cffcf25cc88ab4f6c7c9
|
||||
ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
|
||||
ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d
|
||||
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
|
||||
ExpoModulesCore: aa1a8e103d41de84baa5d7c6b98314e2230f1eef
|
||||
ExpoAsset: 23a958e97d3d340919fe6774db35d563241e6c03
|
||||
ExpoBlur: b90747a3f22a8b6ceffd9cb0dc41a4184efdc656
|
||||
ExpoBrightness: 46c980463e8a54b9ce77f923c4bff0bb0c9526e0
|
||||
ExpoClipboard: b36b287d8356887844bb08ed5c84b5979bb4dd1e
|
||||
ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322
|
||||
ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a
|
||||
ExpoDocumentPicker: 7cd9e71a0f66fb19eb0a586d6f26eee1284692e0
|
||||
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
|
||||
ExpoFont: 35ac6191ed86bbf56b3ebd2d9154eda9fad5b509
|
||||
ExpoGlassEffect: 8ce45eca31f12e949e23a4ee13e2bfb59e9b0785
|
||||
ExpoHaptics: d3a6375d8dcc3a1083d003bc2298ff654fafb536
|
||||
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
|
||||
ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58
|
||||
ExpoLinking: f4c4a351523da72a6bfa7e1f4ca92aee1043a3ca
|
||||
ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd
|
||||
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
|
||||
ExpoRandom: d1444df65007bdd4070009efd5dab18e20bf0f00
|
||||
ExpoScreenOrientation: ef9ab3fb85c8a8ff57d52aa169b750aca03f0f4c
|
||||
ExpoSharing: 032c01bb034319e2374badf082ae935be866d2e9
|
||||
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
|
||||
ExpoWebBrowser: b973e1351fdcf5fec0c400997b1851f5a8219ec3
|
||||
ExpoScreenOrientation: c68bd20f210d0616960638c787889e07787e5adb
|
||||
ExpoSharing: 0d983394ed4a80334bab5a0d5384f75710feb7e8
|
||||
ExpoSystemUI: 2ad325f361a2fcd96a464e8574e19935c461c9cc
|
||||
ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52
|
||||
EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368
|
||||
EXUpdates: ef83273afc231a627b170358c90689ac30a4429d
|
||||
EXUpdates: f20abbc8a9f4e150656fe88126d52f52d4e7793f
|
||||
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
|
||||
FBLazyVector: 9e0cd874afd81d9a4d36679daca991b58b260d42
|
||||
FFmpegKit: 3885085fbbc320745838ee4c8a1f9c5e5953dab2
|
||||
google-cast-sdk: 32f65af50d164e3c475e79ad123db3cc26fbcd37
|
||||
hermes-engine: 35c763d57c9832d0eef764316ca1c4d043581394
|
||||
ImageColors: 51cd79f7a9d2524b7a681c660b0a50574085563b
|
||||
ImageColors: e12eb73e29bc1feaa3c228db8c174a1b25acb59d
|
||||
KSPlayer: f163ac6195f240b6fa5b8225aeb39ec811a70c62
|
||||
Libass: e88af2324e1217e3a4c8bdc675f6f23a9dfc7677
|
||||
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
||||
|
|
@ -3240,9 +3229,8 @@ SPEC CHECKSUMS:
|
|||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
||||
MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df
|
||||
MobileVLCKit: 2d9c7c373393ae43086aeeff890bf0b1afc15c5c
|
||||
NitroMmkv: 7fe66a61d5acab6516098a64f42af575595e7566
|
||||
NitroModules: a672a4b7470810b8dae8fc2ff91eabaa2e1eff7d
|
||||
NitroMmkv: 4af10c70043b4c3cded3f16547627c7d9d8e3b8b
|
||||
NitroModules: a71a5ab2911caf79e45170e6e12475b5260a12d0
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
RCTDeprecation: 7487d6dda857ccd4cb3dd6ecfccdc3170e85dcbc
|
||||
RCTRequired: 54128b7df8be566881d48c7234724a78cb9b6157
|
||||
|
|
@ -3279,15 +3267,15 @@ SPEC CHECKSUMS:
|
|||
React-Mapbuffer: fbe1da882a187e5898bdf125e1cc6e603d27ecae
|
||||
React-microtasksnativemodule: 76905804171d8ccbe69329fc84c57eb7934add7f
|
||||
react-native-blur: 1b00ef07fe0efdc0c40b37139a5268ccad73c72d
|
||||
react-native-bottom-tabs: b6459855502662d724d84b7edc937ea2b5a988ff
|
||||
react-native-bottom-tabs: bcb70e4fae95fc9da0da875f7414acda26dfc551
|
||||
react-native-device-brightness: 1a997350d060c3df9f303b1df84a4f7c5cbeb924
|
||||
react-native-get-random-values: a603782b2b222a34533c66371614790282dba3f1
|
||||
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
|
||||
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
|
||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
||||
react-native-skia: e386a7d05f10c87d2b0f9bf0165a6b59bc0c7410
|
||||
react-native-skia: 268f183f849742e9da216743ee234bd7ad81c69b
|
||||
react-native-slider: f954578344106f0a732a4358ce3a3e11015eb6e1
|
||||
react-native-video: 5d9635903e562e0c5eb47c5fa401f1c807d6e068
|
||||
react-native-video: f5982e21efab0dc356d92541a8a9e19581307f58
|
||||
React-NativeModulesApple: a9464983ccc0f66f45e93558671f60fc7536e438
|
||||
React-oscompat: 73db7dbc80edef36a9d6ed3c6c4e1724ead4236d
|
||||
React-perflogger: 123272debf907cc423962adafcf4513320e43757
|
||||
|
|
@ -3322,20 +3310,20 @@ SPEC CHECKSUMS:
|
|||
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
|
||||
RNFastImage: 2d36f4cfed9b2342f94f8591c8be69dd047ac67c
|
||||
RNGestureHandler: 723f29dac55e25f109d263ed65cecc4b9c4bd46a
|
||||
RNReanimated: 1442a577e066e662f0ce1cd1864a65c8e547aee0
|
||||
RNReanimated: e1c71e6e693a66b203ae98773347b625d3cc85ee
|
||||
RNScreens: 61c18865ab074f4d995ac8d7cf5060522a649d05
|
||||
RNSentry: be6d501966b60b30547abe59ea86626d80ad2680
|
||||
RNSVG: 99ab6158011aece12019b236f168faa7a1e41af6
|
||||
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
|
||||
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
|
||||
RNVectorIcons: 4351544f100d4f12cac156a7c13399e60bab3e26
|
||||
RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1
|
||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
||||
RNWorklets: 9eb6d567fa43984e96b6924a6df504b8a15980cd
|
||||
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
|
||||
Sentry: 83a3814c3ca042874b39c5c5bdffb6570d4d760e
|
||||
Sentry: c643eb180df401dd8c734c5036ddd9dd9218daa6
|
||||
SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d
|
||||
Yoga: 051f086b5ccf465ff2ed38a2cf5a558ae01aaaa1
|
||||
|
||||
PODFILE CHECKSUM: 1db7b3713ca6ad8568e4bdf6b72b92b72ee8199d
|
||||
PODFILE CHECKSUM: b884d1ff07ac4a43323bce2e2e1342592513858c
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"expo.jsEngine": "hermes",
|
||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||
"newArchEnabled": "true"
|
||||
}
|
||||
"newArchEnabled": "true",
|
||||
"ios.deploymentTarget": "16.0"
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
defaults.url=https://sentry.io/
|
||||
defaults.org=tapframe
|
||||
defaults.project=react-native
|
||||
auth.token=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
|
||||
auth.token=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
|
||||
|
|
|
|||
81
node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
generated
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package com.brentvatne.common.api
|
||||
|
||||
import android.graphics.Color
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
|
||||
/**
|
||||
* Helper file to parse SubtitleStyle prop and build a dedicated class
|
||||
*/
|
||||
class SubtitleStyle public constructor() {
|
||||
var fontSize = -1
|
||||
private set
|
||||
var paddingLeft = 0
|
||||
private set
|
||||
var paddingRight = 0
|
||||
private set
|
||||
var paddingTop = 0
|
||||
private set
|
||||
var paddingBottom = 0
|
||||
private set
|
||||
var opacity = 1f
|
||||
private set
|
||||
var subtitlesFollowVideo = true
|
||||
private set
|
||||
|
||||
// Extended styling (used by ExoPlayerView via Media3 SubtitleView)
|
||||
// Stored as Android color ints to avoid parsing multiple times.
|
||||
var textColor: Int? = null
|
||||
private set
|
||||
var backgroundColor: Int? = null
|
||||
private set
|
||||
var edgeType: String? = null
|
||||
private set
|
||||
var edgeColor: Int? = null
|
||||
private set
|
||||
|
||||
companion object {
|
||||
private const val PROP_FONT_SIZE_TRACK = "fontSize"
|
||||
private const val PROP_PADDING_BOTTOM = "paddingBottom"
|
||||
private const val PROP_PADDING_TOP = "paddingTop"
|
||||
private const val PROP_PADDING_LEFT = "paddingLeft"
|
||||
private const val PROP_PADDING_RIGHT = "paddingRight"
|
||||
private const val PROP_OPACITY = "opacity"
|
||||
private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo"
|
||||
|
||||
// Extended props (optional)
|
||||
private const val PROP_TEXT_COLOR = "textColor"
|
||||
private const val PROP_BACKGROUND_COLOR = "backgroundColor"
|
||||
private const val PROP_EDGE_TYPE = "edgeType"
|
||||
private const val PROP_EDGE_COLOR = "edgeColor"
|
||||
|
||||
private fun parseColorOrNull(value: String?): Int? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
return try {
|
||||
Color.parseColor(value)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): SubtitleStyle {
|
||||
val subtitleStyle = SubtitleStyle()
|
||||
subtitleStyle.fontSize = ReactBridgeUtils.safeGetInt(src, PROP_FONT_SIZE_TRACK, -1)
|
||||
subtitleStyle.paddingBottom = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_BOTTOM, 0)
|
||||
subtitleStyle.paddingTop = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_TOP, 0)
|
||||
subtitleStyle.paddingLeft = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_LEFT, 0)
|
||||
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
|
||||
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
|
||||
subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true)
|
||||
|
||||
// Extended styling
|
||||
subtitleStyle.textColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_TEXT_COLOR, null))
|
||||
subtitleStyle.backgroundColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_BACKGROUND_COLOR, null))
|
||||
subtitleStyle.edgeType = ReactBridgeUtils.safeGetString(src, PROP_EDGE_TYPE, null)
|
||||
subtitleStyle.edgeColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_EDGE_COLOR, null))
|
||||
|
||||
return subtitleStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
441
node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
generated
vendored
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
package com.brentvatne.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.View.MeasureSpec
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import com.brentvatne.common.api.SubtitleStyle
|
||||
|
||||
@UnstableApi
|
||||
class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
|
||||
FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var localStyle = SubtitleStyle()
|
||||
private var pendingResizeMode: Int? = null
|
||||
private val liveBadge: TextView = TextView(context).apply {
|
||||
text = "LIVE"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
val drawable = GradientDrawable()
|
||||
drawable.setColor(Color.RED)
|
||||
drawable.cornerRadius = 6f
|
||||
background = drawable
|
||||
setPadding(12, 4, 12, 4)
|
||||
visibility = View.GONE
|
||||
}
|
||||
|
||||
private val playerView = PlayerView(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
setShutterBackgroundColor(Color.TRANSPARENT)
|
||||
useController = true
|
||||
controllerAutoShow = true
|
||||
controllerHideOnTouch = true
|
||||
controllerShowTimeoutMs = 5000
|
||||
// Don't show subtitle button by default - will be enabled when tracks are available
|
||||
setShowSubtitleButton(false)
|
||||
// Enable proper surface view handling to prevent rendering issues
|
||||
setUseArtwork(false)
|
||||
setDefaultArtwork(null)
|
||||
// Ensure proper video scaling - start with FIT mode
|
||||
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame).
|
||||
* This keeps subtitles anchored in-place even when the video surface/content frame moves
|
||||
* due to aspect ratio / resizeMode changes.
|
||||
*
|
||||
* Controlled by SubtitleStyle.subtitlesFollowVideo.
|
||||
*/
|
||||
private val overlaySubtitleView = SubtitleView(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
visibility = View.GONE
|
||||
// We control styling via SubtitleStyle; don't pull Android system caption defaults.
|
||||
setApplyEmbeddedStyles(true)
|
||||
setApplyEmbeddedFontSizes(true)
|
||||
}
|
||||
|
||||
private fun updateSubtitleRenderingMode() {
|
||||
val internalSubtitleView = playerView.subtitleView
|
||||
val followVideo = localStyle.subtitlesFollowVideo
|
||||
val shouldShow = localStyle.opacity != 0.0f
|
||||
|
||||
if (followVideo) {
|
||||
internalSubtitleView?.visibility = if (shouldShow) View.VISIBLE else View.GONE
|
||||
overlaySubtitleView.visibility = View.GONE
|
||||
} else {
|
||||
// Hard-disable PlayerView's internal subtitle view. PlayerView can recreate/toggle this view
|
||||
// during resize/layout, so we re-assert this in multiple lifecycle points.
|
||||
internalSubtitleView?.visibility = View.GONE
|
||||
internalSubtitleView?.alpha = 0f
|
||||
overlaySubtitleView.visibility = if (shouldShow) View.VISIBLE else View.GONE
|
||||
overlaySubtitleView.alpha = 1f
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Add PlayerView with explicit layout parameters
|
||||
val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(playerView, playerViewLayoutParams)
|
||||
|
||||
// Add overlay subtitles above PlayerView (so it doesn't move with video content frame)
|
||||
val subtitleOverlayLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(overlaySubtitleView, subtitleOverlayLayoutParams)
|
||||
|
||||
// Add live badge with its own layout parameters
|
||||
val liveBadgeLayoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||
liveBadgeLayoutParams.setMargins(16, 16, 16, 16)
|
||||
addView(liveBadge, liveBadgeLayoutParams)
|
||||
|
||||
// PlayerView may internally recreate its subtitle view during relayouts (e.g. resizeMode changes).
|
||||
// Ensure our rendering mode is re-applied whenever PlayerView lays out.
|
||||
playerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
updateSubtitleRenderingMode()
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlayer(player: ExoPlayer?) {
|
||||
val currentPlayer = playerView.player
|
||||
|
||||
if (currentPlayer != null) {
|
||||
currentPlayer.removeListener(playerListener)
|
||||
}
|
||||
|
||||
playerView.player = player
|
||||
|
||||
if (player != null) {
|
||||
player.addListener(playerListener)
|
||||
|
||||
// Apply pending resize mode if we have one
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
}
|
||||
|
||||
// Re-assert subtitle rendering mode for the current style.
|
||||
updateSubtitleRenderingMode()
|
||||
applySubtitleStyle(localStyle)
|
||||
}
|
||||
|
||||
fun getPlayerView(): PlayerView = playerView
|
||||
|
||||
fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
|
||||
val targetResizeMode = when (resizeMode) {
|
||||
ResizeMode.RESIZE_MODE_FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
ResizeMode.RESIZE_MODE_CENTER_CROP -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
ResizeMode.RESIZE_MODE_FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
ResizeMode.RESIZE_MODE_FIXED_WIDTH -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
|
||||
// Apply the resize mode to PlayerView immediately
|
||||
playerView.resizeMode = targetResizeMode
|
||||
|
||||
// Store it for reapplication if needed
|
||||
pendingResizeMode = targetResizeMode
|
||||
|
||||
// Force PlayerView to recalculate its layout
|
||||
playerView.requestLayout()
|
||||
|
||||
// Also request layout on the parent to ensure proper sizing
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
localStyle = style
|
||||
applySubtitleStyle(localStyle)
|
||||
}
|
||||
|
||||
private fun applySubtitleStyle(style: SubtitleStyle) {
|
||||
updateSubtitleRenderingMode()
|
||||
|
||||
playerView.subtitleView?.let { subtitleView ->
|
||||
// Important:
|
||||
// Avoid inheriting Android system caption settings via setUserDefaultStyle(),
|
||||
// because those can force a background/window that the app doesn't want.
|
||||
val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
|
||||
val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
|
||||
val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
|
||||
|
||||
val resolvedEdgeType = when (style.edgeType?.lowercase()) {
|
||||
"outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
"shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||
else -> CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
}
|
||||
|
||||
// windowColor MUST be transparent to avoid the "caption window" background.
|
||||
val captionStyle = CaptionStyleCompat(
|
||||
resolvedTextColor,
|
||||
resolvedBackgroundColor,
|
||||
Color.TRANSPARENT,
|
||||
resolvedEdgeType,
|
||||
resolvedEdgeColor,
|
||||
null
|
||||
)
|
||||
subtitleView.setStyle(captionStyle)
|
||||
|
||||
// Text size: if not provided, fall back to user default size.
|
||||
if (style.fontSize > 0) {
|
||||
// Use DIP so the value matches React Native's dp-based fontSize more closely.
|
||||
// SP would multiply by system fontScale and makes "30" look larger than RN "30".
|
||||
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
|
||||
} else {
|
||||
subtitleView.setUserDefaultTextSize()
|
||||
}
|
||||
|
||||
// Horizontal padding is still useful (safe area); vertical offset is handled via bottomPaddingFraction.
|
||||
subtitleView.setPadding(
|
||||
style.paddingLeft,
|
||||
style.paddingTop,
|
||||
style.paddingRight,
|
||||
0
|
||||
)
|
||||
|
||||
// Bottom offset for *internal* subtitles:
|
||||
// Use Media3 SubtitleView's bottomPaddingFraction (moves cues up) rather than raw view padding.
|
||||
if (style.paddingBottom > 0 && playerView.height > 0) {
|
||||
val fraction = (style.paddingBottom.toFloat() / playerView.height.toFloat())
|
||||
.coerceIn(0f, 0.9f)
|
||||
subtitleView.setBottomPaddingFraction(fraction)
|
||||
}
|
||||
|
||||
if (style.opacity != 0.0f) {
|
||||
subtitleView.alpha = style.opacity
|
||||
subtitleView.visibility = android.view.View.VISIBLE
|
||||
} else {
|
||||
subtitleView.visibility = android.view.View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the same styling to the overlay subtitle view.
|
||||
run {
|
||||
val subtitleView = overlaySubtitleView
|
||||
|
||||
val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
|
||||
val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
|
||||
val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
|
||||
|
||||
val resolvedEdgeType = when (style.edgeType?.lowercase()) {
|
||||
"outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
"shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||
else -> CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
}
|
||||
|
||||
val captionStyle = CaptionStyleCompat(
|
||||
resolvedTextColor,
|
||||
resolvedBackgroundColor,
|
||||
Color.TRANSPARENT,
|
||||
resolvedEdgeType,
|
||||
resolvedEdgeColor,
|
||||
null
|
||||
)
|
||||
subtitleView.setStyle(captionStyle)
|
||||
|
||||
if (style.fontSize > 0) {
|
||||
// Use DIP so the value matches React Native's dp-based fontSize more closely.
|
||||
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
|
||||
} else {
|
||||
subtitleView.setUserDefaultTextSize()
|
||||
}
|
||||
|
||||
subtitleView.setPadding(
|
||||
style.paddingLeft,
|
||||
style.paddingTop,
|
||||
style.paddingRight,
|
||||
0
|
||||
)
|
||||
|
||||
// Bottom offset relative to the full view height (stable even when video content frame moves).
|
||||
val h = height.takeIf { it > 0 } ?: subtitleView.height
|
||||
if (style.paddingBottom > 0 && h > 0) {
|
||||
val fraction = (style.paddingBottom.toFloat() / h.toFloat())
|
||||
.coerceIn(0f, 0.9f)
|
||||
subtitleView.setBottomPaddingFraction(fraction)
|
||||
} else {
|
||||
subtitleView.setBottomPaddingFraction(0f)
|
||||
}
|
||||
|
||||
if (style.opacity != 0.0f) {
|
||||
subtitleView.alpha = style.opacity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setShutterColor(color: Int) {
|
||||
playerView.setShutterBackgroundColor(color)
|
||||
}
|
||||
|
||||
fun updateSurfaceView(viewType: Int) {
|
||||
// TODO: Implement proper surface type switching if needed
|
||||
}
|
||||
|
||||
val isPlaying: Boolean
|
||||
get() = playerView.player?.isPlaying ?: false
|
||||
|
||||
fun invalidateAspectRatio() {
|
||||
// PlayerView handles aspect ratio automatically through its internal AspectRatioFrameLayout
|
||||
playerView.requestLayout()
|
||||
|
||||
// Reapply the current resize mode to ensure it's properly set
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
}
|
||||
|
||||
fun setUseController(useController: Boolean) {
|
||||
playerView.useController = useController
|
||||
if (useController) {
|
||||
// Ensure proper touch handling when controls are enabled
|
||||
playerView.controllerAutoShow = true
|
||||
playerView.controllerHideOnTouch = true
|
||||
// Show controls immediately when enabled
|
||||
playerView.showController()
|
||||
}
|
||||
}
|
||||
|
||||
fun showController() {
|
||||
playerView.showController()
|
||||
}
|
||||
|
||||
fun hideController() {
|
||||
playerView.hideController()
|
||||
}
|
||||
|
||||
fun setControllerShowTimeoutMs(showTimeoutMs: Int) {
|
||||
playerView.controllerShowTimeoutMs = showTimeoutMs
|
||||
}
|
||||
|
||||
fun setControllerAutoShow(autoShow: Boolean) {
|
||||
playerView.controllerAutoShow = autoShow
|
||||
}
|
||||
|
||||
fun setControllerHideOnTouch(hideOnTouch: Boolean) {
|
||||
playerView.controllerHideOnTouch = hideOnTouch
|
||||
}
|
||||
|
||||
fun setFullscreenButtonClickListener(listener: PlayerView.FullscreenButtonClickListener?) {
|
||||
playerView.setFullscreenButtonClickListener(listener)
|
||||
}
|
||||
|
||||
fun setShowSubtitleButton(show: Boolean) {
|
||||
playerView.setShowSubtitleButton(show)
|
||||
}
|
||||
|
||||
fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible
|
||||
|
||||
fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) {
|
||||
playerView.setControllerVisibilityListener(listener)
|
||||
}
|
||||
|
||||
override fun addOnLayoutChangeListener(listener: View.OnLayoutChangeListener) {
|
||||
playerView.addOnLayoutChangeListener(listener)
|
||||
}
|
||||
|
||||
override fun setFocusable(focusable: Boolean) {
|
||||
playerView.isFocusable = focusable
|
||||
}
|
||||
|
||||
private fun updateLiveUi() {
|
||||
val player = playerView.player ?: return
|
||||
val isLive = player.isCurrentMediaItemLive
|
||||
val seekable = player.isCurrentMediaItemSeekable
|
||||
|
||||
// Show/hide badge
|
||||
liveBadge.visibility = if (isLive) View.VISIBLE else View.GONE
|
||||
|
||||
// Disable/enable scrubbing based on seekable
|
||||
val timeBar = playerView.findViewById<DefaultTimeBar?>(androidx.media3.ui.R.id.exo_progress)
|
||||
timeBar?.isEnabled = !isLive || seekable
|
||||
}
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
override fun onCues(cueGroup: CueGroup) {
|
||||
// Keep overlay subtitles in sync. This does NOT interfere with PlayerView's own subtitle rendering.
|
||||
// When subtitlesFollowVideo=false, overlaySubtitleView is the visible one.
|
||||
updateSubtitleRenderingMode()
|
||||
overlaySubtitleView.setCues(cueGroup.cues)
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
playerView.post {
|
||||
playerView.requestLayout()
|
||||
// Reapply resize mode to ensure it's properly set after timeline changes
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
}
|
||||
updateLiveUi()
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) ||
|
||||
events.contains(Player.EVENT_IS_PLAYING_CHANGED)
|
||||
) {
|
||||
updateLiveUi()
|
||||
}
|
||||
|
||||
// Handle video size changes which affect aspect ratio
|
||||
if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) {
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
playerView.requestLayout()
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExoPlayerView"
|
||||
}
|
||||
|
||||
/**
|
||||
* React Native (Yoga) can sometimes defer layout passes that are required by
|
||||
* PlayerView for its child views (controller overlay, surface view, subtitle view, …).
|
||||
* This helper forces a second measure / layout after RN finishes, ensuring the
|
||||
* internal views receive the final size. The same approach is used in the v7
|
||||
* implementation (see VideoView.kt) and in React Native core (Toolbar example [link]).
|
||||
*/
|
||||
private val layoutRunnable = Runnable {
|
||||
measure(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
||||
)
|
||||
layout(left, top, right, bottom)
|
||||
}
|
||||
|
||||
override fun requestLayout() {
|
||||
super.requestLayout()
|
||||
// Post a second layout pass so the ExoPlayer internal views get correct bounds.
|
||||
post(layoutRunnable)
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
|
||||
if (changed) {
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
// Re-apply bottomPaddingFraction once we have a concrete height.
|
||||
updateSubtitleRenderingMode()
|
||||
applySubtitleStyle(localStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -161,10 +161,11 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
AdEvent.AdEventListener,
|
||||
AdErrorEvent.AdErrorListener {
|
||||
|
||||
public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 1;
|
||||
public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 0.5;
|
||||
public static final double DEFAULT_MIN_BUFFER_MEMORY_RESERVE = 0;
|
||||
|
||||
private static final String TAG = "ReactExoplayerView";
|
||||
private static final ExecutorService SHARED_EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
|
||||
private static final CookieManager DEFAULT_COOKIE_MANAGER;
|
||||
private static final int SHOW_PROGRESS = 1;
|
||||
|
|
@ -211,6 +212,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
private float audioVolume = 1f;
|
||||
private int maxBitRate = 0;
|
||||
private boolean hasDrmFailed = false;
|
||||
private int drmRetryCount = 0;
|
||||
private boolean isUsingContentResolution = false;
|
||||
private boolean selectTrackWhenReady = false;
|
||||
private final Handler mainHandler;
|
||||
|
|
@ -227,6 +229,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
*/
|
||||
private boolean isSeeking = false;
|
||||
private long seekPosition = -1;
|
||||
private boolean hasVideoEnded = false;
|
||||
|
||||
// Props from React
|
||||
private Source source = new Source();
|
||||
|
|
@ -242,7 +245,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
private BufferingStrategy.BufferingStrategyEnum bufferingStrategy;
|
||||
private boolean disableDisconnectError;
|
||||
private boolean preventsDisplaySleepDuringVideoPlayback = true;
|
||||
private float mProgressUpdateInterval = 250.0f;
|
||||
private float mProgressUpdateInterval = 1000.0f;
|
||||
protected boolean playInBackground = false;
|
||||
private boolean mReportBandwidth = false;
|
||||
private boolean controls = false;
|
||||
|
|
@ -642,9 +645,12 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave);
|
||||
}
|
||||
if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) {
|
||||
long requestedCacheSize = source.getBufferConfig().getCacheSize();
|
||||
long MAX_SAFE_CACHE_SIZE = 100L * 1024 * 1024;
|
||||
long effectiveCacheSize = Math.min(requestedCacheSize, MAX_SAFE_CACHE_SIZE);
|
||||
RNVSimpleCache.INSTANCE.setSimpleCache(
|
||||
this.getContext(),
|
||||
source.getBufferConfig().getCacheSize()
|
||||
(int) effectiveCacheSize
|
||||
);
|
||||
useCache = true;
|
||||
} else {
|
||||
|
|
@ -653,9 +659,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
if (playerNeedsSource) {
|
||||
// Will force display of shutter view if needed
|
||||
exoPlayerView.invalidateAspectRatio();
|
||||
drmRetryCount = 0;
|
||||
hasDrmFailed = false;
|
||||
// DRM session manager creation must be done on a different thread to prevent crashes so we start a new thread
|
||||
ExecutorService es = Executors.newSingleThreadExecutor();
|
||||
es.execute(() -> {
|
||||
SHARED_EXECUTOR.execute(() -> {
|
||||
// DRM initialization must run on a different thread
|
||||
if (viewHasDropped && runningSource == source) {
|
||||
return;
|
||||
|
|
@ -850,13 +857,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
MediaSource mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, videoSource);
|
||||
|
||||
// wait for player to be set
|
||||
while (player == null) {
|
||||
try {
|
||||
wait();
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
DebugLog.e(TAG, ex.toString());
|
||||
}
|
||||
if (player == null) {
|
||||
DebugLog.w(TAG, "Player not ready yet, aborting source initialization");
|
||||
playerNeedsSource = true;
|
||||
return;
|
||||
}
|
||||
|
||||
boolean haveResumePosition = resumeWindow != C.INDEX_UNSET;
|
||||
|
|
@ -1411,6 +1415,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
break;
|
||||
case Player.STATE_READY:
|
||||
text += "ready";
|
||||
hasVideoEnded = false;
|
||||
eventEmitter.onReadyForDisplay.invoke();
|
||||
onBuffering(false);
|
||||
clearProgressMessageHandler(); // ensure there is no other message
|
||||
|
|
@ -1429,7 +1434,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
case Player.STATE_ENDED:
|
||||
text += "ended";
|
||||
updateProgress();
|
||||
eventEmitter.onVideoEnd.invoke();
|
||||
if (!hasVideoEnded) {
|
||||
hasVideoEnded = true;
|
||||
eventEmitter.onVideoEnd.invoke();
|
||||
}
|
||||
onStopPlayback();
|
||||
setKeepScreenOn(false);
|
||||
break;
|
||||
|
|
@ -1479,8 +1487,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
ArrayList<Track> textTracks = getTextTrackInfo();
|
||||
|
||||
if (source.getContentStartTime() != -1) {
|
||||
ExecutorService es = Executors.newSingleThreadExecutor();
|
||||
es.execute(() -> {
|
||||
SHARED_EXECUTOR.execute(() -> {
|
||||
// To prevent ANRs caused by getVideoTrackInfo we run this on a different thread and notify the player only when we're done
|
||||
ArrayList<VideoTrack> videoTracks = getVideoTrackInfoFromManifest();
|
||||
if (videoTracks != null) {
|
||||
|
|
@ -1591,12 +1598,11 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
// We need retry count to in case where minefest request fails from poor network conditions
|
||||
@WorkerThread
|
||||
private ArrayList<VideoTrack> getVideoTrackInfoFromManifest(int retryCount) {
|
||||
ExecutorService es = Executors.newSingleThreadExecutor();
|
||||
final DataSource dataSource = this.mediaDataSourceFactory.createDataSource();
|
||||
final Uri sourceUri = source.getUri();
|
||||
final long startTime = source.getContentStartTime() * 1000 - 100; // s -> ms with 100ms offset
|
||||
|
||||
Future<ArrayList<VideoTrack>> result = es.submit(new Callable() {
|
||||
Future<ArrayList<VideoTrack>> result = SHARED_EXECUTOR.submit(new Callable<ArrayList<VideoTrack>>() {
|
||||
final DataSource ds = dataSource;
|
||||
final Uri uri = sourceUri;
|
||||
final long startTimeUs = startTime * 1000; // ms -> us
|
||||
|
|
@ -1643,7 +1649,6 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
if (results == null && retryCount < 1) {
|
||||
return this.getVideoTrackInfoFromManifest(++retryCount);
|
||||
}
|
||||
es.shutdown();
|
||||
return results;
|
||||
} catch (Exception e) {
|
||||
DebugLog.w(TAG, "error in getVideoTrackInfoFromManifest handling request:" + e.getMessage());
|
||||
|
|
@ -1819,7 +1824,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION
|
||||
&& player.getRepeatMode() == Player.REPEAT_MODE_ONE) {
|
||||
updateProgress();
|
||||
eventEmitter.onVideoEnd.invoke();
|
||||
if (!hasVideoEnded) {
|
||||
hasVideoEnded = true;
|
||||
eventEmitter.onVideoEnd.invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1931,12 +1939,15 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
case PlaybackException.ERROR_CODE_DRM_UNSPECIFIED:
|
||||
if (!hasDrmFailed) {
|
||||
// When DRM fails to reach the app level certificate server it will fail with a source error so we assume that it is DRM related and try one more time
|
||||
hasDrmFailed = true;
|
||||
playerNeedsSource = true;
|
||||
updateResumePosition();
|
||||
initializePlayer();
|
||||
setPlayWhenReady(true);
|
||||
return;
|
||||
if (drmRetryCount < 1) {
|
||||
drmRetryCount++;
|
||||
hasDrmFailed = true;
|
||||
playerNeedsSource = true;
|
||||
updateResumePosition();
|
||||
initializePlayer();
|
||||
setPlayWhenReady(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
|
@ -2030,6 +2041,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
}
|
||||
|
||||
if (!isSourceEqual) {
|
||||
hasVideoEnded = false;
|
||||
playerNeedsSource = true;
|
||||
initializePlayer();
|
||||
}
|
||||
|
|
@ -2114,6 +2126,16 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
if (textRendererIndex != C.INDEX_UNSET) {
|
||||
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
|
||||
boolean trackFound = false;
|
||||
// NOTE:
|
||||
// RNVideo emits textTracks as a flattened list (Track.index is assigned using textTracks.size()).
|
||||
// However, previous logic compared the requested "index" against the *trackIndex within a group*,
|
||||
// which makes any index > 0 either select the wrong subtitle or keep the first one.
|
||||
// Here we interpret type="index" as the flattened index, matching the JS list order.
|
||||
int targetFlatIndex = -1;
|
||||
if ("index".equals(type)) {
|
||||
targetFlatIndex = ReactBridgeUtils.safeParseInt(value, -1);
|
||||
}
|
||||
int flatIndex = 0;
|
||||
|
||||
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
||||
TrackGroup group = groups.get(groupIndex);
|
||||
|
|
@ -2126,8 +2148,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
} else if ("title".equals(type) && format.label != null && format.label.equals(value)) {
|
||||
isMatch = true;
|
||||
} else if ("index".equals(type)) {
|
||||
int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
|
||||
if (targetIndex == trackIndex) {
|
||||
if (targetFlatIndex != -1 && targetFlatIndex == flatIndex) {
|
||||
isMatch = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -2139,6 +2160,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
trackFound = true;
|
||||
break;
|
||||
}
|
||||
flatIndex++;
|
||||
}
|
||||
if (trackFound) break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,54 @@
|
|||
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.3.5",
|
||||
"buildVersion": "33",
|
||||
"date": "2026-01-09",
|
||||
"localizedDescription": "## Update Notes\n\n### ExoPlayer Subtitle Fixes\nThis update mainly focuses on fixing multiple subtitle-related issues in ExoPlayer:\n- Fixed issue where **only the first subtitle index** was being rendered \n- Fixed subtitles **always showing background** due to Android native caption settings \n- Fixed subtitles being **rendered inside the player view** \n- Fixed **subtitle bottom offset** issues \n- Merged PR **#391** by **@saifshaikh1805** \n - Fixes issue **#301**\n\n### KSPlayer Improvements\n- Improved **subtitle behavior** in KSPlayer \n- Merged PR **#394** by **@AdityasahuX07**\n\n### Settings & Persistence\n- Implemented **save and load** functionality for **Discover settings**\n\nThis release improves subtitle rendering accuracy across players and adds better persistence for user preferences.",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.5/app-release.apk",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.3.4",
|
||||
"buildVersion": "32",
|
||||
"date": "2026-01-06",
|
||||
"localizedDescription": "## Update Notes\n\n### Player & Playback\n- Fixed **Android player crashes with large files** when using ExoPlayer \n - Merged PR **#361** by **@chrisk325**\n\n### Trakt Improvements\n- Improved **Trakt Continue Watching** section for better accuracy and reliability\n\n### Internationalization\n- Added **multi-language support** across the app using **i18n** \n - More languages will be added through **community contributions** \n - ⚠️ **Arabic UI does not use RTL yet**. RTL support will be added in a future update\n\n### Stability & Fixes\n- Crash optimizations and internal stability improvements\n\nThis update focuses on improving playback stability, Trakt experience, and expanding language support.",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.4/app-release.apk",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.3.3",
|
||||
"buildVersion": "31",
|
||||
"date": "2026-01-01",
|
||||
"localizedDescription": "# Nuvio Media Hub – v1.3.3\n\n## Update Notes\n\n### Playback & Preferences\n- Added **default audio and subtitle track selection**\n\n### Plugins & Repositories\n- Added support for **multiple active repositories**\n- Improved **plugin fetch logic** for better reliability and performance\n- Changed OTA server.\n\n### Trakt & Metadata Fixes\n- Fixed **TMDB enrichment logic**\n- Fixed **Trakt watch progress not syncing** for older seasons \n - Contributed by **@chrisk325** \n - Fixes #331 and closes #233 \n - ⚠️ It is recommended to **log out and log back into Trakt** inside the app to correctly reflect watched status for older seasons\n\n### UI Improvements & Bug Fixes\n- Minor UI refinements and bug fixes \n- Added **YouTube-style press-and-hold playback speed indicator** \n- Refined **gesture indicator pill** \n- Fixed **OSC not auto-hiding on Android** \n - Contributed by **@AdityasahuX07** \n - Fixes #326 and #298 \n\nThis release includes valuable contributions from the community and focuses on improving playback preferences, plugin handling, Trakt syncing, and overall UI polish.",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.3.3/Stable_1-3-3.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.3.2",
|
||||
"buildVersion": "30",
|
||||
"date": "2025-12-28",
|
||||
"localizedDescription": "# Nuvio Media Hub – v1.3.2\n\n### New Features\n- Added **Skip Intro** feature powered by **IntroDB** \n- Added support for **Internal Subtitle customization** for both **MPV** and **ExoPlayer** \n - ExoPlayer customization is currently limited \n\n### Stability & Fixes\n- Improved **StreamScreen error handling** to prevent crashes \n- Minor bug fixes and internal improvements",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.3.2/Stable_1-3-2.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.3.0",
|
||||
"buildVersion": "28",
|
||||
"date": "2025-12-24",
|
||||
"localizedDescription": "# ⚠️ Important Notice Before Updating to v1.3.0\n\nBefore updating, please read this carefully.\n\nEspecially for **Android users**, this update is **not mandatory**.\nWe have **completely migrated the internal player** from **ExoPlayer + VLC** to **MPV Player**.\nBecause this is a major internal change, **unexpected bugs may occur**.\n\nThis update is **recommended only for users who are willing to test and provide feedback**.\nYour feedback is important to help complete this migration and make it bug-free.\n\n---\n\n# Nuvio Media Hub – v1.3.0\n\n## Android\n- Replaced internal player with **MPV Player** for better codec support\n- Added toggles for **Software (SW) / Hardware (HW) decoding**\n\n## Global\n- Dependency updates\n- Minor bug fixes",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.3.0/Stable_1-3-0.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.2.11",
|
||||
"buildVersion": "27",
|
||||
"date": "2025-12-15",
|
||||
"localizedDescription": "# Nuvio Media Hub – v1.2.11 \n\n## Update Notes\n- **Dependency updates** for improved stability \n- **Android animation improvements** for smoother UI interactions \n- Multiple **backend bug fixes** \n\n## Note for iOS Users\n- iOS users can continue using the **TestFlight build** \n- It may show version **1.2.10 (27)**, but the **build is the same as 1.2.11** \n- This is intentional, as bumping the version would require a manual TestFlight review",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.2.11/Stable_1-2-11.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.2.10",
|
||||
"buildVersion": "25",
|
||||
|
|
@ -208,4 +256,4 @@
|
|||
}
|
||||
],
|
||||
"news": []
|
||||
}
|
||||
}
|
||||
1711
package-lock.json
generated
22
package.json
|
|
@ -12,7 +12,7 @@
|
|||
"@adrianso/react-native-device-brightness": "^1.2.7",
|
||||
"@backpackapp-io/react-native-toast": "^0.15.1",
|
||||
"@bottom-tabs/react-navigation": "^1.0.2",
|
||||
"@d11/react-native-fast-image": "^8.8.0",
|
||||
"@d11/react-native-fast-image": "^8.13.0",
|
||||
"@expo/env": "^2.0.7",
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
"@react-navigation/stack": "^7.2.10",
|
||||
"@sentry/react-native": "^7.6.0",
|
||||
"@shopify/flash-list": "^2.2.0",
|
||||
"@shopify/react-native-skia": "^2.3.13",
|
||||
"@shopify/react-native-skia": "^2.4.14",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react-native-video": "^5.0.20",
|
||||
"axios": "^1.12.2",
|
||||
|
|
@ -43,17 +43,19 @@
|
|||
"expo-auth-session": "~7.0.8",
|
||||
"expo-blur": "~15.0.7",
|
||||
"expo-brightness": "~14.0.7",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-crypto": "~15.0.7",
|
||||
"expo-dev-client": "~6.0.15",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-device": "~8.0.9",
|
||||
"expo-document-picker": "~14.0.7",
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-glass-effect": "~0.1.4",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-intent-launcher": "~13.0.7",
|
||||
"expo-libvlc-player": "^2.2.3",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-localization": "~17.0.7",
|
||||
"expo-navigation-bar": "~5.0.10",
|
||||
"expo-notifications": "~0.32.12",
|
||||
"expo-random": "^14.0.1",
|
||||
"expo-screen-orientation": "~9.0.7",
|
||||
|
|
@ -62,10 +64,14 @@
|
|||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"i18next": "^25.7.3",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lottie-react-native": "~7.3.1",
|
||||
"posthog-react-native": "^4.4.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-boost": "^0.6.2",
|
||||
"react-native-bottom-tabs": "^1.0.2",
|
||||
|
|
@ -78,17 +84,17 @@
|
|||
"react-native-mmkv": "^4.0.0",
|
||||
"react-native-nitro-modules": "^0.31.2",
|
||||
"react-native-paper": "^5.14.5",
|
||||
"react-native-reanimated": "^4.1.1",
|
||||
"react-native-reanimated": "^4.2.0",
|
||||
"react-native-reanimated-carousel": "^4.0.3",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "^4.18.0",
|
||||
"react-native-svg": "^15.12.1",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-video": "^6.17.0",
|
||||
"react-native-video": "6.18.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-wheel-color-picker": "^1.3.1",
|
||||
"react-native-worklets": "^0.6.1"
|
||||
"react-native-worklets": "^0.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
|
|
@ -99,7 +105,7 @@
|
|||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"xcode": "^3.0.1"
|
||||
},
|
||||
"private": true
|
||||
|
|
|
|||
|
|
@ -1,14 +1,58 @@
|
|||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/results.bin
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/results.bin
|
||||
new file mode 100644
|
||||
index 0000000..0d259dd
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/results.bin
|
||||
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/results.bin
|
||||
@@ -0,0 +1 @@
|
||||
+o/classes
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/transformed/classes/classes_dex/classes.dex
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/transformed/classes/classes_dex/classes.dex
|
||||
new file mode 100644
|
||||
index 0000000..f27c56f
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/transformed/classes/classes_dex/classes.dex differ
|
||||
index 0000000..83d6ed6
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/transformed/classes/classes_dex/classes.dex differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/results.bin
|
||||
new file mode 100644
|
||||
index 0000000..0d259dd
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/results.bin
|
||||
@@ -0,0 +1 @@
|
||||
+o/classes
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/transformed/classes/classes_dex/classes.dex
|
||||
new file mode 100644
|
||||
index 0000000..5c0a147
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/transformed/classes/classes_dex/classes.dex differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/results.bin
|
||||
new file mode 100644
|
||||
index 0000000..0d259dd
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/results.bin
|
||||
@@ -0,0 +1 @@
|
||||
+o/classes
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/transformed/classes/classes_dex/classes.dex
|
||||
new file mode 100644
|
||||
index 0000000..e74c2bf
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/transformed/classes/classes_dex/classes.dex differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/results.bin
|
||||
new file mode 100644
|
||||
index 0000000..0d259dd
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/results.bin
|
||||
@@ -0,0 +1 @@
|
||||
+o/classes
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/transformed/classes/classes_dex/classes.dex
|
||||
new file mode 100644
|
||||
index 0000000..d25d1d1
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/transformed/classes/classes_dex/classes.dex differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/results.bin
|
||||
new file mode 100644
|
||||
index 0000000..0d259dd
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/results.bin
|
||||
@@ -0,0 +1 @@
|
||||
+o/classes
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/transformed/classes/classes_dex/classes.dex
|
||||
new file mode 100644
|
||||
index 0000000..28daa05
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/transformed/classes/classes_dex/classes.dex differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/generated/source/buildConfig/debug/com/brentvatne/react/BuildConfig.java b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/generated/source/buildConfig/debug/com/brentvatne/react/BuildConfig.java
|
||||
new file mode 100644
|
||||
index 0000000..b26a50e
|
||||
|
|
@ -76,10 +120,6 @@ index 0000000..247891c
|
|||
+ "elementType": "File"
|
||||
+}
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/aar_main_jar/debug/syncDebugLibJars/classes.jar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/aar_main_jar/debug/syncDebugLibJars/classes.jar
|
||||
new file mode 100644
|
||||
index 0000000..1f4ce80
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/aar_main_jar/debug/syncDebugLibJars/classes.jar differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties
|
||||
new file mode 100644
|
||||
index 0000000..1211b1e
|
||||
|
|
@ -100,12 +140,9 @@ index 0000000..9e26dfe
|
|||
@@ -0,0 +1 @@
|
||||
+{}
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/annotations_typedef_file/debug/extractDebugAnnotations/typedefs.txt b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/annotations_typedef_file/debug/extractDebugAnnotations/typedefs.txt
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar
|
||||
new file mode 100644
|
||||
index 0000000..9ec2b04
|
||||
index 0000000..e5f72fd
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar
|
||||
new file mode 100644
|
||||
|
|
@ -169,17 +206,13 @@ diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda
|
|||
new file mode 100644
|
||||
index 0000000..bb5b5f9
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_exo_legacy_player_control_view.xml.flat differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug-mergeJavaRes/merge-state b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug-mergeJavaRes/merge-state
|
||||
new file mode 100644
|
||||
index 0000000..441a1d2
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug-mergeJavaRes/merge-state differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties
|
||||
new file mode 100644
|
||||
index 0000000..842471c
|
||||
index 0000000..186e5bd
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties
|
||||
@@ -0,0 +1,3 @@
|
||||
+#Thu Oct 16 20:19:39 IST 2025
|
||||
+#Sun Jan 11 19:30:05 IST 2026
|
||||
+com.brentvatne.react.react-native-video-main-6\:/drawable/circle.xml=/Users/nayifnoushad/Documents/Projects/NuvioStreaming/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/packaged_res/debug/packageDebugResources/drawable/circle.xml
|
||||
+com.brentvatne.react.react-native-video-main-6\:/layout/exo_legacy_player_control_view.xml=/Users/nayifnoushad/Documents/Projects/NuvioStreaming/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/packaged_res/debug/packageDebugResources/layout/exo_legacy_player_control_view.xml
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml
|
||||
|
|
@ -283,31 +316,31 @@ index 0000000..2f92e20
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/androidx/media3/exoplayer/rtsp/RtspMediaSource.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$1.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$1.class
|
||||
new file mode 100644
|
||||
index 0000000..a1368f5
|
||||
index 0000000..2a5b980
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$1.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$2.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$2.class
|
||||
new file mode 100644
|
||||
index 0000000..ec7b745
|
||||
index 0000000..750c145
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$2.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$3.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$3.class
|
||||
new file mode 100644
|
||||
index 0000000..598654a
|
||||
index 0000000..19b4277
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$3.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$4.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$4.class
|
||||
new file mode 100644
|
||||
index 0000000..803aefe
|
||||
index 0000000..9f6af89
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$4.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$OnAudioFocusChangedListener.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$OnAudioFocusChangedListener.class
|
||||
new file mode 100644
|
||||
index 0000000..6ec4ad1
|
||||
index 0000000..99b4560
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$OnAudioFocusChangedListener.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$RNVLoadControl.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$RNVLoadControl.class
|
||||
new file mode 100644
|
||||
index 0000000..22d8796
|
||||
index 0000000..077afc1
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$RNVLoadControl.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView.class
|
||||
new file mode 100644
|
||||
index 0000000..53eee7f
|
||||
index 0000000..4d14dd4
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/react/BuildConfig.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/react/BuildConfig.class
|
||||
new file mode 100644
|
||||
|
|
@ -414,10 +447,6 @@ index 0000000..2300097
|
|||
+5 <uses-sdk android:minSdkVersion="24" />
|
||||
+6
|
||||
+7</manifest>
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/merged_java_res/debug/mergeDebugJavaResource/feature-react-native-video.jar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/merged_java_res/debug/mergeDebugJavaResource/feature-react-native-video.jar
|
||||
new file mode 100644
|
||||
index 0000000..87d384d
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/merged_java_res/debug/mergeDebugJavaResource/feature-react-native-video.jar differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml
|
||||
new file mode 100644
|
||||
index 0000000..728c5a9
|
||||
|
|
@ -628,7 +657,7 @@ index 0000000..e8dd9e4
|
|||
\ No newline at end of file
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar
|
||||
new file mode 100644
|
||||
index 0000000..e65b062
|
||||
index 0000000..1f68b89
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt
|
||||
new file mode 100644
|
||||
|
|
@ -683,7 +712,7 @@ index 0000000..1cb95ed
|
|||
+style ExoMediaButton_FullScreen
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab
|
||||
new file mode 100644
|
||||
index 0000000..7df5bcf
|
||||
index 0000000..6eb806a
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream
|
||||
new file mode 100644
|
||||
|
|
@ -699,7 +728,7 @@ index 0000000..41d6c24
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..d0eda54
|
||||
index 0000000..c7f2806
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i
|
||||
new file mode 100644
|
||||
|
|
@ -711,7 +740,7 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab
|
||||
new file mode 100644
|
||||
index 0000000..532458c
|
||||
index 0000000..d038dce
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream
|
||||
new file mode 100644
|
||||
|
|
@ -727,7 +756,7 @@ index 0000000..bba171d
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..5d6b64a
|
||||
index 0000000..9a58d92
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i
|
||||
new file mode 100644
|
||||
|
|
@ -739,7 +768,7 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab
|
||||
new file mode 100644
|
||||
index 0000000..8178530
|
||||
index 0000000..e70d24d
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream
|
||||
new file mode 100644
|
||||
|
|
@ -755,7 +784,7 @@ index 0000000..bba171d
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..6a45a65
|
||||
index 0000000..23ddf3d
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i
|
||||
new file mode 100644
|
||||
|
|
@ -795,7 +824,7 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab
|
||||
new file mode 100644
|
||||
index 0000000..c3975e3
|
||||
index 0000000..daeef6b
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream
|
||||
new file mode 100644
|
||||
|
|
@ -811,7 +840,7 @@ index 0000000..b7d7395
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..e3911a1
|
||||
index 0000000..3412003
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i
|
||||
new file mode 100644
|
||||
|
|
@ -851,7 +880,7 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab
|
||||
new file mode 100644
|
||||
index 0000000..c626f49
|
||||
index 0000000..409b10e
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream
|
||||
new file mode 100644
|
||||
|
|
@ -867,19 +896,19 @@ index 0000000..882f24f
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values
|
||||
new file mode 100644
|
||||
index 0000000..2ff3eb7
|
||||
index 0000000..8cabf48
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..af661f7
|
||||
index 0000000..6e6f502
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.s b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.s
|
||||
new file mode 100644
|
||||
index 0000000..b6551b9
|
||||
index 0000000..67a228d
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.s
|
||||
@@ -0,0 +1 @@
|
||||
+ÏÒ
|
||||
+ÏÒñÐãÊÃÊ
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i
|
||||
new file mode 100644
|
||||
|
|
@ -891,7 +920,7 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab
|
||||
new file mode 100644
|
||||
index 0000000..85d9216
|
||||
index 0000000..1298c8c
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream
|
||||
new file mode 100644
|
||||
|
|
@ -907,7 +936,7 @@ index 0000000..41d6c24
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..dfc60fb
|
||||
index 0000000..7c991ce
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i
|
||||
new file mode 100644
|
||||
|
|
@ -919,7 +948,7 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab
|
||||
new file mode 100644
|
||||
index 0000000..95c2c01
|
||||
index 0000000..5a73f92
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream
|
||||
new file mode 100644
|
||||
|
|
@ -935,7 +964,7 @@ index 0000000..385642d
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..195d865
|
||||
index 0000000..119e277
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i
|
||||
new file mode 100644
|
||||
|
|
@ -947,7 +976,7 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab
|
||||
new file mode 100644
|
||||
index 0000000..d98dace
|
||||
index 0000000..52ad30e
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream
|
||||
new file mode 100644
|
||||
|
|
@ -963,7 +992,7 @@ index 0000000..42df8b9
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..bc345c6
|
||||
index 0000000..8157519
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i
|
||||
new file mode 100644
|
||||
|
|
@ -975,16 +1004,16 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab
|
||||
new file mode 100644
|
||||
index 0000000..672070d
|
||||
index 0000000..1c94f4d
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab
|
||||
@@ -0,0 +1,2 @@
|
||||
+44
|
||||
+49
|
||||
+0
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab
|
||||
new file mode 100644
|
||||
index 0000000..1878d5a
|
||||
index 0000000..b2fe254
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream
|
||||
new file mode 100644
|
||||
|
|
@ -1000,7 +1029,7 @@ index 0000000..41d6c24
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..d3fcc7e
|
||||
index 0000000..d69b273
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i
|
||||
new file mode 100644
|
||||
|
|
@ -1012,27 +1041,27 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab
|
||||
new file mode 100644
|
||||
index 0000000..04aeda4
|
||||
index 0000000..bfd339b
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream
|
||||
new file mode 100644
|
||||
index 0000000..132a271
|
||||
index 0000000..5ecc69a
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len
|
||||
new file mode 100644
|
||||
index 0000000..79ad34c
|
||||
index 0000000..1c209ae
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len
|
||||
new file mode 100644
|
||||
index 0000000..41d6c24
|
||||
index 0000000..31ed51e
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..c834290
|
||||
index 0000000..17f0ceb
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i
|
||||
new file mode 100644
|
||||
index 0000000..1601c02
|
||||
index 0000000..c49a939
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len
|
||||
new file mode 100644
|
||||
|
|
@ -1040,27 +1069,39 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab
|
||||
new file mode 100644
|
||||
index 0000000..5fc703e
|
||||
index 0000000..cb83174
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream
|
||||
new file mode 100644
|
||||
index 0000000..39f4cac
|
||||
index 0000000..9652ad6
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len
|
||||
new file mode 100644
|
||||
index 0000000..e381b23
|
||||
index 0000000..33b23e4
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len
|
||||
new file mode 100644
|
||||
index 0000000..35ed991
|
||||
index 0000000..47e3ec3
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values
|
||||
new file mode 100644
|
||||
index 0000000..19d0fa5
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at
|
||||
new file mode 100644
|
||||
index 0000000..a262c4a
|
||||
index 0000000..00fa3dd
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.s b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.s
|
||||
new file mode 100644
|
||||
index 0000000..9ae424c
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.s
|
||||
@@ -0,0 +1 @@
|
||||
+ì‡
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i
|
||||
new file mode 100644
|
||||
index 0000000..1bfa622
|
||||
index 0000000..ab80614
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len
|
||||
new file mode 100644
|
||||
|
|
@ -1068,16 +1109,16 @@ index 0000000..131e265
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/last-build.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/last-build.bin
|
||||
new file mode 100644
|
||||
index 0000000..1fe138d
|
||||
index 0000000..dcaf58a
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/last-build.bin differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin
|
||||
new file mode 100644
|
||||
index 0000000..fb7ec91
|
||||
index 0000000..d7d62e0
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/outputs/aar/react-native-video-debug.aar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/outputs/aar/react-native-video-debug.aar
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/local-state/build-history.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/local-state/build-history.bin
|
||||
new file mode 100644
|
||||
index 0000000..a704fe4
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/outputs/aar/react-native-video-debug.aar differ
|
||||
index 0000000..ee3ec97
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/local-state/build-history.bin differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/outputs/logs/manifest-merger-debug-report.txt b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/outputs/logs/manifest-merger-debug-report.txt
|
||||
new file mode 100644
|
||||
index 0000000..a388215
|
||||
|
|
@ -1100,9 +1141,37 @@ index 0000000..a388215
|
|||
+ INJECTED from /Users/nayifnoushad/Documents/Projects/NuvioStreaming/node_modules/react-native-video/android/src/main/AndroidManifestNew.xml
|
||||
+ android:minSdkVersion
|
||||
+ INJECTED from /Users/nayifnoushad/Documents/Projects/NuvioStreaming/node_modules/react-native-video/android/src/main/AndroidManifestNew.xml
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$1.class.uniqueId3 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$1.class.uniqueId3
|
||||
new file mode 100644
|
||||
index 0000000..2a5b980
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$1.class.uniqueId3 differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$2.class.uniqueId5 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$2.class.uniqueId5
|
||||
new file mode 100644
|
||||
index 0000000..750c145
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$2.class.uniqueId5 differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$3.class.uniqueId1 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$3.class.uniqueId1
|
||||
new file mode 100644
|
||||
index 0000000..19b4277
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$3.class.uniqueId1 differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$4.class.uniqueId6 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$4.class.uniqueId6
|
||||
new file mode 100644
|
||||
index 0000000..9f6af89
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$4.class.uniqueId6 differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$OnAudioFocusChangedListener.class.uniqueId2 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$OnAudioFocusChangedListener.class.uniqueId2
|
||||
new file mode 100644
|
||||
index 0000000..99b4560
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$OnAudioFocusChangedListener.class.uniqueId2 differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$RNVLoadControl.class.uniqueId4 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$RNVLoadControl.class.uniqueId4
|
||||
new file mode 100644
|
||||
index 0000000..077afc1
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$RNVLoadControl.class.uniqueId4 differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView.class.uniqueId0 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView.class.uniqueId0
|
||||
new file mode 100644
|
||||
index 0000000..4d14dd4
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView.class.uniqueId0 differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin
|
||||
new file mode 100644
|
||||
index 0000000..b63c0e3
|
||||
index 0000000..0f0a9cf
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/META-INF/react-native-video_debug.kotlin_module b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/META-INF/react-native-video_debug.kotlin_module
|
||||
new file mode 100644
|
||||
|
|
@ -1214,11 +1283,11 @@ index 0000000..763511c
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/Source.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle$Companion.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle$Companion.class
|
||||
new file mode 100644
|
||||
index 0000000..1ef82f1
|
||||
index 0000000..efa138a
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle$Companion.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle.class
|
||||
new file mode 100644
|
||||
index 0000000..4ebb38c
|
||||
index 0000000..c328ffb
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/TimedMetadata.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/TimedMetadata.class
|
||||
new file mode 100644
|
||||
|
|
@ -1314,15 +1383,15 @@ index 0000000..7d79368
|
|||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/DefaultReactExoplayerConfig.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$Companion.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$Companion.class
|
||||
new file mode 100644
|
||||
index 0000000..345dd50
|
||||
index 0000000..7c35b62
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$Companion.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$playerListener$1.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$playerListener$1.class
|
||||
new file mode 100644
|
||||
index 0000000..4dcc80e
|
||||
index 0000000..055451a
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$playerListener$1.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView.class
|
||||
new file mode 100644
|
||||
index 0000000..df0ace7
|
||||
index 0000000..be801e5
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView.class differ
|
||||
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/FullScreenPlayerView$KeepScreenOnUpdater$Companion.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/FullScreenPlayerView$KeepScreenOnUpdater$Companion.class
|
||||
new file mode 100644
|
||||
|
|
@ -1452,11 +1521,377 @@ diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda
|
|||
new file mode 100644
|
||||
index 0000000..0143f64
|
||||
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/receiver/PictureInPictureReceiver.class differ
|
||||
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
|
||||
index 1ac0fd0..953eb59 100644
|
||||
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
|
||||
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.brentvatne.common.api
|
||||
|
||||
+import android.graphics.Color
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
|
||||
@@ -22,6 +23,17 @@ class SubtitleStyle public constructor() {
|
||||
var subtitlesFollowVideo = true
|
||||
private set
|
||||
|
||||
+ // Extended styling (used by ExoPlayerView via Media3 SubtitleView)
|
||||
+ // Stored as Android color ints to avoid parsing multiple times.
|
||||
+ var textColor: Int? = null
|
||||
+ private set
|
||||
+ var backgroundColor: Int? = null
|
||||
+ private set
|
||||
+ var edgeType: String? = null
|
||||
+ private set
|
||||
+ var edgeColor: Int? = null
|
||||
+ private set
|
||||
+
|
||||
companion object {
|
||||
private const val PROP_FONT_SIZE_TRACK = "fontSize"
|
||||
private const val PROP_PADDING_BOTTOM = "paddingBottom"
|
||||
@@ -31,6 +43,21 @@ class SubtitleStyle public constructor() {
|
||||
private const val PROP_OPACITY = "opacity"
|
||||
private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo"
|
||||
|
||||
+ // Extended props (optional)
|
||||
+ private const val PROP_TEXT_COLOR = "textColor"
|
||||
+ private const val PROP_BACKGROUND_COLOR = "backgroundColor"
|
||||
+ private const val PROP_EDGE_TYPE = "edgeType"
|
||||
+ private const val PROP_EDGE_COLOR = "edgeColor"
|
||||
+
|
||||
+ private fun parseColorOrNull(value: String?): Int? {
|
||||
+ if (value.isNullOrBlank()) return null
|
||||
+ return try {
|
||||
+ Color.parseColor(value)
|
||||
+ } catch (_: IllegalArgumentException) {
|
||||
+ null
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): SubtitleStyle {
|
||||
val subtitleStyle = SubtitleStyle()
|
||||
@@ -41,6 +68,13 @@ class SubtitleStyle public constructor() {
|
||||
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
|
||||
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
|
||||
subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true)
|
||||
+
|
||||
+ // Extended styling
|
||||
+ subtitleStyle.textColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_TEXT_COLOR, null))
|
||||
+ subtitleStyle.backgroundColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_BACKGROUND_COLOR, null))
|
||||
+ subtitleStyle.edgeType = ReactBridgeUtils.safeGetString(src, PROP_EDGE_TYPE, null)
|
||||
+ subtitleStyle.edgeColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_EDGE_COLOR, null))
|
||||
+
|
||||
return subtitleStyle
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
|
||||
index bb945fe..2d3f8ca 100644
|
||||
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
|
||||
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
|
||||
@@ -10,11 +10,14 @@ import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
+import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
+import androidx.media3.ui.CaptionStyleCompat
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.PlayerView
|
||||
+import androidx.media3.ui.SubtitleView
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import com.brentvatne.common.api.SubtitleStyle
|
||||
|
||||
@@ -52,15 +55,58 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
|
||||
+ /**
|
||||
+ * Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame).
|
||||
+ * This keeps subtitles anchored in-place even when the video surface/content frame moves
|
||||
+ * due to aspect ratio / resizeMode changes.
|
||||
+ *
|
||||
+ * Controlled by SubtitleStyle.subtitlesFollowVideo.
|
||||
+ */
|
||||
+ private val overlaySubtitleView = SubtitleView(context).apply {
|
||||
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
+ visibility = View.GONE
|
||||
+ // We control styling via SubtitleStyle; don't pull Android system caption defaults.
|
||||
+ setApplyEmbeddedStyles(true)
|
||||
+ setApplyEmbeddedFontSizes(true)
|
||||
+ }
|
||||
+
|
||||
+ private fun updateSubtitleRenderingMode() {
|
||||
+ val internalSubtitleView = playerView.subtitleView
|
||||
+ val followVideo = localStyle.subtitlesFollowVideo
|
||||
+ val shouldShow = localStyle.opacity != 0.0f
|
||||
+
|
||||
+ if (followVideo) {
|
||||
+ internalSubtitleView?.visibility = if (shouldShow) View.VISIBLE else View.GONE
|
||||
+ overlaySubtitleView.visibility = View.GONE
|
||||
+ } else {
|
||||
+ // Hard-disable PlayerView's internal subtitle view. PlayerView can recreate/toggle this view
|
||||
+ // during resize/layout, so we re-assert this in multiple lifecycle points.
|
||||
+ internalSubtitleView?.visibility = View.GONE
|
||||
+ internalSubtitleView?.alpha = 0f
|
||||
+ overlaySubtitleView.visibility = if (shouldShow) View.VISIBLE else View.GONE
|
||||
+ overlaySubtitleView.alpha = 1f
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
init {
|
||||
// Add PlayerView with explicit layout parameters
|
||||
val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(playerView, playerViewLayoutParams)
|
||||
|
||||
+ // Add overlay subtitles above PlayerView (so it doesn't move with video content frame)
|
||||
+ val subtitleOverlayLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
+ addView(overlaySubtitleView, subtitleOverlayLayoutParams)
|
||||
+
|
||||
// Add live badge with its own layout parameters
|
||||
val liveBadgeLayoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||
liveBadgeLayoutParams.setMargins(16, 16, 16, 16)
|
||||
addView(liveBadge, liveBadgeLayoutParams)
|
||||
+
|
||||
+ // PlayerView may internally recreate its subtitle view during relayouts (e.g. resizeMode changes).
|
||||
+ // Ensure our rendering mode is re-applied whenever PlayerView lays out.
|
||||
+ playerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
+ updateSubtitleRenderingMode()
|
||||
+ }
|
||||
}
|
||||
|
||||
fun setPlayer(player: ExoPlayer?) {
|
||||
@@ -80,6 +126,10 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
}
|
||||
+
|
||||
+ // Re-assert subtitle rendering mode for the current style.
|
||||
+ updateSubtitleRenderingMode()
|
||||
+ applySubtitleStyle(localStyle)
|
||||
}
|
||||
|
||||
fun getPlayerView(): PlayerView = playerView
|
||||
@@ -108,23 +158,63 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
+ localStyle = style
|
||||
+ applySubtitleStyle(localStyle)
|
||||
+ }
|
||||
+
|
||||
+ private fun applySubtitleStyle(style: SubtitleStyle) {
|
||||
+ updateSubtitleRenderingMode()
|
||||
+
|
||||
playerView.subtitleView?.let { subtitleView ->
|
||||
- // Reset to defaults
|
||||
- subtitleView.setUserDefaultStyle()
|
||||
- subtitleView.setUserDefaultTextSize()
|
||||
+ // Important:
|
||||
+ // Avoid inheriting Android system caption settings via setUserDefaultStyle(),
|
||||
+ // because those can force a background/window that the app doesn't want.
|
||||
+ val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
|
||||
+ val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
|
||||
+ val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
|
||||
+
|
||||
+ val resolvedEdgeType = when (style.edgeType?.lowercase()) {
|
||||
+ "outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
+ "shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||
+ else -> CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
+ }
|
||||
+
|
||||
+ // windowColor MUST be transparent to avoid the "caption window" background.
|
||||
+ val captionStyle = CaptionStyleCompat(
|
||||
+ resolvedTextColor,
|
||||
+ resolvedBackgroundColor,
|
||||
+ Color.TRANSPARENT,
|
||||
+ resolvedEdgeType,
|
||||
+ resolvedEdgeColor,
|
||||
+ null
|
||||
+ )
|
||||
+ subtitleView.setStyle(captionStyle)
|
||||
|
||||
- // Apply custom styling
|
||||
+ // Text size: if not provided, fall back to user default size.
|
||||
if (style.fontSize > 0) {
|
||||
- subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
|
||||
+ // Use DIP so the value matches React Native's dp-based fontSize more closely.
|
||||
+ // SP would multiply by system fontScale and makes "30" look larger than RN "30".
|
||||
+ subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
|
||||
+ } else {
|
||||
+ subtitleView.setUserDefaultTextSize()
|
||||
}
|
||||
|
||||
+ // Horizontal padding is still useful (safe area); vertical offset is handled via bottomPaddingFraction.
|
||||
subtitleView.setPadding(
|
||||
style.paddingLeft,
|
||||
style.paddingTop,
|
||||
style.paddingRight,
|
||||
- style.paddingBottom
|
||||
+ 0
|
||||
)
|
||||
|
||||
+ // Bottom offset for *internal* subtitles:
|
||||
+ // Use Media3 SubtitleView's bottomPaddingFraction (moves cues up) rather than raw view padding.
|
||||
+ if (style.paddingBottom > 0 && playerView.height > 0) {
|
||||
+ val fraction = (style.paddingBottom.toFloat() / playerView.height.toFloat())
|
||||
+ .coerceIn(0f, 0.9f)
|
||||
+ subtitleView.setBottomPaddingFraction(fraction)
|
||||
+ }
|
||||
+
|
||||
if (style.opacity != 0.0f) {
|
||||
subtitleView.alpha = style.opacity
|
||||
subtitleView.visibility = android.view.View.VISIBLE
|
||||
@@ -132,7 +222,59 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||
subtitleView.visibility = android.view.View.GONE
|
||||
}
|
||||
}
|
||||
- localStyle = style
|
||||
+
|
||||
+ // Apply the same styling to the overlay subtitle view.
|
||||
+ run {
|
||||
+ val subtitleView = overlaySubtitleView
|
||||
+
|
||||
+ val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
|
||||
+ val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
|
||||
+ val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
|
||||
+
|
||||
+ val resolvedEdgeType = when (style.edgeType?.lowercase()) {
|
||||
+ "outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
+ "shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||
+ else -> CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
+ }
|
||||
+
|
||||
+ val captionStyle = CaptionStyleCompat(
|
||||
+ resolvedTextColor,
|
||||
+ resolvedBackgroundColor,
|
||||
+ Color.TRANSPARENT,
|
||||
+ resolvedEdgeType,
|
||||
+ resolvedEdgeColor,
|
||||
+ null
|
||||
+ )
|
||||
+ subtitleView.setStyle(captionStyle)
|
||||
+
|
||||
+ if (style.fontSize > 0) {
|
||||
+ // Use DIP so the value matches React Native's dp-based fontSize more closely.
|
||||
+ subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
|
||||
+ } else {
|
||||
+ subtitleView.setUserDefaultTextSize()
|
||||
+ }
|
||||
+
|
||||
+ subtitleView.setPadding(
|
||||
+ style.paddingLeft,
|
||||
+ style.paddingTop,
|
||||
+ style.paddingRight,
|
||||
+ 0
|
||||
+ )
|
||||
+
|
||||
+ // Bottom offset relative to the full view height (stable even when video content frame moves).
|
||||
+ val h = height.takeIf { it > 0 } ?: subtitleView.height
|
||||
+ if (style.paddingBottom > 0 && h > 0) {
|
||||
+ val fraction = (style.paddingBottom.toFloat() / h.toFloat())
|
||||
+ .coerceIn(0f, 0.9f)
|
||||
+ subtitleView.setBottomPaddingFraction(fraction)
|
||||
+ } else {
|
||||
+ subtitleView.setBottomPaddingFraction(0f)
|
||||
+ }
|
||||
+
|
||||
+ if (style.opacity != 0.0f) {
|
||||
+ subtitleView.alpha = style.opacity
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
|
||||
fun setShutterColor(color: Int) {
|
||||
@@ -223,6 +365,13 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||
}
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
+ override fun onCues(cueGroup: CueGroup) {
|
||||
+ // Keep overlay subtitles in sync. This does NOT interfere with PlayerView's own subtitle rendering.
|
||||
+ // When subtitlesFollowVideo=false, overlaySubtitleView is the visible one.
|
||||
+ updateSubtitleRenderingMode()
|
||||
+ overlaySubtitleView.setCues(cueGroup.cues)
|
||||
+ }
|
||||
+
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
playerView.post {
|
||||
playerView.requestLayout()
|
||||
@@ -284,6 +433,9 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
+ // Re-apply bottomPaddingFraction once we have a concrete height.
|
||||
+ updateSubtitleRenderingMode()
|
||||
+ applySubtitleStyle(localStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
|
||||
index f175dec..87e436a 100644
|
||||
index 539ecfd..54312f7 100644
|
||||
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
|
||||
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
|
||||
@@ -726,7 +726,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
@@ -161,10 +161,11 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
AdEvent.AdEventListener,
|
||||
AdErrorEvent.AdErrorListener {
|
||||
|
||||
- public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 1;
|
||||
+ public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 0.5;
|
||||
public static final double DEFAULT_MIN_BUFFER_MEMORY_RESERVE = 0;
|
||||
|
||||
private static final String TAG = "ReactExoplayerView";
|
||||
+ private static final ExecutorService SHARED_EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
|
||||
private static final CookieManager DEFAULT_COOKIE_MANAGER;
|
||||
private static final int SHOW_PROGRESS = 1;
|
||||
@@ -211,6 +212,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
private float audioVolume = 1f;
|
||||
private int maxBitRate = 0;
|
||||
private boolean hasDrmFailed = false;
|
||||
+ private int drmRetryCount = 0;
|
||||
private boolean isUsingContentResolution = false;
|
||||
private boolean selectTrackWhenReady = false;
|
||||
private final Handler mainHandler;
|
||||
@@ -243,7 +245,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
private BufferingStrategy.BufferingStrategyEnum bufferingStrategy;
|
||||
private boolean disableDisconnectError;
|
||||
private boolean preventsDisplaySleepDuringVideoPlayback = true;
|
||||
- private float mProgressUpdateInterval = 250.0f;
|
||||
+ private float mProgressUpdateInterval = 1000.0f;
|
||||
protected boolean playInBackground = false;
|
||||
private boolean mReportBandwidth = false;
|
||||
private boolean controls = false;
|
||||
@@ -643,9 +645,12 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave);
|
||||
}
|
||||
if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) {
|
||||
+ long requestedCacheSize = source.getBufferConfig().getCacheSize();
|
||||
+ long MAX_SAFE_CACHE_SIZE = 100L * 1024 * 1024;
|
||||
+ long effectiveCacheSize = Math.min(requestedCacheSize, MAX_SAFE_CACHE_SIZE);
|
||||
RNVSimpleCache.INSTANCE.setSimpleCache(
|
||||
this.getContext(),
|
||||
- source.getBufferConfig().getCacheSize()
|
||||
+ (int) effectiveCacheSize
|
||||
);
|
||||
useCache = true;
|
||||
} else {
|
||||
@@ -654,9 +659,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
if (playerNeedsSource) {
|
||||
// Will force display of shutter view if needed
|
||||
exoPlayerView.invalidateAspectRatio();
|
||||
+ drmRetryCount = 0;
|
||||
+ hasDrmFailed = false;
|
||||
// DRM session manager creation must be done on a different thread to prevent crashes so we start a new thread
|
||||
- ExecutorService es = Executors.newSingleThreadExecutor();
|
||||
- es.execute(() -> {
|
||||
+ SHARED_EXECUTOR.execute(() -> {
|
||||
// DRM initialization must run on a different thread
|
||||
if (viewHasDropped && runningSource == source) {
|
||||
return;
|
||||
@@ -727,7 +733,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
|
||||
DefaultRenderersFactory renderersFactory =
|
||||
new DefaultRenderersFactory(getContext())
|
||||
|
|
@ -1465,3 +1900,110 @@ index f175dec..87e436a 100644
|
|||
.setEnableDecoderFallback(true)
|
||||
.forceEnableMediaCodecAsynchronousQueueing();
|
||||
|
||||
@@ -851,13 +857,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
MediaSource mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, videoSource);
|
||||
|
||||
// wait for player to be set
|
||||
- while (player == null) {
|
||||
- try {
|
||||
- wait();
|
||||
- } catch (InterruptedException ex) {
|
||||
- Thread.currentThread().interrupt();
|
||||
- DebugLog.e(TAG, ex.toString());
|
||||
- }
|
||||
+ if (player == null) {
|
||||
+ DebugLog.w(TAG, "Player not ready yet, aborting source initialization");
|
||||
+ playerNeedsSource = true;
|
||||
+ return;
|
||||
}
|
||||
|
||||
boolean haveResumePosition = resumeWindow != C.INDEX_UNSET;
|
||||
@@ -1484,8 +1487,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
ArrayList<Track> textTracks = getTextTrackInfo();
|
||||
|
||||
if (source.getContentStartTime() != -1) {
|
||||
- ExecutorService es = Executors.newSingleThreadExecutor();
|
||||
- es.execute(() -> {
|
||||
+ SHARED_EXECUTOR.execute(() -> {
|
||||
// To prevent ANRs caused by getVideoTrackInfo we run this on a different thread and notify the player only when we're done
|
||||
ArrayList<VideoTrack> videoTracks = getVideoTrackInfoFromManifest();
|
||||
if (videoTracks != null) {
|
||||
@@ -1596,12 +1598,11 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
// We need retry count to in case where minefest request fails from poor network conditions
|
||||
@WorkerThread
|
||||
private ArrayList<VideoTrack> getVideoTrackInfoFromManifest(int retryCount) {
|
||||
- ExecutorService es = Executors.newSingleThreadExecutor();
|
||||
final DataSource dataSource = this.mediaDataSourceFactory.createDataSource();
|
||||
final Uri sourceUri = source.getUri();
|
||||
final long startTime = source.getContentStartTime() * 1000 - 100; // s -> ms with 100ms offset
|
||||
|
||||
- Future<ArrayList<VideoTrack>> result = es.submit(new Callable() {
|
||||
+ Future<ArrayList<VideoTrack>> result = SHARED_EXECUTOR.submit(new Callable<ArrayList<VideoTrack>>() {
|
||||
final DataSource ds = dataSource;
|
||||
final Uri uri = sourceUri;
|
||||
final long startTimeUs = startTime * 1000; // ms -> us
|
||||
@@ -1648,7 +1649,6 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
if (results == null && retryCount < 1) {
|
||||
return this.getVideoTrackInfoFromManifest(++retryCount);
|
||||
}
|
||||
- es.shutdown();
|
||||
return results;
|
||||
} catch (Exception e) {
|
||||
DebugLog.w(TAG, "error in getVideoTrackInfoFromManifest handling request:" + e.getMessage());
|
||||
@@ -1939,12 +1939,15 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
case PlaybackException.ERROR_CODE_DRM_UNSPECIFIED:
|
||||
if (!hasDrmFailed) {
|
||||
// When DRM fails to reach the app level certificate server it will fail with a source error so we assume that it is DRM related and try one more time
|
||||
- hasDrmFailed = true;
|
||||
- playerNeedsSource = true;
|
||||
- updateResumePosition();
|
||||
- initializePlayer();
|
||||
- setPlayWhenReady(true);
|
||||
- return;
|
||||
+ if (drmRetryCount < 1) {
|
||||
+ drmRetryCount++;
|
||||
+ hasDrmFailed = true;
|
||||
+ playerNeedsSource = true;
|
||||
+ updateResumePosition();
|
||||
+ initializePlayer();
|
||||
+ setPlayWhenReady(true);
|
||||
+ return;
|
||||
+ }
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -2123,6 +2126,16 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
if (textRendererIndex != C.INDEX_UNSET) {
|
||||
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
|
||||
boolean trackFound = false;
|
||||
+ // NOTE:
|
||||
+ // RNVideo emits textTracks as a flattened list (Track.index is assigned using textTracks.size()).
|
||||
+ // However, previous logic compared the requested "index" against the *trackIndex within a group*,
|
||||
+ // which makes any index > 0 either select the wrong subtitle or keep the first one.
|
||||
+ // Here we interpret type="index" as the flattened index, matching the JS list order.
|
||||
+ int targetFlatIndex = -1;
|
||||
+ if ("index".equals(type)) {
|
||||
+ targetFlatIndex = ReactBridgeUtils.safeParseInt(value, -1);
|
||||
+ }
|
||||
+ int flatIndex = 0;
|
||||
|
||||
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
||||
TrackGroup group = groups.get(groupIndex);
|
||||
@@ -2135,8 +2148,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
} else if ("title".equals(type) && format.label != null && format.label.equals(value)) {
|
||||
isMatch = true;
|
||||
} else if ("index".equals(type)) {
|
||||
- int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
|
||||
- if (targetIndex == trackIndex) {
|
||||
+ if (targetFlatIndex != -1 && targetFlatIndex == flatIndex) {
|
||||
isMatch = true;
|
||||
}
|
||||
}
|
||||
@@ -2148,6 +2160,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
trackFound = true;
|
||||
break;
|
||||
}
|
||||
+ flatIndex++;
|
||||
}
|
||||
if (trackFound) break;
|
||||
}
|
||||
427
plugins/mpv-bridge/android/mpv/MPVView.kt
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
package com.nuvio.app.mpv
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.TextureView
|
||||
import dev.jdtech.mpv.MPVLib
|
||||
|
||||
class MPVView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener, MPVLib.EventObserver {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MPVView"
|
||||
}
|
||||
|
||||
private var isMpvInitialized = false
|
||||
private var pendingDataSource: String? = null
|
||||
private var isPaused: Boolean = true
|
||||
private var surface: Surface? = null
|
||||
private var httpHeaders: Map<String, String>? = null
|
||||
|
||||
// Event listener for React Native
|
||||
var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null
|
||||
var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null
|
||||
var onEndCallback: (() -> Unit)? = null
|
||||
var onErrorCallback: ((message: String) -> Unit)? = null
|
||||
var onTracksChangedCallback: ((audioTracks: List<Map<String, Any>>, subtitleTracks: List<Map<String, Any>>) -> Unit)? = null
|
||||
|
||||
init {
|
||||
surfaceTextureListener = this
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
Log.d(TAG, "Surface texture available: ${width}x${height}")
|
||||
try {
|
||||
surface = Surface(surfaceTexture)
|
||||
|
||||
MPVLib.create(context.applicationContext)
|
||||
initOptions()
|
||||
MPVLib.init()
|
||||
MPVLib.attachSurface(surface!!)
|
||||
MPVLib.addObserver(this)
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
|
||||
observeProperties()
|
||||
isMpvInitialized = true
|
||||
|
||||
// If a data source was set before surface was ready, load it now
|
||||
pendingDataSource?.let { url ->
|
||||
applyHttpHeaders()
|
||||
loadFile(url)
|
||||
pendingDataSource = null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize MPV", e)
|
||||
onErrorCallback?.invoke("MPV initialization failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
Log.d(TAG, "Surface texture size changed: ${width}x${height}")
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
Log.d(TAG, "Surface texture destroyed")
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.removeObserver(this)
|
||||
MPVLib.detachSurface()
|
||||
MPVLib.destroy()
|
||||
isMpvInitialized = false
|
||||
}
|
||||
surface?.release()
|
||||
surface = null
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||
// Called when the SurfaceTexture is updated via updateTexImage()
|
||||
}
|
||||
|
||||
private fun initOptions() {
|
||||
// Mobile-optimized profile
|
||||
MPVLib.setOptionString("profile", "fast")
|
||||
MPVLib.setOptionString("vo", "gpu")
|
||||
MPVLib.setOptionString("gpu-context", "android")
|
||||
MPVLib.setOptionString("opengl-es", "yes")
|
||||
|
||||
// Hardware decoding - use mediacodec-copy to allow subtitle overlay
|
||||
// 'mediacodec-copy' copies frames to CPU memory which enables subtitle blending
|
||||
MPVLib.setOptionString("hwdec", "auto")
|
||||
MPVLib.setOptionString("hwdec-codecs", "all")
|
||||
|
||||
// Audio output
|
||||
MPVLib.setOptionString("ao", "audiotrack,opensles")
|
||||
|
||||
// Network caching for streaming
|
||||
MPVLib.setOptionString("demuxer-max-bytes", "67108864") // 64MB
|
||||
MPVLib.setOptionString("demuxer-max-back-bytes", "33554432") // 32MB
|
||||
MPVLib.setOptionString("cache", "yes")
|
||||
MPVLib.setOptionString("cache-secs", "30")
|
||||
|
||||
// Network options
|
||||
MPVLib.setOptionString("network-timeout", "60") // 60 second timeout
|
||||
|
||||
// Subtitle configuration - CRITICAL for Android
|
||||
MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles
|
||||
MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default
|
||||
MPVLib.setOptionString("sub-font-size", "48") // Larger font size for mobile readability
|
||||
MPVLib.setOptionString("sub-pos", "95") // Position at bottom (0-100, 100 = very bottom)
|
||||
MPVLib.setOptionString("sub-color", "#FFFFFFFF") // White color
|
||||
MPVLib.setOptionString("sub-border-size", "3") // Thicker border for readability
|
||||
MPVLib.setOptionString("sub-border-color", "#FF000000") // Black border
|
||||
MPVLib.setOptionString("sub-shadow-offset", "2") // Add shadow for better visibility
|
||||
MPVLib.setOptionString("sub-shadow-color", "#80000000") // Semi-transparent black shadow
|
||||
|
||||
// Font configuration - point to Android system fonts for all language support
|
||||
MPVLib.setOptionString("osd-fonts-dir", "/system/fonts")
|
||||
MPVLib.setOptionString("sub-fonts-dir", "/system/fonts")
|
||||
MPVLib.setOptionString("sub-font", "Roboto") // Default fallback font
|
||||
// Allow embedded fonts in ASS/SSA but fallback to system fonts
|
||||
MPVLib.setOptionString("embeddedfonts", "yes")
|
||||
|
||||
// Language/encoding support for various subtitle formats
|
||||
MPVLib.setOptionString("sub-codepage", "auto") // Auto-detect encoding (supports UTF-8, Latin, CJK, etc.)
|
||||
|
||||
MPVLib.setOptionString("osc", "no") // Disable on screen controller
|
||||
MPVLib.setOptionString("osd-level", "1")
|
||||
|
||||
// Critical for subtitle rendering on Android GPU
|
||||
// blend-subtitles=no lets the GPU renderer handle subtitle overlay properly
|
||||
MPVLib.setOptionString("blend-subtitles", "no")
|
||||
MPVLib.setOptionString("sub-use-margins", "no")
|
||||
// Use 'scale' to allow ASS styling but with our scale and font overrides
|
||||
// This preserves styled subtitles while having font fallbacks
|
||||
MPVLib.setOptionString("sub-ass-override", "scale")
|
||||
MPVLib.setOptionString("sub-scale", "1.0")
|
||||
MPVLib.setOptionString("sub-fix-timing", "yes") // Fix timing for SRT subtitles
|
||||
|
||||
// Force subtitle rendering
|
||||
MPVLib.setOptionString("sid", "auto") // Auto-select subtitle track
|
||||
|
||||
// Disable terminal/input
|
||||
MPVLib.setOptionString("terminal", "no")
|
||||
MPVLib.setOptionString("input-default-bindings", "no")
|
||||
}
|
||||
|
||||
private fun observeProperties() {
|
||||
// MPV format constants (from MPVLib source)
|
||||
val MPV_FORMAT_NONE = 0
|
||||
val MPV_FORMAT_FLAG = 3
|
||||
val MPV_FORMAT_INT64 = 4
|
||||
val MPV_FORMAT_DOUBLE = 5
|
||||
|
||||
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("duration/full", MPV_FORMAT_DOUBLE) // Use /full for complete HLS duration
|
||||
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("eof-reached", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("video-params/aspect", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("width", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("height", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("track-list", MPV_FORMAT_NONE)
|
||||
|
||||
// Observe subtitle properties for debugging
|
||||
MPVLib.observeProperty("sid", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("sub-visibility", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("sub-text", MPV_FORMAT_NONE)
|
||||
}
|
||||
|
||||
private fun loadFile(url: String) {
|
||||
Log.d(TAG, "Loading file: $url")
|
||||
MPVLib.command(arrayOf("loadfile", url))
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
fun setDataSource(url: String) {
|
||||
if (isMpvInitialized) {
|
||||
// Apply headers before loading the file
|
||||
applyHttpHeaders()
|
||||
loadFile(url)
|
||||
} else {
|
||||
pendingDataSource = url
|
||||
}
|
||||
}
|
||||
|
||||
fun setHeaders(headers: Map<String, String>?) {
|
||||
httpHeaders = headers
|
||||
Log.d(TAG, "Headers set: $headers")
|
||||
}
|
||||
|
||||
private fun applyHttpHeaders() {
|
||||
httpHeaders?.let { headers ->
|
||||
if (headers.isNotEmpty()) {
|
||||
// Format headers for MPV: comma-separated "Key: Value" pairs
|
||||
val headerList = headers.map { (key, value) -> "$key: $value" }
|
||||
val headerString = headerList.joinToString(",")
|
||||
Log.d(TAG, "Applying HTTP headers: $headerString")
|
||||
MPVLib.setOptionString("http-header-fields", headerString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setPaused(paused: Boolean) {
|
||||
isPaused = paused
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.setPropertyBoolean("pause", paused)
|
||||
}
|
||||
}
|
||||
|
||||
fun seekTo(positionSeconds: Double) {
|
||||
Log.d(TAG, "seekTo called: positionSeconds=$positionSeconds, isMpvInitialized=$isMpvInitialized")
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Executing MPV seek command: seek $positionSeconds absolute")
|
||||
MPVLib.command(arrayOf("seek", positionSeconds.toString(), "absolute"))
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.setPropertyDouble("speed", speed)
|
||||
}
|
||||
}
|
||||
|
||||
fun setVolume(volume: Double) {
|
||||
if (isMpvInitialized) {
|
||||
// MPV volume is 0-100
|
||||
MPVLib.setPropertyDouble("volume", volume * 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
if (isMpvInitialized) {
|
||||
if (trackId == -1) {
|
||||
MPVLib.setPropertyString("aid", "no")
|
||||
} else {
|
||||
MPVLib.setPropertyInt("aid", trackId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized")
|
||||
if (isMpvInitialized) {
|
||||
if (trackId == -1) {
|
||||
Log.d(TAG, "Disabling subtitles (sid=no)")
|
||||
MPVLib.setPropertyString("sid", "no")
|
||||
MPVLib.setPropertyString("sub-visibility", "no")
|
||||
} else {
|
||||
Log.d(TAG, "Setting subtitle track to: $trackId")
|
||||
MPVLib.setPropertyInt("sid", trackId)
|
||||
// Ensure subtitles are visible
|
||||
MPVLib.setPropertyString("sub-visibility", "yes")
|
||||
|
||||
// Debug: Verify the subtitle was set correctly
|
||||
val currentSid = MPVLib.getPropertyInt("sid")
|
||||
val subVisibility = MPVLib.getPropertyString("sub-visibility")
|
||||
val subDelay = MPVLib.getPropertyDouble("sub-delay")
|
||||
val subScale = MPVLib.getPropertyDouble("sub-scale")
|
||||
Log.d(TAG, "After setting - sid=$currentSid, sub-visibility=$subVisibility, sub-delay=$subDelay, sub-scale=$subScale")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setResizeMode(mode: String) {
|
||||
Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized")
|
||||
if (isMpvInitialized) {
|
||||
when (mode) {
|
||||
"contain" -> {
|
||||
// Letterbox - show entire video with black bars
|
||||
MPVLib.setPropertyDouble("panscan", 0.0)
|
||||
MPVLib.setPropertyString("keepaspect", "yes")
|
||||
}
|
||||
"cover" -> {
|
||||
// Fill/crop - zoom to fill, cropping edges
|
||||
MPVLib.setPropertyDouble("panscan", 1.0)
|
||||
MPVLib.setPropertyString("keepaspect", "yes")
|
||||
}
|
||||
"stretch" -> {
|
||||
// Stretch - disable aspect ratio
|
||||
MPVLib.setPropertyDouble("panscan", 0.0)
|
||||
MPVLib.setPropertyString("keepaspect", "no")
|
||||
}
|
||||
else -> {
|
||||
// Default to contain
|
||||
MPVLib.setPropertyDouble("panscan", 0.0)
|
||||
MPVLib.setPropertyString("keepaspect", "yes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MPVLib.EventObserver implementation
|
||||
|
||||
override fun eventProperty(property: String) {
|
||||
Log.d(TAG, "Property changed: $property")
|
||||
when (property) {
|
||||
"track-list" -> {
|
||||
// Parse track list and notify React Native
|
||||
parseAndSendTracks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAndSendTracks() {
|
||||
try {
|
||||
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||
Log.d(TAG, "Track count: $trackCount")
|
||||
|
||||
val audioTracks = mutableListOf<Map<String, Any>>()
|
||||
val subtitleTracks = mutableListOf<Map<String, Any>>()
|
||||
|
||||
for (i in 0 until trackCount) {
|
||||
val type = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||
val id = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val title = MPVLib.getPropertyString("track-list/$i/title") ?: ""
|
||||
val lang = MPVLib.getPropertyString("track-list/$i/lang") ?: ""
|
||||
val codec = MPVLib.getPropertyString("track-list/$i/codec") ?: ""
|
||||
|
||||
val trackName = when {
|
||||
title.isNotEmpty() -> title
|
||||
lang.isNotEmpty() -> lang.uppercase()
|
||||
else -> "Track $id"
|
||||
}
|
||||
|
||||
val track = mapOf(
|
||||
"id" to id,
|
||||
"name" to trackName,
|
||||
"language" to lang,
|
||||
"codec" to codec
|
||||
)
|
||||
|
||||
when (type) {
|
||||
"audio" -> {
|
||||
Log.d(TAG, "Found audio track: $track")
|
||||
audioTracks.add(track)
|
||||
}
|
||||
"sub" -> {
|
||||
Log.d(TAG, "Found subtitle track: $track")
|
||||
subtitleTracks.add(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sending tracks - Audio: ${audioTracks.size}, Subtitles: ${subtitleTracks.size}")
|
||||
onTracksChangedCallback?.invoke(audioTracks, subtitleTracks)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing tracks", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Long) {
|
||||
Log.d(TAG, "Property $property = $value (Long)")
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Double) {
|
||||
Log.d(TAG, "Property $property = $value (Double)")
|
||||
when (property) {
|
||||
"time-pos" -> {
|
||||
val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0
|
||||
onProgressCallback?.invoke(value, duration)
|
||||
}
|
||||
"duration/full", "duration" -> {
|
||||
val width = MPVLib.getPropertyInt("width") ?: 0
|
||||
val height = MPVLib.getPropertyInt("height") ?: 0
|
||||
onLoadCallback?.invoke(value, width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) {
|
||||
Log.d(TAG, "Property $property = $value (Boolean)")
|
||||
when (property) {
|
||||
"eof-reached" -> {
|
||||
if (value) {
|
||||
onEndCallback?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: String) {
|
||||
Log.d(TAG, "Property $property = $value (String)")
|
||||
}
|
||||
|
||||
override fun event(eventId: Int) {
|
||||
Log.d(TAG, "Event: $eventId")
|
||||
// MPV event constants (from MPVLib source)
|
||||
val MPV_EVENT_FILE_LOADED = 8
|
||||
val MPV_EVENT_END_FILE = 7
|
||||
|
||||
when (eventId) {
|
||||
MPV_EVENT_FILE_LOADED -> {
|
||||
// File is loaded, start playback if not paused
|
||||
if (!isPaused) {
|
||||
MPVLib.setPropertyBoolean("pause", false)
|
||||
}
|
||||
}
|
||||
MPV_EVENT_END_FILE -> {
|
||||
Log.d(TAG, "MPV_EVENT_END_FILE")
|
||||
|
||||
// Heuristic: If duration is effectively 0 at end of file, it's a load error
|
||||
val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0
|
||||
val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0
|
||||
val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false
|
||||
|
||||
Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached")
|
||||
|
||||
if (duration < 1.0 && !eofReached) {
|
||||
val customError = "Unable to play media. Source may be unreachable."
|
||||
Log.e(TAG, "Playback error detected (heuristic): $customError")
|
||||
onErrorCallback?.invoke(customError)
|
||||
} else {
|
||||
onEndCallback?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
plugins/mpv-bridge/android/mpv/MpvPackage.kt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package com.nuvio.app.mpv
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class MpvPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return listOf(MpvPlayerViewManager(reactContext))
|
||||
}
|
||||
}
|
||||
183
plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package com.nuvio.app.mpv
|
||||
|
||||
import android.graphics.Color
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.common.MapBuilder
|
||||
import com.facebook.react.uimanager.SimpleViewManager
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.uimanager.annotations.ReactProp
|
||||
import com.facebook.react.uimanager.events.RCTEventEmitter
|
||||
|
||||
class MpvPlayerViewManager(
|
||||
private val reactContext: ReactApplicationContext
|
||||
) : SimpleViewManager<MPVView>() {
|
||||
|
||||
companion object {
|
||||
const val REACT_CLASS = "MpvPlayer"
|
||||
|
||||
// Commands
|
||||
const val COMMAND_SEEK = 1
|
||||
const val COMMAND_SET_AUDIO_TRACK = 2
|
||||
const val COMMAND_SET_SUBTITLE_TRACK = 3
|
||||
}
|
||||
|
||||
override fun getName(): String = REACT_CLASS
|
||||
|
||||
override fun createViewInstance(context: ThemedReactContext): MPVView {
|
||||
val view = MPVView(context)
|
||||
// Note: Do NOT set background color - it will block the SurfaceView content
|
||||
|
||||
// Set up event callbacks
|
||||
view.onLoadCallback = { duration, width, height ->
|
||||
val event = Arguments.createMap().apply {
|
||||
putDouble("duration", duration)
|
||||
putInt("width", width)
|
||||
putInt("height", height)
|
||||
}
|
||||
sendEvent(context, view.id, "onLoad", event)
|
||||
}
|
||||
|
||||
view.onProgressCallback = { position, duration ->
|
||||
val event = Arguments.createMap().apply {
|
||||
putDouble("currentTime", position)
|
||||
putDouble("duration", duration)
|
||||
}
|
||||
sendEvent(context, view.id, "onProgress", event)
|
||||
}
|
||||
|
||||
view.onEndCallback = {
|
||||
sendEvent(context, view.id, "onEnd", Arguments.createMap())
|
||||
}
|
||||
|
||||
view.onErrorCallback = { message ->
|
||||
val event = Arguments.createMap().apply {
|
||||
putString("error", message)
|
||||
}
|
||||
sendEvent(context, view.id, "onError", event)
|
||||
}
|
||||
|
||||
view.onTracksChangedCallback = { audioTracks, subtitleTracks ->
|
||||
val event = Arguments.createMap().apply {
|
||||
val audioArray = Arguments.createArray()
|
||||
audioTracks.forEach { track ->
|
||||
val trackMap = Arguments.createMap().apply {
|
||||
putInt("id", track["id"] as Int)
|
||||
putString("name", track["name"] as String)
|
||||
putString("language", track["language"] as String)
|
||||
putString("codec", track["codec"] as String)
|
||||
}
|
||||
audioArray.pushMap(trackMap)
|
||||
}
|
||||
putArray("audioTracks", audioArray)
|
||||
|
||||
val subtitleArray = Arguments.createArray()
|
||||
subtitleTracks.forEach { track ->
|
||||
val trackMap = Arguments.createMap().apply {
|
||||
putInt("id", track["id"] as Int)
|
||||
putString("name", track["name"] as String)
|
||||
putString("language", track["language"] as String)
|
||||
putString("codec", track["codec"] as String)
|
||||
}
|
||||
subtitleArray.pushMap(trackMap)
|
||||
}
|
||||
putArray("subtitleTracks", subtitleArray)
|
||||
}
|
||||
sendEvent(context, view.id, "onTracksChanged", event)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
private fun sendEvent(context: ThemedReactContext, viewId: Int, eventName: String, params: com.facebook.react.bridge.WritableMap) {
|
||||
context.getJSModule(RCTEventEmitter::class.java)
|
||||
.receiveEvent(viewId, eventName, params)
|
||||
}
|
||||
|
||||
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any> {
|
||||
return MapBuilder.builder<String, Any>()
|
||||
.put("onLoad", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onLoad")))
|
||||
.put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress")))
|
||||
.put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd")))
|
||||
.put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError")))
|
||||
.put("onTracksChanged", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onTracksChanged")))
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getCommandsMap(): Map<String, Int> {
|
||||
return MapBuilder.of(
|
||||
"seek", COMMAND_SEEK,
|
||||
"setAudioTrack", COMMAND_SET_AUDIO_TRACK,
|
||||
"setSubtitleTrack", COMMAND_SET_SUBTITLE_TRACK
|
||||
)
|
||||
}
|
||||
|
||||
override fun receiveCommand(view: MPVView, commandId: String?, args: ReadableArray?) {
|
||||
android.util.Log.d("MpvPlayerViewManager", "receiveCommand: $commandId, args: $args")
|
||||
when (commandId) {
|
||||
"seek" -> {
|
||||
val position = args?.getDouble(0)
|
||||
android.util.Log.d("MpvPlayerViewManager", "Seek command received: position=$position")
|
||||
position?.let { view.seekTo(it) }
|
||||
}
|
||||
"setAudioTrack" -> {
|
||||
args?.getInt(0)?.let { view.setAudioTrack(it) }
|
||||
}
|
||||
"setSubtitleTrack" -> {
|
||||
args?.getInt(0)?.let { view.setSubtitleTrack(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// React Props
|
||||
|
||||
@ReactProp(name = "source")
|
||||
fun setSource(view: MPVView, source: String?) {
|
||||
source?.let { view.setDataSource(it) }
|
||||
}
|
||||
|
||||
@ReactProp(name = "paused")
|
||||
fun setPaused(view: MPVView, paused: Boolean) {
|
||||
view.setPaused(paused)
|
||||
}
|
||||
|
||||
@ReactProp(name = "volume", defaultFloat = 1.0f)
|
||||
fun setVolume(view: MPVView, volume: Float) {
|
||||
view.setVolume(volume.toDouble())
|
||||
}
|
||||
|
||||
@ReactProp(name = "rate", defaultFloat = 1.0f)
|
||||
fun setRate(view: MPVView, rate: Float) {
|
||||
view.setSpeed(rate.toDouble())
|
||||
}
|
||||
|
||||
// Handle backgroundColor prop to prevent crash from React Native style system
|
||||
@ReactProp(name = "backgroundColor", customType = "Color")
|
||||
fun setBackgroundColor(view: MPVView, color: Int?) {
|
||||
// Intentionally ignoring - background color would block the TextureView content
|
||||
// Leave the view transparent
|
||||
}
|
||||
|
||||
@ReactProp(name = "resizeMode")
|
||||
fun setResizeMode(view: MPVView, resizeMode: String?) {
|
||||
view.setResizeMode(resizeMode ?: "contain")
|
||||
}
|
||||
|
||||
@ReactProp(name = "headers")
|
||||
fun setHeaders(view: MPVView, headers: com.facebook.react.bridge.ReadableMap?) {
|
||||
if (headers != null) {
|
||||
val headerMap = mutableMapOf<String, String>()
|
||||
val iterator = headers.keySetIterator()
|
||||
while (iterator.hasNextKey()) {
|
||||
val key = iterator.nextKey()
|
||||
headers.getString(key)?.let { value ->
|
||||
headerMap[key] = value
|
||||
}
|
||||
}
|
||||
view.setHeaders(headerMap)
|
||||
} else {
|
||||
view.setHeaders(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB |
1
src/assets/lottie/monito.json
Normal file
|
|
@ -1,10 +0,0 @@
|
|||
<svg width="50" height="14" viewBox="0 0 50 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="49" height="13" rx="1.5" stroke="#575757"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9999 3.5H3V10.5H11.9999V3.5ZM4.49995 9.5C4.32871 9.5 4.16151 9.48267 3.99995 9.44995V4.55005C4.10415 4.52905 4.21069 4.51416 4.31915 4.50635C4.33529 4.50513 4.35146 4.50439 4.36768 4.50366L4.40562 4.50195C4.43691 4.50073 4.46836 4.5 4.49995 4.5C5.88065 4.5 6.99995 5.61938 6.99995 7C6.99995 8.38062 5.88065 9.5 4.49995 9.5ZM10.4999 9.5C10.5348 9.5 10.5695 9.49927 10.6041 9.4978C10.6614 9.49561 10.7183 9.49146 10.7746 9.48535C10.8508 9.47681 10.926 9.46509 10.9999 9.44995V4.55005C10.9079 4.53149 10.814 4.51782 10.7187 4.50952C10.6466 4.50317 10.5736 4.5 10.4999 4.5C9.11924 4.5 7.99995 5.61938 7.99995 7C7.99995 8.38062 9.11924 9.5 10.4999 9.5Z" fill="#CBCBCB"/>
|
||||
<path d="M20.2905 4.092L17.8875 10.356H16.6095L14.2065 4.092H15.5385L17.2755 8.88L19.0035 4.092H20.2905Z" fill="#CBCBCB"/>
|
||||
<path d="M22.6024 10.356H21.3784V4.092H22.6024V10.356Z" fill="#CBCBCB"/>
|
||||
<path d="M26.4969 10.5C25.7649 10.5 25.1679 10.332 24.7059 9.996C24.2499 9.66 23.9529 9.192 23.8149 8.592H25.0389C25.1169 8.844 25.2819 9.048 25.5339 9.204C25.7919 9.354 26.1069 9.429 26.4789 9.429C26.8329 9.429 27.1239 9.363 27.3519 9.231C27.5859 9.093 27.7029 8.895 27.7029 8.637C27.7029 8.487 27.6699 8.358 27.6039 8.25C27.5379 8.136 27.4149 8.04 27.2349 7.962C27.0549 7.878 26.7939 7.809 26.4519 7.755L25.7499 7.638C25.1679 7.542 24.7299 7.356 24.4359 7.08C24.1479 6.804 24.0039 6.408 24.0039 5.892C24.0039 5.496 24.1089 5.154 24.3189 4.866C24.5349 4.572 24.8229 4.347 25.1829 4.191C25.5489 4.029 25.9569 3.948 26.4069 3.948C27.0609 3.948 27.6099 4.104 28.0539 4.416C28.4979 4.722 28.7709 5.172 28.8729 5.766H27.6489C27.5889 5.514 27.4509 5.328 27.2349 5.208C27.0189 5.082 26.7459 5.019 26.4159 5.019C26.0499 5.019 25.7619 5.088 25.5519 5.226C25.3419 5.358 25.2369 5.544 25.2369 5.784C25.2369 6 25.3149 6.168 25.4709 6.288C25.6329 6.408 25.9509 6.507 26.4249 6.585L27.0999 6.693C28.3299 6.891 28.9449 7.5 28.9449 8.52C28.9449 8.94 28.8399 9.3 28.6299 9.6C28.4199 9.894 28.1289 10.119 27.7569 10.275C27.3909 10.425 26.9709 10.5 26.4969 10.5Z" fill="#CBCBCB"/>
|
||||
<path d="M31.4959 10.356H30.2719V4.092H31.4959V10.356Z" fill="#CBCBCB"/>
|
||||
<path d="M36.0024 10.5C35.5404 10.5 35.1144 10.419 34.7244 10.257C34.3404 10.095 34.0074 9.867 33.7254 9.573C33.4434 9.279 33.2244 8.934 33.0684 8.538C32.9124 8.136 32.8344 7.698 32.8344 7.224C32.8344 6.75 32.9124 6.315 33.0684 5.919C33.2244 5.517 33.4434 5.169 33.7254 4.875C34.0074 4.581 34.3404 4.353 34.7244 4.191C35.1144 4.029 35.5404 3.948 36.0024 3.948C36.4644 3.948 36.8874 4.029 37.2714 4.191C37.6614 4.353 37.9974 4.581 38.2794 4.875C38.5674 5.169 38.7894 5.517 38.9454 5.919C39.1014 6.315 39.1794 6.75 39.1794 7.224C39.1794 7.698 39.1014 8.136 38.9454 8.538C38.7894 8.934 38.5674 9.279 38.2794 9.573C37.9974 9.867 37.6614 10.095 37.2714 10.257C36.8874 10.419 36.4644 10.5 36.0024 10.5ZM36.0024 9.402C36.3804 9.402 36.7134 9.312 37.0014 9.132C37.2954 8.946 37.5234 8.691 37.6854 8.367C37.8534 8.037 37.9374 7.656 37.9374 7.224C37.9374 6.792 37.8534 6.414 37.6854 6.09C37.5234 5.76 37.2954 5.505 37.0014 5.325C36.7134 5.139 36.3804 5.046 36.0024 5.046C35.6304 5.046 35.2974 5.139 35.0034 5.325C34.7154 5.505 34.4874 5.76 34.3194 6.09C34.1574 6.414 34.0764 6.792 34.0764 7.224C34.0764 7.656 34.1574 8.037 34.3194 8.367C34.4874 8.691 34.7154 8.946 35.0034 9.132C35.2974 9.312 35.6304 9.402 36.0024 9.402Z" fill="#CBCBCB"/>
|
||||
<path d="M40.519 10.356V4.092H41.707L44.812 8.484V4.092H46V10.356H44.812L41.707 5.964V10.356H40.519Z" fill="#CBCBCB"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
BIN
src/assets/tmdb_logo.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
309
src/components/AnnouncementOverlay.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
interface Announcement {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
interface AnnouncementOverlayProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onActionPress?: () => void;
|
||||
title?: string;
|
||||
announcements: Announcement[];
|
||||
actionButtonText?: string;
|
||||
}
|
||||
|
||||
const AnnouncementOverlay: React.FC<AnnouncementOverlayProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onActionPress,
|
||||
title = "What's New",
|
||||
announcements,
|
||||
actionButtonText = "Got it!",
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
|
||||
const scaleAnim = useRef(new Animated.Value(0.8)).current;
|
||||
const opacityAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
tension: 50,
|
||||
friction: 7,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
scaleAnim.setValue(0.8);
|
||||
opacityAnim.setValue(0);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleClose = () => {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 0.8,
|
||||
tension: 50,
|
||||
friction: 7,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
onClose();
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
if (onActionPress) {
|
||||
handleClose();
|
||||
// Delay navigation slightly to allow animation to complete
|
||||
setTimeout(() => {
|
||||
onActionPress();
|
||||
}, 300);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity: opacityAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={handleClose}
|
||||
>
|
||||
<Feather name="x" size={20} color={colors.white} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: colors.primary + '20' }]}>
|
||||
<Feather name="zap" size={32} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.title, { color: colors.white }]}>{title}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.mediumEmphasis }]}>
|
||||
Exciting updates in this release
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Announcements */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{announcements.map((announcement, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={styles.announcementItem}
|
||||
>
|
||||
<View style={[styles.announcementIcon, { backgroundColor: colors.primary + '15' }]}>
|
||||
<Feather name={announcement.icon as any} size={24} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.announcementContent}>
|
||||
<View style={styles.announcementHeader}>
|
||||
<Text style={[styles.announcementTitle, { color: colors.white }]}>
|
||||
{announcement.title}
|
||||
</Text>
|
||||
{announcement.tag && (
|
||||
<View style={[styles.tag, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.tagText}>{announcement.tag}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.announcementDescription, { color: colors.mediumEmphasis }]}>
|
||||
{announcement.description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Action Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||
onPress={handleAction}
|
||||
>
|
||||
<Text style={styles.buttonText}>{actionButtonText}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
},
|
||||
container: {
|
||||
width: width * 0.9,
|
||||
maxWidth: 500,
|
||||
maxHeight: height * 0.8,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#2a2a2a',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.9,
|
||||
},
|
||||
scrollView: {
|
||||
maxHeight: height * 0.45,
|
||||
marginBottom: 20,
|
||||
},
|
||||
announcementItem: {
|
||||
backgroundColor: '#252525',
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
announcementIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
announcementContent: {
|
||||
flex: 1,
|
||||
},
|
||||
announcementHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
announcementTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
flex: 1,
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
announcementDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
opacity: 0.9,
|
||||
},
|
||||
button: {
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default AnnouncementOverlay;
|
||||
|
|
@ -15,7 +15,7 @@ import Animated, {
|
|||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { Portal, Dialog, Button } from 'react-native-paper';
|
||||
import { Portal } from 'react-native-paper';
|
||||
|
||||
interface CustomAlertProps {
|
||||
visible: boolean;
|
||||
|
|
@ -40,8 +40,8 @@ export const CustomAlert = ({
|
|||
}: CustomAlertProps) => {
|
||||
const opacity = useSharedValue(0);
|
||||
const scale = useSharedValue(0.95);
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
const { currentTheme } = useTheme();
|
||||
// Using hardcoded dark theme values to match SeriesContent modal
|
||||
const themeColors = currentTheme.colors;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -68,10 +68,11 @@ export const CustomAlert = ({
|
|||
const handleActionPress = useCallback((action: { label: string; onPress: () => void; style?: object }) => {
|
||||
try {
|
||||
action.onPress();
|
||||
// Don't auto-close here if the action handles it, or check if we should
|
||||
// Standard behavior is to close
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.warn('[CustomAlert] Error in action handler:', error);
|
||||
// Still close the alert even if action fails
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
|
@ -86,11 +87,12 @@ export const CustomAlert = ({
|
|||
onRequestClose={onClose}
|
||||
statusBarTranslucent={true}
|
||||
hardwareAccelerated={true}
|
||||
supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}
|
||||
>
|
||||
<Animated.View
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ backgroundColor: 'rgba(0,0,0,0.6)' },
|
||||
styles.overlay,
|
||||
{ backgroundColor: 'rgba(0, 0, 0, 0.85)' },
|
||||
overlayStyle
|
||||
]}
|
||||
>
|
||||
|
|
@ -99,23 +101,22 @@ export const CustomAlert = ({
|
|||
<Animated.View style={[
|
||||
styles.alertContainer,
|
||||
alertStyle,
|
||||
{
|
||||
backgroundColor: themeColors.darkBackground,
|
||||
borderColor: themeColors.primary,
|
||||
}
|
||||
]}>
|
||||
{/* Title */}
|
||||
<Text style={[styles.title, { color: themeColors.highEmphasis }]}>
|
||||
<Text style={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* Message */}
|
||||
<Text style={[styles.message, { color: themeColors.mediumEmphasis }]}>
|
||||
<Text style={styles.message}>
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actionsRow}>
|
||||
<View style={[
|
||||
styles.actionsRow,
|
||||
actions.length === 1 && { justifyContent: 'center' }
|
||||
]}>
|
||||
{actions.map((action, idx) => {
|
||||
const isPrimary = idx === actions.length - 1;
|
||||
return (
|
||||
|
|
@ -124,9 +125,10 @@ export const CustomAlert = ({
|
|||
style={[
|
||||
styles.actionButton,
|
||||
isPrimary
|
||||
? { ...styles.primaryButton, backgroundColor: themeColors.primary }
|
||||
? { backgroundColor: themeColors.primary }
|
||||
: styles.secondaryButton,
|
||||
action.style
|
||||
action.style,
|
||||
actions.length === 1 && { minWidth: 120, maxWidth: '100%' }
|
||||
]}
|
||||
onPress={() => handleActionPress(action)}
|
||||
activeOpacity={0.7}
|
||||
|
|
@ -134,8 +136,8 @@ export const CustomAlert = ({
|
|||
<Text style={[
|
||||
styles.actionText,
|
||||
isPrimary
|
||||
? { color: themeColors.white }
|
||||
: { color: themeColors.primary }
|
||||
? { color: '#FFFFFF' }
|
||||
: { color: '#FFFFFF' }
|
||||
]}>
|
||||
{action.label}
|
||||
</Text>
|
||||
|
|
@ -156,6 +158,7 @@ const styles = StyleSheet.create({
|
|||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999,
|
||||
},
|
||||
overlayPressable: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
|
|
@ -164,29 +167,32 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingHorizontal: 20,
|
||||
width: '100%',
|
||||
},
|
||||
alertContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 340,
|
||||
borderRadius: 24,
|
||||
padding: 28,
|
||||
maxWidth: 400,
|
||||
backgroundColor: '#1E1E1E', // Solid opaque dark background
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: '#007AFF', // iOS blue - will be overridden by theme
|
||||
overflow: 'hidden', // Ensure background fills entire card
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
overflow: 'hidden',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.51,
|
||||
shadowRadius: 13.16,
|
||||
},
|
||||
android: {
|
||||
elevation: 12,
|
||||
elevation: 20,
|
||||
},
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
|
|
@ -194,6 +200,7 @@ const styles = StyleSheet.create({
|
|||
letterSpacing: 0.2,
|
||||
},
|
||||
message: {
|
||||
color: '#AAAAAA',
|
||||
fontSize: 15,
|
||||
marginBottom: 24,
|
||||
textAlign: 'center',
|
||||
|
|
@ -208,17 +215,16 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
actionButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 11,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
primaryButton: {
|
||||
// Background color set dynamically via theme
|
||||
flex: 1, // Distribute space
|
||||
maxWidth: 200, // But limit width
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
actionText: {
|
||||
fontSize: 16,
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ const MajorUpdateOverlay: React.FC<Props> = ({ visible, latestTag, releaseNotes,
|
|||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent presentationStyle="overFullScreen">
|
||||
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent presentationStyle="overFullScreen" supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}>
|
||||
<View style={styles.backdrop}>
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.darkBackground, borderColor: currentTheme.colors.elevation3 }]}>
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.darkBackground, borderColor: currentTheme.colors.elevation3 }]}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconCircle, { backgroundColor: `${currentTheme.colors.primary}22` }]}>
|
||||
<MaterialIcons name="new-releases" size={28} color={currentTheme.colors.primary} />
|
||||
|
|
|
|||
|
|
@ -7,34 +7,42 @@ interface SplashScreenProps {
|
|||
}
|
||||
|
||||
const SplashScreen = ({ onFinish }: SplashScreenProps) => {
|
||||
// Animation value for opacity
|
||||
const fadeAnim = new Animated.Value(1);
|
||||
|
||||
// TEMPORARILY DISABLED
|
||||
useEffect(() => {
|
||||
// Wait for a short period then start fade out animation
|
||||
const timer = setTimeout(() => {
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
// Call onFinish when animation completes
|
||||
// Immediately call onFinish to skip splash screen
|
||||
onFinish();
|
||||
});
|
||||
}, 300); // Show splash for 0.8 seconds
|
||||
}, [onFinish]);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [fadeAnim, onFinish]);
|
||||
return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, { opacity: fadeAnim }]}>
|
||||
<Image
|
||||
source={require('../assets/splash-icon-new.png')}
|
||||
style={styles.image}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
// Animation value for opacity
|
||||
// const fadeAnim = new Animated.Value(1);
|
||||
|
||||
// useEffect(() => {
|
||||
// // Wait for a short period then start fade out animation
|
||||
// const timer = setTimeout(() => {
|
||||
// Animated.timing(fadeAnim, {
|
||||
// toValue: 0,
|
||||
// duration: 400,
|
||||
// useNativeDriver: true,
|
||||
// }).start(() => {
|
||||
// // Call onFinish when animation completes
|
||||
// onFinish();
|
||||
// });
|
||||
// }, 300); // Show splash for 0.8 seconds
|
||||
|
||||
// return () => clearTimeout(timer);
|
||||
// }, [fadeAnim, onFinish]);
|
||||
|
||||
// return (
|
||||
// <Animated.View style={[styles.container, { opacity: fadeAnim }]}>
|
||||
// <Image
|
||||
// source={require('../assets/splash-icon-new.png')}
|
||||
// style={styles.image}
|
||||
// resizeMode="contain"
|
||||
// />
|
||||
// </Animated.View>
|
||||
// );
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import React, { memo, useEffect, useState } from 'react';
|
||||
import React, { memo, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { LegendList } from '@legendapp/list';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withDelay,
|
||||
Easing
|
||||
|
|
@ -45,36 +44,36 @@ interface TabletStreamsLayoutProps {
|
|||
metadata?: any;
|
||||
type: string;
|
||||
currentEpisode?: any;
|
||||
|
||||
|
||||
// Movie logo props
|
||||
movieLogoError: boolean;
|
||||
setMovieLogoError: (error: boolean) => void;
|
||||
|
||||
|
||||
// Stream-related props
|
||||
streamsEmpty: boolean;
|
||||
selectedProvider: string;
|
||||
filterItems: Array<{ id: string; name: string; }>;
|
||||
handleProviderChange: (provider: string) => void;
|
||||
activeFetchingScrapers: string[];
|
||||
|
||||
|
||||
// Loading states
|
||||
isAutoplayWaiting: boolean;
|
||||
autoplayTriggered: boolean;
|
||||
showNoSourcesError: boolean;
|
||||
showInitialLoading: boolean;
|
||||
showStillFetching: boolean;
|
||||
|
||||
|
||||
// Stream rendering props
|
||||
sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null>;
|
||||
renderSectionHeader: ({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => React.ReactElement;
|
||||
handleStreamPress: (stream: Stream) => void;
|
||||
openAlert: (title: string, message: string) => void;
|
||||
|
||||
|
||||
// Settings and theme
|
||||
settings: any;
|
||||
currentTheme: any;
|
||||
colors: any;
|
||||
|
||||
|
||||
// Other props
|
||||
navigation: RootStackNavigationProp;
|
||||
insets: any;
|
||||
|
|
@ -123,19 +122,19 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
hasStremioStreamProviders,
|
||||
}) => {
|
||||
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
|
||||
// Animation values for backdrop entrance
|
||||
const backdropOpacity = useSharedValue(0);
|
||||
const backdropScale = useSharedValue(1.05);
|
||||
const [backdropLoaded, setBackdropLoaded] = useState(false);
|
||||
const [backdropError, setBackdropError] = useState(false);
|
||||
|
||||
|
||||
// Animation values for content panels
|
||||
const leftPanelOpacity = useSharedValue(0);
|
||||
const leftPanelTranslateX = useSharedValue(-30);
|
||||
const rightPanelOpacity = useSharedValue(0);
|
||||
const rightPanelTranslateX = useSharedValue(30);
|
||||
|
||||
|
||||
// Get the backdrop source - prioritize episode thumbnail, then show backdrop, then poster
|
||||
// For episodes without thumbnails, use show's backdrop instead of poster
|
||||
const backdropSource = React.useMemo(() => {
|
||||
|
|
@ -149,7 +148,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
backdropError
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// If episodeImage failed to load, skip it and use backdrop
|
||||
if (backdropError && episodeImage && episodeImage !== metadata?.poster) {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Episode thumbnail failed, falling back to backdrop');
|
||||
|
|
@ -158,26 +157,55 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
return { uri: bannerImage };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If episodeImage exists and is not the same as poster, use it (real episode thumbnail)
|
||||
if (episodeImage && episodeImage !== metadata?.poster && !backdropError) {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Using episode thumbnail:', episodeImage);
|
||||
return { uri: episodeImage };
|
||||
}
|
||||
|
||||
|
||||
// If episodeImage is the same as poster (fallback case), prioritize backdrop
|
||||
if (bannerImage) {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop:', bannerImage);
|
||||
return { uri: bannerImage };
|
||||
}
|
||||
|
||||
|
||||
// No fallback to poster images
|
||||
|
||||
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] No backdrop source found');
|
||||
return undefined;
|
||||
}, [episodeImage, bannerImage, metadata?.poster, backdropError]);
|
||||
|
||||
// Animate backdrop when it loads, or animate content immediately if no backdrop
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (backdropSource?.uri && !backdropLoaded && !backdropError) {
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
|
||||
leftPanelOpacity.value = withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
leftPanelTranslateX.value = withTiming(0, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
|
||||
rightPanelOpacity.value = withDelay(200, withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
rightPanelTranslateX.value = withDelay(200, withTiming(0, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [backdropSource?.uri, backdropLoaded, backdropError]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (backdropSource?.uri && backdropLoaded) {
|
||||
// Animate backdrop first
|
||||
|
|
@ -189,7 +217,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
duration: 1000,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
|
||||
|
||||
// Animate content panels with delay after backdrop starts loading
|
||||
leftPanelOpacity.value = withDelay(300, withTiming(1, {
|
||||
duration: 600,
|
||||
|
|
@ -199,7 +227,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
|
||||
|
||||
rightPanelOpacity.value = withDelay(500, withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
|
|
@ -218,7 +246,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
|
||||
|
||||
rightPanelOpacity.value = withDelay(200, withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
|
|
@ -229,7 +257,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
}));
|
||||
}
|
||||
}, [backdropSource?.uri, backdropLoaded, backdropError]);
|
||||
|
||||
|
||||
// Reset animation when episode changes
|
||||
useEffect(() => {
|
||||
backdropOpacity.value = 0;
|
||||
|
|
@ -241,28 +269,28 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
setBackdropLoaded(false);
|
||||
setBackdropError(false);
|
||||
}, [episodeImage]);
|
||||
|
||||
|
||||
// Animated styles for backdrop
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: backdropOpacity.value,
|
||||
transform: [{ scale: backdropScale.value }],
|
||||
}));
|
||||
|
||||
|
||||
// Animated styles for content panels
|
||||
const leftPanelAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: leftPanelOpacity.value,
|
||||
transform: [{ translateX: leftPanelTranslateX.value }],
|
||||
}));
|
||||
|
||||
|
||||
const rightPanelAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: rightPanelOpacity.value,
|
||||
transform: [{ translateX: rightPanelTranslateX.value }],
|
||||
}));
|
||||
|
||||
|
||||
const handleBackdropLoad = () => {
|
||||
setBackdropLoaded(true);
|
||||
};
|
||||
|
||||
|
||||
const handleBackdropError = () => {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Backdrop image failed to load:', backdropSource?.uri);
|
||||
setBackdropError(true);
|
||||
|
|
@ -295,8 +323,8 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>
|
||||
{isAutoplayWaiting ? 'Finding best stream for autoplay...' :
|
||||
showStillFetching ? 'Still fetching streams…' :
|
||||
'Finding available streams...'}
|
||||
showStillFetching ? 'Still fetching streams…' :
|
||||
'Finding available streams...'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
|
@ -310,78 +338,85 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// Flatten sections into a single list with header items
|
||||
type ListItem = { type: 'header'; title: string; addonId: string } | { type: 'stream'; stream: Stream; index: number };
|
||||
|
||||
const flatListData: ListItem[] = [];
|
||||
sections
|
||||
.filter(Boolean)
|
||||
.filter(section => section!.data && section!.data.length > 0)
|
||||
.forEach(section => {
|
||||
flatListData.push({ type: 'header', title: section!.title, addonId: section!.addonId });
|
||||
section!.data.forEach((stream, index) => {
|
||||
flatListData.push({ type: 'stream', stream, index });
|
||||
});
|
||||
});
|
||||
|
||||
const renderItem = ({ item }: { item: ListItem }) => {
|
||||
if (item.type === 'header') {
|
||||
return renderSectionHeader({ section: { title: item.title, addonId: item.addonId } });
|
||||
}
|
||||
|
||||
const stream = item.stream;
|
||||
return (
|
||||
<StreamCard
|
||||
stream={stream}
|
||||
onPress={() => handleStreamPress(stream)}
|
||||
index={item.index}
|
||||
isLoading={false}
|
||||
statusMessage={undefined}
|
||||
theme={currentTheme}
|
||||
showLogos={settings.showScraperLogos}
|
||||
scraperLogo={(stream.addonId && scraperLogos[stream.addonId]) || (stream as any).addon ? scraperLogos[(stream.addonId || (stream as any).addon) as string] || null : null}
|
||||
showAlert={(t: string, m: string) => openAlert(t, m)}
|
||||
parentTitle={metadata?.name}
|
||||
parentType={type as 'movie' | 'series'}
|
||||
parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
|
||||
parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
|
||||
parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
|
||||
parentPosterUrl={episodeImage || metadata?.poster || undefined}
|
||||
providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(stream))}
|
||||
parentId={id}
|
||||
parentImdbId={imdbId || undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const keyExtractor = (item: ListItem, index: number) => {
|
||||
if (item.type === 'header') {
|
||||
return `header-${item.addonId}-${index}`;
|
||||
}
|
||||
if (item.stream && item.stream.url) {
|
||||
return `stream-${item.stream.url}-${index}`;
|
||||
}
|
||||
return `empty-${index}`;
|
||||
};
|
||||
|
||||
const ListFooterComponent = () => {
|
||||
if (!(loadingStreams || loadingEpisodeStreams) || !hasStremioStreamProviders) return null;
|
||||
return (
|
||||
<View style={styles.footerLoading}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.streamsContent}
|
||||
<LegendList
|
||||
data={flatListData}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
contentContainerStyle={[
|
||||
styles.streamsContainer,
|
||||
{ paddingBottom: insets.bottom + 100 }
|
||||
]}
|
||||
style={styles.streamsContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={true}
|
||||
overScrollMode="never"
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
{sections.filter(Boolean).map((section, sectionIndex) => (
|
||||
<View key={section!.addonId || sectionIndex}>
|
||||
{renderSectionHeader({ section: section! })}
|
||||
|
||||
{section!.data && section!.data.length > 0 ? (
|
||||
<FlatList
|
||||
data={section!.data}
|
||||
keyExtractor={(item, index) => {
|
||||
if (item && item.url) {
|
||||
return `${item.url}-${sectionIndex}-${index}`;
|
||||
}
|
||||
return `empty-${sectionIndex}-${index}`;
|
||||
}}
|
||||
renderItem={({ item, index }) => (
|
||||
<View>
|
||||
<StreamCard
|
||||
stream={item}
|
||||
onPress={() => handleStreamPress(item)}
|
||||
index={index}
|
||||
isLoading={false}
|
||||
statusMessage={undefined}
|
||||
theme={currentTheme}
|
||||
showLogos={settings.showScraperLogos}
|
||||
scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogos[(item.addonId || (item as any).addon) as string] || null : null}
|
||||
showAlert={(t: string, m: string) => openAlert(t, m)}
|
||||
parentTitle={metadata?.name}
|
||||
parentType={type as 'movie' | 'series'}
|
||||
parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
|
||||
parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
|
||||
parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
|
||||
parentPosterUrl={episodeImage || metadata?.poster || undefined}
|
||||
providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
|
||||
parentId={id}
|
||||
parentImdbId={imdbId || undefined}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
scrollEnabled={false}
|
||||
initialNumToRender={6}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={3}
|
||||
removeClippedSubviews={true}
|
||||
showsVerticalScrollIndicator={false}
|
||||
getItemLayout={(data, index) => ({
|
||||
length: 78,
|
||||
offset: 78 * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
|
||||
<View style={styles.footerLoading}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
recycleItems={true}
|
||||
estimatedItemSize={78}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -408,7 +443,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
locations={[0, 0.5, 1]}
|
||||
style={styles.tabletFullScreenGradient}
|
||||
/>
|
||||
|
||||
|
||||
{/* Left Panel: Movie Logo/Episode Info */}
|
||||
<Animated.View style={[styles.tabletLeftPanel, leftPanelAnimatedStyle]}>
|
||||
{type === 'movie' && metadata ? (
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const UpdatePopup: React.FC<UpdatePopupProps> = ({
|
|||
animationType="fade"
|
||||
statusBarTranslucent={true}
|
||||
presentationStyle="overFullScreen"
|
||||
supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={[
|
||||
|
|
@ -104,10 +105,10 @@ const UpdatePopup: React.FC<UpdatePopupProps> = ({
|
|||
styles.iconContainer,
|
||||
{ backgroundColor: `${currentTheme.colors.primary}20` }
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name="system-update"
|
||||
size={32}
|
||||
color={currentTheme.colors.primary}
|
||||
<MaterialIcons
|
||||
name="system-update"
|
||||
size={32}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[
|
||||
|
|
@ -127,10 +128,10 @@ const UpdatePopup: React.FC<UpdatePopupProps> = ({
|
|||
{/* Update Info */}
|
||||
<View style={styles.updateInfo}>
|
||||
<View style={styles.infoRow}>
|
||||
<MaterialIcons
|
||||
name="info-outline"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
<MaterialIcons
|
||||
name="info-outline"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.infoLabel,
|
||||
|
|
@ -150,7 +151,7 @@ const UpdatePopup: React.FC<UpdatePopupProps> = ({
|
|||
{updateInfo.manifest?.id || 'Latest'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
{!!getReleaseNotes() && (
|
||||
<View style={styles.descriptionContainer}>
|
||||
<Text style={[
|
||||
|
|
@ -194,7 +195,7 @@ const UpdatePopup: React.FC<UpdatePopupProps> = ({
|
|||
style={[
|
||||
styles.button,
|
||||
styles.secondaryButton,
|
||||
{
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkBackground || '#2a2a2a',
|
||||
borderColor: currentTheme.colors.elevation3 || '#444444',
|
||||
}
|
||||
|
|
@ -215,7 +216,7 @@ const UpdatePopup: React.FC<UpdatePopupProps> = ({
|
|||
style={[
|
||||
styles.button,
|
||||
styles.secondaryButton,
|
||||
{
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkBackground || '#2a2a2a',
|
||||
borderColor: currentTheme.colors.elevation3 || '#444444',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from 'react-native';
|
||||
import { InteractionManager } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
|
@ -16,7 +17,6 @@ import { useTheme } from '../../contexts/ThemeContext';
|
|||
const { width } = Dimensions.get('window');
|
||||
const COLUMN_COUNT = 7; // 7 days in a week
|
||||
const DAY_ITEM_SIZE = (width - 32 - 56) / 7; // Slightly smaller than 1/7 to fit all days
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
interface CalendarEpisode {
|
||||
id: string;
|
||||
|
|
@ -76,8 +76,19 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
|
|||
episodes = [],
|
||||
onSelectDate
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
const weekDays = [
|
||||
t('common.days_short.sun'),
|
||||
t('common.days_short.mon'),
|
||||
t('common.days_short.tue'),
|
||||
t('common.days_short.wed'),
|
||||
t('common.days_short.thu'),
|
||||
t('common.days_short.fri'),
|
||||
t('common.days_short.sat')
|
||||
];
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const [uiReady, setUiReady] = useState(false);
|
||||
|
|
|
|||
268
src/components/common/Poster.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
Dimensions,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
// Enhanced responsive breakpoints
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
};
|
||||
|
||||
const getDeviceType = (screenWidth: number) => {
|
||||
if (screenWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
if (screenWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
|
||||
if (screenWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
};
|
||||
|
||||
export type PosterShape = 'poster' | 'landscape' | 'square';
|
||||
|
||||
export interface PosterProps {
|
||||
/** The poster image URL */
|
||||
uri?: string | null;
|
||||
/** Width of the poster */
|
||||
width: number;
|
||||
/** Shape of the poster - determines aspect ratio */
|
||||
shape?: PosterShape;
|
||||
/** Optional custom aspect ratio override */
|
||||
aspectRatio?: number;
|
||||
/** Optional custom border radius (uses settings.posterBorderRadius by default) */
|
||||
borderRadius?: number;
|
||||
/** Optional title to display below the poster */
|
||||
title?: string;
|
||||
/** Whether to show the title */
|
||||
showTitle?: boolean;
|
||||
/** Fallback text to show when no poster is available */
|
||||
fallbackText?: string;
|
||||
/** Additional styles for the container */
|
||||
style?: ViewStyle;
|
||||
/** Additional styles for the poster container */
|
||||
posterStyle?: ViewStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Poster component with consistent styling across the app.
|
||||
* Matches the design from ContentItem.tsx with:
|
||||
* - Border: 1.5px solid rgba(255,255,255,0.15)
|
||||
* - Border Radius: settings.posterBorderRadius (default 12)
|
||||
* - Shadow: elevation 1 on Android, subtle shadow on iOS
|
||||
* - Aspect Ratio: 2/3 for poster, 16/9 for landscape, 1/1 for square
|
||||
*/
|
||||
export const Poster: React.FC<PosterProps> = ({
|
||||
uri,
|
||||
width: posterWidth,
|
||||
shape = 'poster',
|
||||
aspectRatio: customAspectRatio,
|
||||
borderRadius: customBorderRadius,
|
||||
title,
|
||||
showTitle = false,
|
||||
fallbackText,
|
||||
style,
|
||||
posterStyle,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings, isLoaded } = useSettings();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
// Reset error state when URI changes
|
||||
useEffect(() => {
|
||||
setImageError(false);
|
||||
}, [uri]);
|
||||
|
||||
// Determine aspect ratio based on shape
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (customAspectRatio) return customAspectRatio;
|
||||
switch (shape) {
|
||||
case 'landscape':
|
||||
return 16 / 9;
|
||||
case 'square':
|
||||
return 1;
|
||||
case 'poster':
|
||||
default:
|
||||
return 2 / 3;
|
||||
}
|
||||
}, [shape, customAspectRatio]);
|
||||
|
||||
// Border radius from settings or custom
|
||||
const borderRadius = customBorderRadius ??
|
||||
(typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12);
|
||||
|
||||
// Device type for responsive title sizing
|
||||
const deviceType = getDeviceType(width);
|
||||
|
||||
// Title font size based on device type
|
||||
const titleFontSize = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
return 16;
|
||||
case 'largeTablet':
|
||||
return 15;
|
||||
case 'tablet':
|
||||
return 14;
|
||||
default:
|
||||
return 13;
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
// Optimize poster URL for TMDB
|
||||
const optimizedUrl = useMemo(() => {
|
||||
if (!uri || uri.includes('placeholder')) {
|
||||
return null;
|
||||
}
|
||||
if (uri.includes('image.tmdb.org')) {
|
||||
return uri.replace(/\/w\d+\//, '/w154/');
|
||||
}
|
||||
return uri;
|
||||
}, [uri]);
|
||||
|
||||
// Placeholder while settings load
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<View style={[styles.container, { width: posterWidth }, style]}>
|
||||
<View
|
||||
style={[
|
||||
styles.posterContainer,
|
||||
{
|
||||
width: posterWidth,
|
||||
aspectRatio,
|
||||
borderRadius,
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
},
|
||||
posterStyle,
|
||||
]}
|
||||
/>
|
||||
{showTitle && <View style={{ height: 18, marginTop: 4 }} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width: posterWidth }, style]}>
|
||||
<View
|
||||
style={[
|
||||
styles.posterContainer,
|
||||
{
|
||||
width: posterWidth,
|
||||
aspectRatio,
|
||||
borderRadius,
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
},
|
||||
posterStyle,
|
||||
]}
|
||||
>
|
||||
{optimizedUrl && !imageError ? (
|
||||
<FastImage
|
||||
source={{
|
||||
uri: optimizedUrl,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable,
|
||||
}}
|
||||
style={[styles.poster, { borderRadius }]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoad={() => setImageError(false)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.poster,
|
||||
styles.fallbackContainer,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
borderRadius,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{imageError ? (
|
||||
<MaterialIcons
|
||||
name="broken-image"
|
||||
size={24}
|
||||
color={currentTheme.colors.textMuted}
|
||||
/>
|
||||
) : fallbackText ? (
|
||||
<Text
|
||||
style={[styles.fallbackText, { color: currentTheme.colors.textMuted }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{fallbackText.length > 20 ? `${fallbackText.substring(0, 20)}...` : fallbackText}
|
||||
</Text>
|
||||
) : (
|
||||
<MaterialIcons
|
||||
name="image"
|
||||
size={24}
|
||||
color={currentTheme.colors.textMuted}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{showTitle && title && (
|
||||
<Text
|
||||
style={[
|
||||
styles.title,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: titleFontSize,
|
||||
},
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {},
|
||||
posterContainer: {
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
// Consistent shadow/elevation matching ContentItem
|
||||
elevation: Platform.OS === 'android' ? 1 : 0,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 1,
|
||||
// Consistent border styling
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255,255,255,0.15)',
|
||||
marginBottom: 8,
|
||||
},
|
||||
poster: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
fallbackContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
fallbackText: {
|
||||
fontSize: 10,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '500',
|
||||
marginTop: 4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default Poster;
|
||||
241
src/components/common/ScreenHeader.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
StatusBar,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Feather, MaterialIcons } from '@expo/vector-icons';
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
/**
|
||||
* The main title displayed in the header
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Optional right action button (icon name from Feather icons)
|
||||
*/
|
||||
rightActionIcon?: string;
|
||||
/**
|
||||
* Optional callback for right action button press
|
||||
*/
|
||||
onRightActionPress?: () => void;
|
||||
/**
|
||||
* Optional custom right action component (overrides rightActionIcon)
|
||||
*/
|
||||
rightActionComponent?: React.ReactNode;
|
||||
/**
|
||||
* Optional back button (shows arrow back icon)
|
||||
*/
|
||||
showBackButton?: boolean;
|
||||
/**
|
||||
* Optional callback for back button press
|
||||
*/
|
||||
onBackPress?: () => void;
|
||||
/**
|
||||
* Whether this screen is displayed on a tablet layout
|
||||
*/
|
||||
isTablet?: boolean;
|
||||
/**
|
||||
* Optional extra top padding for tablet navigation offset
|
||||
*/
|
||||
tabletNavOffset?: number;
|
||||
/**
|
||||
* Optional custom title component (overrides title text)
|
||||
*/
|
||||
titleComponent?: React.ReactNode;
|
||||
/**
|
||||
* Optional children to render below the title row (e.g., filters, search bar)
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Whether to hide the header title row (useful when showing only children)
|
||||
*/
|
||||
hideTitleRow?: boolean;
|
||||
/**
|
||||
* Use MaterialIcons instead of Feather for icons
|
||||
*/
|
||||
useMaterialIcons?: boolean;
|
||||
/**
|
||||
* Optional custom style for title
|
||||
*/
|
||||
titleStyle?: object;
|
||||
}
|
||||
|
||||
const ScreenHeader: React.FC<ScreenHeaderProps> = ({
|
||||
title,
|
||||
rightActionIcon,
|
||||
onRightActionPress,
|
||||
rightActionComponent,
|
||||
showBackButton = false,
|
||||
onBackPress,
|
||||
isTablet = false,
|
||||
tabletNavOffset = 64,
|
||||
titleComponent,
|
||||
children,
|
||||
hideTitleRow = false,
|
||||
useMaterialIcons = false,
|
||||
titleStyle,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Calculate header spacing
|
||||
const topSpacing =
|
||||
(Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT : insets.top) +
|
||||
(isTablet ? tabletNavOffset : 0);
|
||||
|
||||
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||
const titleRowHeight = headerBaseHeight + topSpacing;
|
||||
|
||||
const IconComponent = useMaterialIcons ? MaterialIcons : Feather;
|
||||
const backIconName = useMaterialIcons ? 'arrow-back' : 'arrow-left';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Fixed position header background to prevent shifts */}
|
||||
<View
|
||||
style={[
|
||||
styles.headerBackground,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Header Section */}
|
||||
<View
|
||||
style={[
|
||||
styles.header,
|
||||
{
|
||||
paddingTop: topSpacing,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Title Row */}
|
||||
{!hideTitleRow && (
|
||||
<View
|
||||
style={[
|
||||
styles.titleRow,
|
||||
{
|
||||
height: headerBaseHeight,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.headerContent}>
|
||||
{showBackButton ? (
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={onBackPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<IconComponent
|
||||
name={backIconName as any}
|
||||
size={24}
|
||||
color={currentTheme.colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{titleComponent ? (
|
||||
titleComponent
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.headerTitle,
|
||||
{ color: currentTheme.colors.text },
|
||||
isTablet && { fontSize: 48 }, // Increase font size for tablet
|
||||
showBackButton && styles.headerTitleWithBack,
|
||||
titleStyle,
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Right Action */}
|
||||
{rightActionComponent ? (
|
||||
<View style={styles.rightActionContainer}>{rightActionComponent}</View>
|
||||
) : rightActionIcon && onRightActionPress ? (
|
||||
<TouchableOpacity
|
||||
style={styles.rightActionButton}
|
||||
onPress={onRightActionPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<IconComponent
|
||||
name={rightActionIcon as any}
|
||||
size={24}
|
||||
color={currentTheme.colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.rightActionPlaceholder} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Children (filters, search bar, etc.) */}
|
||||
{children}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerBackground: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
zIndex: 11,
|
||||
},
|
||||
titleRow: {
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 8,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
marginLeft: -8,
|
||||
marginRight: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
flex: 1,
|
||||
},
|
||||
headerTitleWithBack: {
|
||||
fontSize: 24,
|
||||
flex: 0,
|
||||
},
|
||||
rightActionContainer: {
|
||||
minWidth: 40,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
rightActionButton: {
|
||||
padding: 8,
|
||||
marginRight: -8,
|
||||
},
|
||||
rightActionPlaceholder: {
|
||||
width: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export default ScreenHeader;
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Image,
|
||||
} from 'react-native';
|
||||
import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
|
|
@ -39,6 +40,12 @@ import { useSettings } from '../../hooks/useSettings';
|
|||
import { useTrailer } from '../../contexts/TrailerContext';
|
||||
import TrailerService from '../../services/trailerService';
|
||||
import TrailerPlayer from '../video/TrailerPlayer';
|
||||
import { useLibrary } from '../../hooks/useLibrary';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useTraktContext } from '../../contexts/TraktContext';
|
||||
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||
import { useWatchProgress } from '../../hooks/useWatchProgress';
|
||||
import { streamCacheService } from '../../services/streamCacheService';
|
||||
|
||||
interface AppleTVHeroProps {
|
||||
featuredContent: StreamingContent | null;
|
||||
|
|
@ -138,12 +145,23 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
onRetry,
|
||||
scrollY: externalScrollY,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const isFocused = useIsFocused();
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer();
|
||||
const { toggleLibrary, isInLibrary: checkIsInLibrary } = useLibrary();
|
||||
const { showSaved, showTraktSaved, showRemoved, showTraktRemoved } = useToast();
|
||||
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
|
||||
|
||||
// Library and watch state
|
||||
const [inLibrary, setInLibrary] = useState(false);
|
||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||
const [isWatched, setIsWatched] = useState(false);
|
||||
const [shouldResume, setShouldResume] = useState(false);
|
||||
const [type, setType] = useState<'movie' | 'series'>('movie');
|
||||
|
||||
// Create internal scrollY if not provided externally
|
||||
const internalScrollY = useSharedValue(0);
|
||||
|
|
@ -185,6 +203,18 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
|
||||
const currentItem = items[currentIndex] || null;
|
||||
|
||||
// Use watch progress hook
|
||||
const {
|
||||
watchProgress,
|
||||
getPlayButtonText: getProgressPlayButtonText,
|
||||
loadWatchProgress
|
||||
} = useWatchProgress(
|
||||
currentItem?.id || '',
|
||||
type,
|
||||
undefined,
|
||||
[] // Pass episodes if you have them for series
|
||||
);
|
||||
|
||||
// Animation values
|
||||
const dragProgress = useSharedValue(0);
|
||||
const dragDirection = useSharedValue(0); // -1 for left, 1 for right
|
||||
|
|
@ -196,6 +226,15 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const trailerMuted = settings?.trailerMuted ?? true;
|
||||
const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in
|
||||
|
||||
// Handler for trailer end
|
||||
const handleTrailerEnd = useCallback(() => {
|
||||
logger.info('[AppleTVHero] Trailer ended');
|
||||
setTrailerPlaying(false);
|
||||
// Fade back to thumbnail
|
||||
trailerOpacity.value = withTiming(0, { duration: 300 });
|
||||
thumbnailOpacity.value = withTiming(1, { duration: 300 });
|
||||
}, [setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
|
||||
|
||||
// Animated style for trailer container - 60% height with zoom
|
||||
const trailerContainerStyle = useAnimatedStyle(() => {
|
||||
// Faster fade out during drag - complete fade by 0.3 progress instead of 1.0
|
||||
|
|
@ -480,19 +519,198 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
logger.error('[AppleTVHero] Trailer playback error');
|
||||
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]);
|
||||
|
||||
// Handle trailer end
|
||||
const handleTrailerEnd = useCallback(() => {
|
||||
logger.info('[AppleTVHero] Trailer ended');
|
||||
setTrailerPlaying(false);
|
||||
// Update state when current item changes and load watch progress
|
||||
useEffect(() => {
|
||||
if (currentItem) {
|
||||
setType(currentItem.type as 'movie' | 'series');
|
||||
checkItemStatus(currentItem.id);
|
||||
loadWatchProgress();
|
||||
}
|
||||
}, [currentItem, loadWatchProgress]);
|
||||
|
||||
// Reset trailer state
|
||||
setTrailerReady(false);
|
||||
setTrailerPreloaded(false);
|
||||
// Update play button text and watched state when watch progress changes
|
||||
useEffect(() => {
|
||||
if (currentItem) {
|
||||
const buttonText = getProgressPlayButtonText();
|
||||
// Use internal state for resume logic instead of string comparison
|
||||
setShouldResume(buttonText === 'Resume');
|
||||
|
||||
// Smooth fade back to thumbnail
|
||||
trailerOpacity.value = withTiming(0, { duration: 500 });
|
||||
thumbnailOpacity.value = withTiming(1, { duration: 500 });
|
||||
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]);
|
||||
// Update watched state based on progress
|
||||
if (watchProgress) {
|
||||
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
|
||||
setIsWatched(progressPercent >= 85); // Consider watched if 85% or more completed
|
||||
} else {
|
||||
setIsWatched(false);
|
||||
}
|
||||
}
|
||||
}, [watchProgress, getProgressPlayButtonText, currentItem]);
|
||||
|
||||
// Function to check item status
|
||||
const checkItemStatus = useCallback(async (itemId: string) => {
|
||||
try {
|
||||
// Check if item is in library
|
||||
const libraryStatus = checkIsInLibrary(itemId);
|
||||
setInLibrary(libraryStatus);
|
||||
|
||||
// TODO: Check Trakt watchlist status if authenticated
|
||||
if (isTraktAuthenticated) {
|
||||
// await traktService.isInWatchlist(itemId);
|
||||
setIsInWatchlist(Math.random() > 0.5); // Replace with actual Trakt call
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AppleTVHero] Error checking item status:', error);
|
||||
}
|
||||
}, [checkIsInLibrary, isTraktAuthenticated]);
|
||||
|
||||
// Update the handleSaveAction function:
|
||||
const handleSaveAction = useCallback(async (e?: any) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!currentItem) return;
|
||||
|
||||
const wasInLibrary = inLibrary;
|
||||
const wasInWatchlist = isInWatchlist;
|
||||
|
||||
// Update local state immediately for responsiveness
|
||||
setInLibrary(!wasInLibrary);
|
||||
|
||||
try {
|
||||
// Toggle library using the useLibrary hook
|
||||
const success = await toggleLibrary(currentItem);
|
||||
|
||||
if (success) {
|
||||
logger.info('[AppleTVHero] Successfully toggled library:', currentItem.name);
|
||||
} else {
|
||||
logger.warn('[AppleTVHero] Library toggle returned false');
|
||||
}
|
||||
|
||||
// If authenticated with Trakt, also toggle Trakt watchlist
|
||||
if (isTraktAuthenticated) {
|
||||
setIsInWatchlist(!wasInWatchlist);
|
||||
|
||||
// TODO: Replace with your actual Trakt service call
|
||||
// await traktService.toggleWatchlist(currentItem.id, !wasInWatchlist);
|
||||
logger.info('[AppleTVHero] Toggled Trakt watchlist');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[AppleTVHero] Error toggling library:', error);
|
||||
// Revert state on error
|
||||
setInLibrary(wasInLibrary);
|
||||
if (isTraktAuthenticated) {
|
||||
setIsInWatchlist(wasInWatchlist);
|
||||
}
|
||||
}
|
||||
}, [currentItem, inLibrary, isInWatchlist, isTraktAuthenticated, toggleLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]);
|
||||
|
||||
// Play button handler - navigates to Streams screen with progress data if available
|
||||
const handlePlayAction = useCallback(async () => {
|
||||
logger.info('[AppleTVHero] Play button pressed for:', currentItem?.name);
|
||||
if (!currentItem) return;
|
||||
|
||||
// Stop any playing trailer
|
||||
try {
|
||||
setTrailerPlaying(false);
|
||||
} catch { }
|
||||
|
||||
// Check if we should resume based on watch progress
|
||||
const shouldResume = watchProgress &&
|
||||
watchProgress.currentTime > 0 &&
|
||||
(watchProgress.currentTime / watchProgress.duration) < 0.85;
|
||||
|
||||
logger.info('[AppleTVHero] Should resume:', shouldResume, watchProgress);
|
||||
|
||||
try {
|
||||
// Check if we have a cached stream for this content
|
||||
const episodeId = currentItem.type === 'series' && watchProgress?.episodeId
|
||||
? watchProgress.episodeId
|
||||
: undefined;
|
||||
|
||||
logger.info('[AppleTVHero] Looking for cached stream with episodeId:', episodeId);
|
||||
|
||||
const cachedStream = await streamCacheService.getCachedStream(currentItem.id, currentItem.type, episodeId);
|
||||
|
||||
if (cachedStream && cachedStream.stream?.url) {
|
||||
// We have a valid cached stream, navigate directly to player
|
||||
logger.info('[AppleTVHero] Using cached stream for:', currentItem.name);
|
||||
|
||||
// Determine the player route based on platform
|
||||
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
|
||||
|
||||
// Navigate directly to player with cached stream data AND RESUME DATA
|
||||
navigation.navigate(playerRoute as any, {
|
||||
uri: cachedStream.stream.url,
|
||||
title: cachedStream.metadata?.name || currentItem.name,
|
||||
episodeTitle: cachedStream.episodeTitle,
|
||||
season: cachedStream.season,
|
||||
episode: cachedStream.episode,
|
||||
quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined,
|
||||
year: cachedStream.metadata?.year || currentItem.year,
|
||||
streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name,
|
||||
streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream',
|
||||
headers: cachedStream.stream.headers || undefined,
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
episodeId: episodeId,
|
||||
imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || currentItem.imdb_id,
|
||||
backdrop: cachedStream.metadata?.backdrop || currentItem.banner,
|
||||
videoType: undefined, // Let player auto-detect
|
||||
// ADD RESUME DATA if we should resume
|
||||
...(shouldResume && watchProgress && {
|
||||
resumeTime: watchProgress.currentTime,
|
||||
duration: watchProgress.duration
|
||||
})
|
||||
} as any);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// No cached stream, navigate to Streams screen with resume data
|
||||
logger.info('[AppleTVHero] No cached stream, navigating to StreamsScreen for:', currentItem.name);
|
||||
|
||||
const navigationParams: any = {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
title: currentItem.name,
|
||||
addonId: currentItem.addonId,
|
||||
metadata: {
|
||||
poster: currentItem.poster,
|
||||
banner: currentItem.banner,
|
||||
releaseInfo: currentItem.releaseInfo,
|
||||
genres: currentItem.genres
|
||||
}
|
||||
};
|
||||
|
||||
// Add resume data if we have progress that's not near completion
|
||||
if (shouldResume && watchProgress) {
|
||||
navigationParams.resumeTime = watchProgress.currentTime;
|
||||
navigationParams.duration = watchProgress.duration;
|
||||
navigationParams.episodeId = watchProgress.episodeId;
|
||||
logger.info('[AppleTVHero] Passing resume data to Streams:', watchProgress.currentTime, watchProgress.duration);
|
||||
}
|
||||
|
||||
navigation.navigate('Streams', navigationParams);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[AppleTVHero] Error handling play action:', error);
|
||||
// Fallback to StreamsScreen on any error
|
||||
navigation.navigate('Streams', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
title: currentItem.name,
|
||||
addonId: currentItem.addonId,
|
||||
metadata: {
|
||||
poster: currentItem.poster,
|
||||
banner: currentItem.banner,
|
||||
releaseInfo: currentItem.releaseInfo,
|
||||
genres: currentItem.genres
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [currentItem, navigation, setTrailerPlaying, watchProgress]);
|
||||
|
||||
// Handle fullscreen toggle
|
||||
const handleFullscreenToggle = useCallback(async () => {
|
||||
|
|
@ -530,13 +748,32 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const timeSinceInteraction = Date.now() - lastInteractionRef.current;
|
||||
// Only auto-advance if user hasn't interacted recently (5 seconds) and no trailer playing
|
||||
if (timeSinceInteraction >= 5000 && (!globalTrailerPlaying || !trailerReady)) {
|
||||
setCurrentIndex((prev) => (prev + 1) % items.length);
|
||||
// Set next index preview for crossfade
|
||||
const nextIdx = (currentIndex + 1) % items.length;
|
||||
setNextIndex(nextIdx);
|
||||
|
||||
// Set drag direction for slide animation (left/next)
|
||||
dragDirection.value = -1;
|
||||
|
||||
// Animate crossfade before changing index
|
||||
dragProgress.value = withTiming(
|
||||
1,
|
||||
{
|
||||
duration: 500,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
runOnJS(setCurrentIndex)(nextIdx);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Retry after remaining time
|
||||
startAutoPlay();
|
||||
}
|
||||
}, 25000); // Auto-advance every 25 seconds
|
||||
}, [items.length, globalTrailerPlaying, trailerReady]);
|
||||
}, [items.length, globalTrailerPlaying, trailerReady, currentIndex, dragDirection, dragProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
startAutoPlay();
|
||||
|
|
@ -638,7 +875,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
.onEnd((event) => {
|
||||
const velocity = event.velocityX;
|
||||
const translationX = event.translationX;
|
||||
const swipeThreshold = width * 0.05; // Very small threshold - minimal swipe needed
|
||||
const swipeThreshold = width * 0.16; // 16% threshold for swipe detection
|
||||
|
||||
if (Math.abs(translationX) > swipeThreshold || Math.abs(velocity) > 300) {
|
||||
// Complete the swipe - animate to full opacity before navigation
|
||||
|
|
@ -753,10 +990,10 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
<View style={[styles.container, { height: HERO_HEIGHT, marginTop: -insets.top }]}>
|
||||
<View style={styles.noContentContainer}>
|
||||
<MaterialIcons name="theaters" size={48} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.noContentText}>No featured content available</Text>
|
||||
<Text style={styles.noContentText}>{t('home.no_featured_available')}</Text>
|
||||
{onRetry && (
|
||||
<TouchableOpacity style={styles.retryButton} onPress={onRetry} activeOpacity={0.7}>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
<Text style={styles.retryButtonText}>{t('home.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
|
@ -945,35 +1182,61 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
style={logoAnimatedStyle}
|
||||
>
|
||||
{currentItem.logo && !logoError[currentIndex] ? (
|
||||
<View
|
||||
style={[
|
||||
styles.logoContainer,
|
||||
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
|
||||
? { marginBottom: 4 } // Minimal spacing for small logos
|
||||
: { marginBottom: 8 } // Small spacing for normal logos
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
if (currentItem) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
addonId: currentItem.addonId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: currentItem.logo }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
|
||||
onError={() => {
|
||||
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
|
||||
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
|
||||
<View
|
||||
style={[
|
||||
styles.logoContainer,
|
||||
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
|
||||
? { marginBottom: 4 } // Minimal spacing for small logos
|
||||
: { marginBottom: 8 } // Small spacing for normal logos
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
>
|
||||
<Image
|
||||
source={{ uri: currentItem.logo }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
|
||||
onError={() => {
|
||||
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
|
||||
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{currentItem.name}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => {
|
||||
if (currentItem) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
addonId: currentItem.addonId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{currentItem.name}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
|
|
@ -982,7 +1245,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
<View style={styles.metadataBadge}>
|
||||
<MaterialIcons name="tv" size={16} color="#fff" />
|
||||
<Text style={styles.metadataText}>
|
||||
{currentItem.type === 'series' ? 'TV Show' : 'Movie'}
|
||||
{currentItem.type === 'series' ? t('home.tv_show') : t('home.movie')}
|
||||
</Text>
|
||||
{currentItem.genres && currentItem.genres.length > 0 && (
|
||||
<>
|
||||
|
|
@ -993,21 +1256,33 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons - Always Visible */}
|
||||
{/* Action Buttons - Play and Save buttons */}
|
||||
<View style={styles.buttonsContainer}>
|
||||
{/* Info Button */}
|
||||
{/* Play Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.playButton}
|
||||
onPress={() => {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
style={[styles.playButton]}
|
||||
onPress={handlePlayAction}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="info-outline" size={28} color="#000" />
|
||||
<Text style={styles.playButtonText}>Info</Text>
|
||||
<MaterialIcons
|
||||
name={shouldResume ? "replay" : "play-arrow"}
|
||||
size={24}
|
||||
color="#000"
|
||||
/>
|
||||
<Text style={styles.playButtonText}>{shouldResume ? t('home.resume') : t('home.play')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Save Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.saveButton}
|
||||
onPress={handleSaveAction}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={inLibrary ? "bookmark" : "bookmark-outline"}
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
|
@ -1144,25 +1419,25 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#fff',
|
||||
paddingVertical: 14,
|
||||
paddingVertical: 11,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 24,
|
||||
borderRadius: 40,
|
||||
gap: 8,
|
||||
minWidth: 140,
|
||||
minWidth: 130,
|
||||
},
|
||||
playButtonText: {
|
||||
color: '#000',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
secondaryButton: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
saveButton: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 30,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
},
|
||||
paginationContainer: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions, FlatList } from 'react-native';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { CatalogContent, StreamingContent } from '../../services/catalogService';
|
||||
|
|
@ -8,6 +9,7 @@ import { useTheme } from '../../contexts/ThemeContext';
|
|||
import ContentItem from './ContentItem';
|
||||
import Animated, { FadeIn, Layout } from 'react-native-reanimated';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { getFormattedCatalogName, getCatalogDisplayName } from '../../utils/catalogNameUtils';
|
||||
|
||||
interface CatalogSectionProps {
|
||||
catalog: CatalogContent;
|
||||
|
|
@ -41,13 +43,13 @@ const calculatePosterLayout = (screenWidth: number) => {
|
|||
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
|
||||
const LEFT_PADDING = 16; // Left padding
|
||||
const SPACING = 8; // Space between posters
|
||||
|
||||
|
||||
// Calculate available width for posters (reserve space for left padding)
|
||||
const availableWidth = screenWidth - LEFT_PADDING;
|
||||
|
||||
|
||||
// Try different numbers of full posters to find the best fit
|
||||
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
|
||||
|
||||
|
||||
for (let n = 3; n <= 6; n++) {
|
||||
// Calculate poster width needed for N full posters + 0.25 partial poster
|
||||
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
|
||||
|
|
@ -55,12 +57,12 @@ const calculatePosterLayout = (screenWidth: number) => {
|
|||
// We'll use minimal right padding (8px) to maximize space
|
||||
const usableWidth = availableWidth - 8;
|
||||
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
|
||||
|
||||
|
||||
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
|
||||
bestLayout = { numFullPosters: n, posterWidth };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
numFullPosters: bestLayout.numFullPosters,
|
||||
posterWidth: bestLayout.posterWidth,
|
||||
|
|
@ -73,17 +75,52 @@ const posterLayout = calculatePosterLayout(width);
|
|||
const POSTER_WIDTH = posterLayout.posterWidth;
|
||||
|
||||
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Use state for the display name to handle async custom name resolution
|
||||
const [displayName, setDisplayName] = React.useState(catalog.name);
|
||||
|
||||
// Re-resolve and format the name when language or catalog data changes
|
||||
React.useEffect(() => {
|
||||
const resolveName = async () => {
|
||||
// 1. Check for user-defined custom name
|
||||
const customName = await getCatalogDisplayName(
|
||||
catalog.addon,
|
||||
catalog.type,
|
||||
catalog.id,
|
||||
catalog.originalName || catalog.name
|
||||
);
|
||||
|
||||
// 2. If it's a user setting, use it as is
|
||||
if (customName !== (catalog.originalName || catalog.name)) {
|
||||
setDisplayName(customName);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Otherwise, use localized formatting
|
||||
const formatted = getFormattedCatalogName(
|
||||
customName,
|
||||
catalog.type,
|
||||
t('home.movies'),
|
||||
t('home.tv_shows'),
|
||||
t('home.channels')
|
||||
);
|
||||
setDisplayName(formatted);
|
||||
};
|
||||
|
||||
resolveName();
|
||||
}, [catalog.addon, catalog.id, catalog.type, catalog.name, catalog.originalName, i18n.language, t]);
|
||||
|
||||
const handleContentPress = useCallback((id: string, type: string) => {
|
||||
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
|
||||
}, [navigation, catalog.addon]);
|
||||
|
||||
const renderContentItem = useCallback(({ item }: { item: StreamingContent, index: number }) => {
|
||||
return (
|
||||
<ContentItem
|
||||
item={item}
|
||||
<ContentItem
|
||||
item={item}
|
||||
onPress={handleContentPress}
|
||||
/>
|
||||
);
|
||||
|
|
@ -96,25 +133,11 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
// Memoize the keyExtractor to prevent re-creation
|
||||
const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []);
|
||||
|
||||
// Calculate item width for getItemLayout - use base POSTER_WIDTH for consistent spacing
|
||||
// Note: ContentItem may apply size multipliers based on settings, but base width ensures consistent layout
|
||||
const itemWidth = useMemo(() => POSTER_WIDTH, []);
|
||||
|
||||
// getItemLayout for consistent spacing and better performance
|
||||
const getItemLayout = useCallback((data: any, index: number) => {
|
||||
const length = itemWidth + separatorWidth;
|
||||
const paddingHorizontal = isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 16;
|
||||
return {
|
||||
length,
|
||||
offset: paddingHorizontal + (length * index),
|
||||
index,
|
||||
};
|
||||
}, [itemWidth, separatorWidth, isTV, isLargeTablet, isTablet]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<View
|
||||
style={styles.catalogContainer}
|
||||
entering={FadeIn.duration(400)}
|
||||
>
|
||||
<View style={[
|
||||
styles.catalogHeader,
|
||||
|
|
@ -131,7 +154,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{catalog.name}
|
||||
{displayName}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
|
|
@ -145,7 +168,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
onPress={() =>
|
||||
navigation.navigate('Catalog', {
|
||||
id: catalog.id,
|
||||
type: catalog.type,
|
||||
|
|
@ -168,7 +191,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
marginRight: isTV ? 6 : isLargeTablet ? 5 : 4,
|
||||
}
|
||||
]}>View All</Text>
|
||||
]}>{t('home.view_all')}</Text>
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
|
||||
|
|
@ -176,7 +199,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
<FlatList
|
||||
data={catalog.items}
|
||||
renderItem={renderContentItem}
|
||||
|
|
@ -195,14 +218,13 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
}
|
||||
])}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
getItemLayout={getItemLayout}
|
||||
removeClippedSubviews={true}
|
||||
initialNumToRender={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
|
||||
maxToRenderPerBatch={isTV ? 4 : isLargeTablet ? 4 : 3}
|
||||
windowSize={isTV ? 4 : isLargeTablet ? 4 : 3}
|
||||
updateCellsBatchingPeriod={50}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -262,8 +284,8 @@ export default React.memo(CatalogSection, (prevProps, nextProps) => {
|
|||
prevProps.catalog.name === nextProps.catalog.name &&
|
||||
prevProps.catalog.items.length === nextProps.catalog.items.length &&
|
||||
// Deep compare the first few items to detect changes
|
||||
prevProps.catalog.items.slice(0, 3).every((item, index) =>
|
||||
nextProps.catalog.items[index] &&
|
||||
prevProps.catalog.items.slice(0, 3).every((item, index) =>
|
||||
nextProps.catalog.items[index] &&
|
||||
item.id === nextProps.catalog.items[index].id &&
|
||||
item.poster === nextProps.catalog.items[index].poster
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { DeviceEventEmitter } from 'react-native';
|
||||
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share } from 'react-native';
|
||||
|
|
@ -41,7 +42,7 @@ const getDeviceType = (screenWidth: number) => {
|
|||
// Dynamic poster calculation based on screen width - show 1/4 of next poster
|
||||
const calculatePosterLayout = (screenWidth: number) => {
|
||||
const deviceType = getDeviceType(screenWidth);
|
||||
|
||||
|
||||
// Responsive sizing based on device type
|
||||
const MIN_POSTER_WIDTH = deviceType === 'tv' ? 180 : deviceType === 'largeTablet' ? 160 : deviceType === 'tablet' ? 140 : 100;
|
||||
const MAX_POSTER_WIDTH = deviceType === 'tv' ? 220 : deviceType === 'largeTablet' ? 200 : deviceType === 'tablet' ? 180 : 130;
|
||||
|
|
@ -52,9 +53,9 @@ const calculatePosterLayout = (screenWidth: number) => {
|
|||
const availableWidth = screenWidth - LEFT_PADDING;
|
||||
|
||||
// Try different numbers of full posters to find the best fit
|
||||
let bestLayout = {
|
||||
numFullPosters: 3,
|
||||
posterWidth: deviceType === 'tv' ? 200 : deviceType === 'largeTablet' ? 180 : deviceType === 'tablet' ? 160 : 120
|
||||
let bestLayout = {
|
||||
numFullPosters: 3,
|
||||
posterWidth: deviceType === 'tv' ? 200 : deviceType === 'largeTablet' ? 180 : deviceType === 'tablet' ? 160 : 120
|
||||
};
|
||||
|
||||
for (let n = 3; n <= 6; n++) {
|
||||
|
|
@ -82,6 +83,7 @@ const posterLayout = calculatePosterLayout(width);
|
|||
const POSTER_WIDTH = posterLayout.posterWidth;
|
||||
|
||||
const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, deferMs = 0 }: ContentItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Track inLibrary status locally to force re-render
|
||||
const [inLibrary, setInLibrary] = useState(!!item.inLibrary);
|
||||
|
||||
|
|
@ -96,7 +98,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
return () => unsubscribe();
|
||||
}, [item.id, item.type]);
|
||||
|
||||
// Load watched state from AsyncStorage when item changes
|
||||
// Load watched state from AsyncStorage when item changes
|
||||
useEffect(() => {
|
||||
const updateWatched = () => {
|
||||
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then((val: string | null) => setIsWatched(val === 'true'));
|
||||
|
|
@ -126,7 +128,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
const posterWidth = React.useMemo(() => {
|
||||
const deviceType = getDeviceType(width);
|
||||
const sizeMultiplier = deviceType === 'tv' ? 1.2 : deviceType === 'largeTablet' ? 1.1 : deviceType === 'tablet' ? 1.0 : 0.9;
|
||||
|
||||
|
||||
switch (settings.posterSize) {
|
||||
case 'small':
|
||||
return Math.max(90, POSTER_WIDTH - 15) * sizeMultiplier;
|
||||
|
|
@ -139,6 +141,30 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
}
|
||||
}, [settings.posterSize, width]);
|
||||
|
||||
// Determine dimensions based on poster shape
|
||||
const { finalWidth, finalAspectRatio, borderRadius } = React.useMemo(() => {
|
||||
const shape = item.posterShape || 'poster';
|
||||
const baseHeight = posterWidth / (2 / 3); // Standard height derived from portrait width
|
||||
|
||||
let w = posterWidth;
|
||||
let ratio = 2 / 3;
|
||||
|
||||
if (shape === 'landscape') {
|
||||
ratio = 16 / 9;
|
||||
// Maintain same height as portrait posters
|
||||
w = baseHeight * ratio;
|
||||
} else if (shape === 'square') {
|
||||
ratio = 1;
|
||||
w = baseHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
finalWidth: w,
|
||||
finalAspectRatio: ratio,
|
||||
borderRadius: typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12
|
||||
};
|
||||
}, [posterWidth, item.posterShape, settings.posterBorderRadius]);
|
||||
|
||||
// Intersection observer simulation for lazy loading
|
||||
const itemRef = useRef<View>(null);
|
||||
|
||||
|
|
@ -158,10 +184,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
case 'library':
|
||||
if (inLibrary) {
|
||||
catalogService.removeFromLibrary(item.type, item.id);
|
||||
showInfo('Removed from Library', 'Removed from your local library');
|
||||
showInfo(t('library.removed_from_library'), t('library.item_removed'));
|
||||
} else {
|
||||
catalogService.addToLibrary(item);
|
||||
showSuccess('Added to Library', 'Added to your local library');
|
||||
showSuccess(t('library.added_to_library'), t('library.item_added'));
|
||||
}
|
||||
break;
|
||||
case 'watched': {
|
||||
|
|
@ -169,8 +195,8 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
setIsWatched(targetWatched);
|
||||
try {
|
||||
await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
|
||||
} catch {}
|
||||
showInfo(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched', targetWatched ? 'Item marked as watched' : 'Item marked as unwatched');
|
||||
} catch { }
|
||||
showInfo(targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'), targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched'));
|
||||
setTimeout(() => {
|
||||
DeviceEventEmitter.emit('watchedStatusChanged');
|
||||
}, 100);
|
||||
|
|
@ -185,7 +211,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
undefined,
|
||||
{ forceNotify: true, forceWrite: true }
|
||||
);
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
if (item.type === 'movie') {
|
||||
try {
|
||||
|
|
@ -194,9 +220,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
await trakt.addToWatchedMovies(item.id);
|
||||
try {
|
||||
await storageService.updateTraktSyncStatus(item.id, item.type, true, 100);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
setMenuVisible(false);
|
||||
|
|
@ -216,10 +242,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
case 'trakt-watchlist': {
|
||||
if (isInWatchlist(item.id, item.type as 'movie' | 'show')) {
|
||||
await removeFromWatchlist(item.id, item.type as 'movie' | 'show');
|
||||
showInfo('Removed from Watchlist', 'Removed from your Trakt watchlist');
|
||||
showInfo(t('library.removed_from_watchlist'), t('library.removed_from_watchlist_desc'));
|
||||
} else {
|
||||
await addToWatchlist(item.id, item.type as 'movie' | 'show');
|
||||
showSuccess('Added to Watchlist', 'Added to your Trakt watchlist');
|
||||
showSuccess(t('library.added_to_watchlist'), t('library.added_to_watchlist_desc'));
|
||||
}
|
||||
setMenuVisible(false);
|
||||
break;
|
||||
|
|
@ -227,10 +253,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
case 'trakt-collection': {
|
||||
if (isInCollection(item.id, item.type as 'movie' | 'show')) {
|
||||
await removeFromCollection(item.id, item.type as 'movie' | 'show');
|
||||
showInfo('Removed from Collection', 'Removed from your Trakt collection');
|
||||
showInfo(t('library.removed_from_collection'), t('library.removed_from_collection_desc'));
|
||||
} else {
|
||||
await addToCollection(item.id, item.type as 'movie' | 'show');
|
||||
showSuccess('Added to Collection', 'Added to your Trakt collection');
|
||||
showSuccess(t('library.added_to_collection'), t('library.added_to_collection_desc'));
|
||||
}
|
||||
setMenuVisible(false);
|
||||
break;
|
||||
|
|
@ -242,44 +268,34 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
setMenuVisible(false);
|
||||
}, []);
|
||||
|
||||
|
||||
// Memoize optimized poster URL to prevent recalculating
|
||||
const optimizedPosterUrl = React.useMemo(() => {
|
||||
if (!item.poster || item.poster.includes('placeholder')) {
|
||||
return 'https://via.placeholder.com/154x231/333/666?text=No+Image';
|
||||
}
|
||||
|
||||
// For TMDB images, use smaller sizes
|
||||
if (item.poster.includes('image.tmdb.org')) {
|
||||
// Replace any size with w154 (fits 100-130px tiles perfectly)
|
||||
return item.poster.replace(/\/w\d+\//, '/w154/');
|
||||
}
|
||||
|
||||
// For metahub images, use smaller sizes
|
||||
if (item.poster.includes('placeholder')) {
|
||||
return item.poster.replace('/medium/', '/small/');
|
||||
}
|
||||
|
||||
// Return original URL for other sources to avoid breaking them
|
||||
return item.poster;
|
||||
}, [item.poster, item.id]);
|
||||
|
||||
// While settings load, render a placeholder with reserved space (poster aspect + title)
|
||||
if (!isLoaded) {
|
||||
const placeholderRadius = 12;
|
||||
return (
|
||||
<View style={[styles.itemContainer, { width: posterWidth }]}>
|
||||
<View style={[styles.itemContainer, { width: finalWidth }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.contentItem,
|
||||
{
|
||||
width: posterWidth,
|
||||
borderRadius: placeholderRadius,
|
||||
width: finalWidth,
|
||||
aspectRatio: finalAspectRatio,
|
||||
borderRadius,
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Reserve space for title to keep section spacing stable */}
|
||||
<View style={{ height: 18, marginTop: 4 }} />
|
||||
</View>
|
||||
);
|
||||
|
|
@ -287,24 +303,24 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
|
||||
return (
|
||||
<>
|
||||
<Animated.View style={[styles.itemContainer, { width: posterWidth }]} entering={FadeIn.duration(300)}>
|
||||
<Animated.View style={[styles.itemContainer, { width: finalWidth }]} entering={FadeIn.duration(300)}>
|
||||
<TouchableOpacity
|
||||
style={[styles.contentItem, { width: posterWidth, borderRadius: posterRadius }]}
|
||||
style={[styles.contentItem, { width: finalWidth, aspectRatio: finalAspectRatio, borderRadius }]}
|
||||
activeOpacity={0.7}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={300}
|
||||
>
|
||||
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius: posterRadius }] }>
|
||||
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius }]}>
|
||||
{/* Image with FastImage for aggressive caching */}
|
||||
{item.poster ? (
|
||||
<FastImage
|
||||
source={{
|
||||
source={{
|
||||
uri: optimizedPosterUrl,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius: posterRadius }]}
|
||||
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius }]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoad={() => {
|
||||
setImageError(false);
|
||||
|
|
@ -316,14 +332,14 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
/>
|
||||
) : (
|
||||
// Show placeholder for items without posters
|
||||
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center', borderRadius: posterRadius }] }>
|
||||
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center', borderRadius: posterRadius }]}>
|
||||
<Text style={{ color: currentTheme.colors.textMuted, fontSize: 10, textAlign: 'center' }}>
|
||||
{item.name.substring(0, 20)}...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{imageError && (
|
||||
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<MaterialIcons name="broken-image" size={24} color={currentTheme.colors.textMuted} />
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -350,14 +366,14 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
</View>
|
||||
</TouchableOpacity>
|
||||
{settings.showPosterTitles && (
|
||||
<Text
|
||||
<Text
|
||||
style={[
|
||||
styles.title,
|
||||
{
|
||||
styles.title,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: getDeviceType(width) === 'tv' ? 16 : getDeviceType(width) === 'largeTablet' ? 15 : getDeviceType(width) === 'tablet' ? 14 : 13
|
||||
}
|
||||
]}
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.name}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Dimensions,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useTraktContext } from '../../contexts/TraktContext';
|
||||
|
|
@ -39,6 +40,7 @@ interface DropUpMenuProps {
|
|||
}
|
||||
|
||||
export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: isSavedProp, isWatched: isWatchedProp }: DropUpMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const translateY = useSharedValue(300);
|
||||
const opacity = useSharedValue(0);
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
|
|
@ -98,16 +100,16 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
|
|||
const isWatched = !!isWatchedProp;
|
||||
const inTraktWatchlist = isAuthenticated && isInWatchlist(item.id, item.type as 'movie' | 'show');
|
||||
const inTraktCollection = isAuthenticated && isInCollection(item.id, item.type as 'movie' | 'show');
|
||||
|
||||
|
||||
let menuOptions = [
|
||||
{
|
||||
icon: 'bookmark',
|
||||
label: isSaved ? 'Remove from Library' : 'Add to Library',
|
||||
label: isSaved ? t('library.remove_from_library') : t('library.add_to_library'),
|
||||
action: 'library'
|
||||
},
|
||||
{
|
||||
icon: 'check-circle',
|
||||
label: isWatched ? 'Mark as Unwatched' : 'Mark as Watched',
|
||||
label: isWatched ? t('library.mark_unwatched') : t('library.mark_watched'),
|
||||
action: 'watched'
|
||||
},
|
||||
/*
|
||||
|
|
@ -119,7 +121,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
|
|||
*/
|
||||
{
|
||||
icon: 'share',
|
||||
label: 'Share',
|
||||
label: t('library.share'),
|
||||
action: 'share'
|
||||
}
|
||||
];
|
||||
|
|
@ -129,12 +131,12 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
|
|||
menuOptions.push(
|
||||
{
|
||||
icon: 'playlist-add-check',
|
||||
label: inTraktWatchlist ? 'Remove from Trakt Watchlist' : 'Add to Trakt Watchlist',
|
||||
label: inTraktWatchlist ? t('library.remove_from_watchlist') : t('library.add_to_watchlist'),
|
||||
action: 'trakt-watchlist'
|
||||
},
|
||||
{
|
||||
icon: 'video-library',
|
||||
label: inTraktCollection ? 'Remove from Trakt Collection' : 'Add to Trakt Collection',
|
||||
label: inTraktCollection ? t('library.remove_from_collection') : t('library.add_to_collection'),
|
||||
action: 'trakt-collection'
|
||||
}
|
||||
);
|
||||
|
|
@ -152,6 +154,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
|
|||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
|
|
@ -162,7 +165,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
|
|||
<View style={styles.dragHandle} />
|
||||
<View style={styles.menuHeader}>
|
||||
<FastImage
|
||||
source={{
|
||||
source={{
|
||||
uri: item.poster,
|
||||
priority: FastImage.priority.high,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Platform
|
||||
} from 'react-native';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
|
|
@ -52,6 +53,7 @@ const nowMs = () => Date.now();
|
|||
const since = (start: number) => `${(nowMs() - start).toFixed(0)}ms`;
|
||||
|
||||
const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
|
@ -103,11 +105,11 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
|
|||
return (
|
||||
<View style={styles.noContentContainer}>
|
||||
<MaterialIcons name="theaters" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={styles.noContentTitle}>{onRetry ? 'Couldn\'t load featured content' : 'No Featured Content'}</Text>
|
||||
<Text style={styles.noContentTitle}>{onRetry ? t('home.couldnt_load_featured') : t('home.no_featured_content')}</Text>
|
||||
<Text style={styles.noContentText}>
|
||||
{onRetry
|
||||
? 'There was a problem fetching featured content. Please check your connection and try again.'
|
||||
: 'Install addons with catalogs or change the content source in your settings.'}
|
||||
? t('home.load_error_desc')
|
||||
: t('home.no_featured_desc')}
|
||||
</Text>
|
||||
<View style={styles.noContentButtons}>
|
||||
{onRetry ? (
|
||||
|
|
@ -115,7 +117,7 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
|
|||
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={onRetry}
|
||||
>
|
||||
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Retry</Text>
|
||||
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>{t('home.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -123,13 +125,13 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
|
|||
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('Addons')}
|
||||
>
|
||||
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Install Addons</Text>
|
||||
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>{t('home.install_addons')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.noContentButton}
|
||||
onPress={() => navigation.navigate('HomeScreenSettings')}
|
||||
>
|
||||
<Text style={styles.noContentButtonText}>Settings</Text>
|
||||
<Text style={styles.noContentButtonText}>{t('home.settings')}</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -139,6 +141,7 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
|
|||
};
|
||||
|
||||
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loading, onRetry }: FeaturedContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
|
||||
|
|
@ -509,7 +512,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name="play-arrow" size={28} color={currentTheme.colors.black} />
|
||||
<Text style={[styles.tabletPlayButtonText as TextStyle, { color: currentTheme.colors.black }]}>
|
||||
Play Now
|
||||
{t('home.play_now')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -520,7 +523,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name={isSaved ? "bookmark" : "bookmark-outline"} size={20} color={currentTheme.colors.white} />
|
||||
<Text style={[styles.tabletSecondaryButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
{isSaved ? "Saved" : "My List"}
|
||||
{isSaved ? t('home.saved') : t('home.my_list')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -531,7 +534,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name="info-outline" size={20} color={currentTheme.colors.white} />
|
||||
<Text style={[styles.tabletSecondaryButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
More Info
|
||||
{t('home.more_info')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
|
@ -626,7 +629,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name={isSaved ? "bookmark" : "bookmark-outline"} size={24} color={currentTheme.colors.white} />
|
||||
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
{isSaved ? "Saved" : "Save"}
|
||||
{isSaved ? t('home.saved') : t('home.save')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -644,7 +647,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
|
||||
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
|
||||
Play
|
||||
{t('home.play')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -655,7 +658,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
|
||||
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
Info
|
||||
{t('home.info')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useMemo, useState, useEffect, useCallback, memo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, ScrollView, StyleProp, Platform, Image, useWindowDimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS, SharedValue, interpolate, Extrapolation } from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
|
|
@ -38,6 +39,7 @@ interface HeroCarouselProps {
|
|||
const TOP_TABS_OFFSET = Platform.OS === 'ios' ? 44 : 48;
|
||||
|
||||
const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
|
@ -83,6 +85,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedLogoIds, setFailedLogoIds] = useState<Set<string>>(new Set());
|
||||
const scrollViewRef = useRef<any>(null);
|
||||
const [isScrollReady, setIsScrollReady] = useState(false);
|
||||
const [flippedMap, setFlippedMap] = useState<Record<string, boolean>>({});
|
||||
const toggleFlipById = useCallback((id: string) => {
|
||||
setFlippedMap((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
|
|
@ -95,9 +98,9 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
// Optimized: update background as soon as scroll starts, without waiting for momentum end
|
||||
const scrollX = useSharedValue(0);
|
||||
const paginationProgress = useSharedValue(0);
|
||||
|
||||
|
||||
// Parallel image prefetch: start fetching banners and logos as soon as data arrives
|
||||
const itemsToPreload = useMemo(() => data.slice(0, 12), [data]);
|
||||
const itemsToPreload = useMemo(() => data.slice(0, 3), [data]);
|
||||
useEffect(() => {
|
||||
if (!itemsToPreload.length) return;
|
||||
try {
|
||||
|
|
@ -121,17 +124,19 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
// no-op: prefetch is best-effort
|
||||
}
|
||||
}, [itemsToPreload]);
|
||||
|
||||
|
||||
// Comprehensive reset when component mounts/remounts to prevent glitching
|
||||
useEffect(() => {
|
||||
// Start at the first real item for looping
|
||||
scrollX.value = loopingEnabled ? interval : 0;
|
||||
setActiveIndex(0);
|
||||
setIsScrollReady(false);
|
||||
|
||||
// Scroll to position 0 after a brief delay to ensure ScrollView is ready
|
||||
// Scroll to position and mark ready after layout
|
||||
const timer = setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ x: loopingEnabled ? interval : 0, y: 0, animated: false });
|
||||
}, 50);
|
||||
setIsScrollReady(true);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
|
@ -141,10 +146,12 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
if (data.length > 0) {
|
||||
scrollX.value = loopingEnabled ? interval : 0;
|
||||
setActiveIndex(0);
|
||||
setIsScrollReady(false);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ x: loopingEnabled ? interval : 0, y: 0, animated: false });
|
||||
}, 100);
|
||||
setIsScrollReady(true);
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
|
@ -158,7 +165,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [windowWidth, windowHeight, interval, loopingEnabled]);
|
||||
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollX.value = event.contentOffset.x;
|
||||
|
|
@ -192,12 +199,12 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
},
|
||||
(idx, prevIdx) => {
|
||||
if (idx == null || idx === prevIdx) return;
|
||||
|
||||
|
||||
// Debounce updates to reduce JS bridge crossings
|
||||
const now = Date.now();
|
||||
if (now - lastIndexUpdateRef.current < 100) return; // 100ms debounce
|
||||
lastIndexUpdateRef.current = now;
|
||||
|
||||
|
||||
// Clamp to bounds to avoid out-of-range access
|
||||
const clamped = Math.max(0, Math.min(idx, data.length - 1));
|
||||
runOnJS(setActiveIndex)(clamped);
|
||||
|
|
@ -223,9 +230,9 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
|
||||
const contentPadding = useMemo(() => ({ paddingHorizontal: (windowWidth - cardWidth) / 2 }), [windowWidth, cardWidth]);
|
||||
|
||||
const handleNavigateToMetadata = useCallback((id: string, type: any) => {
|
||||
navigation.navigate('Metadata', { id, type });
|
||||
}, [navigation]);
|
||||
const handleNavigateToMetadata = useCallback((id: string, type: any, addonId?: string) => {
|
||||
navigation.navigate('Metadata', { id, type, addonId });
|
||||
}, [navigation]);
|
||||
|
||||
// Container animation based on scroll - must be before early returns
|
||||
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
|
||||
|
|
@ -263,22 +270,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
height: cardHeight,
|
||||
}
|
||||
] as StyleProp<ViewStyle>}>
|
||||
<View style={styles.bannerContainer as ViewStyle}>
|
||||
<View style={styles.skeletonBannerFull as ViewStyle} />
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.25)"]}
|
||||
locations={[0.6, 1]}
|
||||
style={styles.bannerOverlay as ViewStyle}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.info as ViewStyle}>
|
||||
<View style={[styles.skeletonLine, { width: '62%' }] as StyleProp<ViewStyle>} />
|
||||
<View style={[styles.skeletonLine, { width: '44%', marginTop: 6 }] as StyleProp<ViewStyle>} />
|
||||
<View style={styles.skeletonActions as ViewStyle}>
|
||||
<View style={[styles.skeletonPill, { width: 96 }] as StyleProp<ViewStyle>} />
|
||||
<View style={[styles.skeletonPill, { width: 80 }] as StyleProp<ViewStyle>} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.skeletonBannerFull as ViewStyle} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
|
@ -289,11 +281,11 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
}
|
||||
|
||||
// Memoized background component with improved timing
|
||||
const BackgroundImage = React.memo(({
|
||||
item,
|
||||
const BackgroundImage = React.memo(({
|
||||
item,
|
||||
insets
|
||||
}: {
|
||||
item: StreamingContent;
|
||||
}: {
|
||||
item: StreamingContent;
|
||||
insets: any;
|
||||
}) => {
|
||||
return (
|
||||
|
|
@ -317,7 +309,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
) : (
|
||||
<>
|
||||
<FastImage
|
||||
source={{
|
||||
source={{
|
||||
uri: item.banner || item.poster,
|
||||
priority: FastImage.priority.low,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
|
|
@ -352,15 +344,15 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
if (!hasData) return null;
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(150).easing(Easing.out(Easing.cubic))}>
|
||||
<View>
|
||||
<Animated.View style={[styles.container as ViewStyle, { paddingTop: 12 + effectiveTopOffset }]}>
|
||||
{/* Removed preload images for performance - let FastImage cache handle it naturally */}
|
||||
{settings.enableHomeHeroBackground && data[activeIndex] && (
|
||||
<BackgroundImage
|
||||
item={data[activeIndex]}
|
||||
insets={insets}
|
||||
/>
|
||||
)}
|
||||
{settings.enableHomeHeroBackground && data[activeIndex] && (
|
||||
<BackgroundImage
|
||||
item={data[activeIndex]}
|
||||
insets={insets}
|
||||
/>
|
||||
)}
|
||||
{/* Bottom blend to HomeScreen background (not the card) */}
|
||||
{settings.enableHomeHeroBackground && (
|
||||
<LinearGradient
|
||||
|
|
@ -383,6 +375,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
pagingEnabled={false}
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
style={{ opacity: isScrollReady ? 1 : 0 }}
|
||||
contentOffset={{ x: loopingEnabled ? interval : 0, y: 0 }}
|
||||
onMomentumScrollEnd={(e) => {
|
||||
if (!loopingEnabled) return;
|
||||
// Determine current page index in cloned space
|
||||
|
|
@ -400,13 +394,14 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
}}
|
||||
>
|
||||
{(loopingEnabled ? loopData : data).map((item, index) => (
|
||||
/* TEST 5: ORIGINAL CARD WITHOUT LINEAR GRADIENT */
|
||||
<CarouselCard
|
||||
key={`${item.id}-${index}-${loopingEnabled ? 'loop' : 'base'}`}
|
||||
item={item}
|
||||
colors={currentTheme.colors}
|
||||
logoFailed={failedLogoIds.has(item.id)}
|
||||
onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))}
|
||||
onPressInfo={() => handleNavigateToMetadata(item.id, item.type)}
|
||||
onPressInfo={() => handleNavigateToMetadata(item.id, item.type, item.addonId)}
|
||||
scrollX={scrollX}
|
||||
index={index}
|
||||
flipped={!!flippedMap[item.id]}
|
||||
|
|
@ -444,10 +439,162 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
}}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// MINIMAL ANIMATED CARD FOR PERFORMANCE TESTING
|
||||
interface AnimatedCardWrapperProps {
|
||||
item: StreamingContent;
|
||||
index: number;
|
||||
scrollX: SharedValue<number>;
|
||||
interval: number;
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
colors: any;
|
||||
isTablet: boolean;
|
||||
}
|
||||
|
||||
const AnimatedCardWrapper: React.FC<AnimatedCardWrapperProps> = memo(({
|
||||
item, index, scrollX, interval, cardWidth, cardHeight, colors, isTablet
|
||||
}) => {
|
||||
const cardAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * interval;
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
|
||||
if (distance > interval * 1.5) {
|
||||
return {
|
||||
transform: [{ scale: isTablet ? 0.95 : 0.9 }],
|
||||
opacity: isTablet ? 0.85 : 0.7
|
||||
};
|
||||
}
|
||||
|
||||
const maxDistance = interval;
|
||||
const scale = 1 - (distance / maxDistance) * 0.1;
|
||||
const clampedScale = Math.max(isTablet ? 0.95 : 0.9, Math.min(1, scale));
|
||||
const opacity = 1 - (distance / maxDistance) * 0.3;
|
||||
const clampedOpacity = Math.max(isTablet ? 0.85 : 0.7, Math.min(1, opacity));
|
||||
|
||||
return {
|
||||
transform: [{ scale: clampedScale }],
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
const logoOpacity = useSharedValue(0);
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
const isFlipped = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (logoLoaded) {
|
||||
logoOpacity.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.ease) });
|
||||
}
|
||||
}, [logoLoaded]);
|
||||
|
||||
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: logoOpacity.value,
|
||||
}));
|
||||
|
||||
// TEST 4: FLIP STYLES
|
||||
const frontFlipStyle = useAnimatedStyle(() => {
|
||||
const rotate = interpolate(isFlipped.value, [0, 1], [0, 180]);
|
||||
return {
|
||||
transform: [
|
||||
{ perspective: 1000 },
|
||||
{ rotateY: `${rotate}deg` },
|
||||
],
|
||||
} as any;
|
||||
});
|
||||
|
||||
const backFlipStyle = useAnimatedStyle(() => {
|
||||
const rotate = interpolate(isFlipped.value, [0, 1], [-180, 0]);
|
||||
return {
|
||||
transform: [
|
||||
{ perspective: 1000 },
|
||||
{ rotateY: `${rotate}deg` },
|
||||
],
|
||||
} as any;
|
||||
});
|
||||
|
||||
// TEST 4: OVERLAY ANIMATED STYLE (genres opacity on scroll)
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * interval;
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
|
||||
if (distance > interval * 1.2) {
|
||||
return { opacity: 0 };
|
||||
}
|
||||
|
||||
const maxDistance = interval * 0.5;
|
||||
const progress = Math.min(distance / maxDistance, 1);
|
||||
const opacity = 1 - progress;
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
return {
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{ width: cardWidth + 16 }}>
|
||||
<Animated.View style={[
|
||||
{
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
backgroundColor: colors.elevation1,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cardAnimatedStyle
|
||||
]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.banner || item.poster,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', position: 'absolute' }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
|
||||
locations={[0.4, 0.7, 1]}
|
||||
style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }}
|
||||
/>
|
||||
{item.logo && (
|
||||
<View style={{ position: 'absolute', left: 0, right: 0, bottom: 40, alignItems: 'center' }}>
|
||||
<Animated.View style={logoAnimatedStyle}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.logo,
|
||||
priority: FastImage.priority.high,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={{ width: Math.round(cardWidth * 0.72), height: 64 }}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
onLoad={() => setLogoLoaded(true)}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
{/* TEST 4: GENRES with overlayAnimatedStyle */}
|
||||
{item.genres && (
|
||||
<View style={{ position: 'absolute', left: 0, right: 0, bottom: 12, alignItems: 'center' }}>
|
||||
<Animated.Text
|
||||
style={[{ color: 'rgba(255,255,255,0.7)', fontSize: 13, textAlign: 'center' }, overlayAnimatedStyle]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.genres.slice(0, 3).join(' • ')}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface CarouselCardProps {
|
||||
item: StreamingContent;
|
||||
colors: any;
|
||||
|
|
@ -465,15 +612,16 @@ interface CarouselCardProps {
|
|||
}
|
||||
|
||||
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip, interval, cardWidth, cardHeight, isTablet }) => {
|
||||
const { t } = useTranslation();
|
||||
const [bannerLoaded, setBannerLoaded] = useState(false);
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
|
||||
|
||||
const bannerOpacity = useSharedValue(0);
|
||||
const logoOpacity = useSharedValue(0);
|
||||
const genresOpacity = useSharedValue(0);
|
||||
const actionsOpacity = useSharedValue(0);
|
||||
const isFlipped = useSharedValue(flipped ? 1 : 0);
|
||||
|
||||
|
||||
// Reset animations when component mounts/remounts to prevent glitching
|
||||
useEffect(() => {
|
||||
bannerOpacity.value = 0;
|
||||
|
|
@ -484,17 +632,17 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
setBannerLoaded(false);
|
||||
setLogoLoaded(false);
|
||||
}, [item.id]);
|
||||
|
||||
|
||||
const inputRange = [
|
||||
(index - 1) * interval,
|
||||
index * interval,
|
||||
(index + 1) * interval,
|
||||
];
|
||||
|
||||
|
||||
const bannerAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: bannerOpacity.value,
|
||||
}));
|
||||
|
||||
|
||||
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: logoOpacity.value,
|
||||
}));
|
||||
|
|
@ -538,52 +686,52 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
const translateX = scrollX.value;
|
||||
const cardOffset = index * interval;
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
|
||||
|
||||
// AGGRESSIVE early exit for cards far from center
|
||||
if (distance > interval * 1.2) {
|
||||
return { opacity: 0 };
|
||||
}
|
||||
|
||||
|
||||
const maxDistance = interval * 0.5;
|
||||
const progress = Math.min(distance / maxDistance, 1);
|
||||
const opacity = 1 - progress;
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
|
||||
return {
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// ULTRA-OPTIMIZED: Only animate center card and ±1 neighbors
|
||||
const cardAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * interval;
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
|
||||
|
||||
// AGGRESSIVE early exit for cards far from center
|
||||
if (distance > interval * 1.5) {
|
||||
return {
|
||||
transform: [{ scale: isTablet ? 0.95 : 0.9 }],
|
||||
opacity: isTablet ? 0.85 : 0.7
|
||||
return {
|
||||
transform: [{ scale: isTablet ? 0.95 : 0.9 }],
|
||||
opacity: isTablet ? 0.85 : 0.7
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const maxDistance = interval;
|
||||
|
||||
|
||||
// Scale animation based on distance from center
|
||||
const scale = 1 - (distance / maxDistance) * 0.1;
|
||||
const clampedScale = Math.max(isTablet ? 0.95 : 0.9, Math.min(1, scale));
|
||||
|
||||
|
||||
// Opacity animation for cards that are far from center
|
||||
const opacity = 1 - (distance / maxDistance) * 0.3;
|
||||
const clampedOpacity = Math.max(isTablet ? 0.85 : 0.7, Math.min(1, opacity));
|
||||
|
||||
|
||||
return {
|
||||
transform: [{ scale: clampedScale }],
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
|
||||
// const bannerParallaxStyle = useAnimatedStyle(() => {
|
||||
// const translateX = scrollX.value;
|
||||
|
|
@ -597,7 +745,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
// transform: [{ translateX: parallaxOffset }],
|
||||
// };
|
||||
// });
|
||||
|
||||
|
||||
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
|
||||
// const infoParallaxStyle = useAnimatedStyle(() => {
|
||||
// const translateX = scrollX.value;
|
||||
|
|
@ -618,21 +766,21 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
// opacity: clampedOpacity,
|
||||
// };
|
||||
// });
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (bannerLoaded) {
|
||||
bannerOpacity.value = withTiming(1, {
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.ease)
|
||||
bannerOpacity.value = withTiming(1, {
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.ease)
|
||||
});
|
||||
}
|
||||
}, [bannerLoaded]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (logoLoaded) {
|
||||
logoOpacity.value = withTiming(1, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease)
|
||||
logoOpacity.value = withTiming(1, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease)
|
||||
});
|
||||
}
|
||||
}, [logoLoaded]);
|
||||
|
|
@ -669,11 +817,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
onLoad={() => setBannerLoaded(true)}
|
||||
/>
|
||||
</Animated.View>
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.18)", "rgba(0,0,0,0.72)"]}
|
||||
locations={[0.3, 1]}
|
||||
style={styles.bannerGradient as ViewStyle}
|
||||
/>
|
||||
{/* Overlay removed for performance - readability via text shadows */}
|
||||
</View>
|
||||
<View style={styles.backContent as ViewStyle}>
|
||||
{item.logo && !logoFailed ? (
|
||||
|
|
@ -706,7 +850,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
textShadowRadius: 2,
|
||||
}
|
||||
]}>
|
||||
{item.description || 'No description available'}
|
||||
{item.description || t('home.no_description')}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
|
@ -733,11 +877,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
onLoad={() => setBannerLoaded(true)}
|
||||
/>
|
||||
</Animated.View>
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
|
||||
locations={[0.4, 0.7, 1]}
|
||||
style={styles.bannerGradient as ViewStyle}
|
||||
/>
|
||||
{/* Overlay removed for performance - readability via text shadows */}
|
||||
</View>
|
||||
{item.logo && !logoFailed ? (
|
||||
<View style={styles.logoOverlay as ViewStyle} pointerEvents="none">
|
||||
|
|
@ -757,23 +897,23 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
</View>
|
||||
) : (
|
||||
<View style={styles.titleOverlay as ViewStyle} pointerEvents="none">
|
||||
<Animated.View entering={FadeIn.duration(300)}>
|
||||
<View>
|
||||
<Text style={[styles.title as TextStyle, { color: colors.highEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{item.genres && (
|
||||
<View style={styles.genresOverlay as ViewStyle} pointerEvents="none">
|
||||
<Animated.View entering={FadeIn.duration(400).delay(100)}>
|
||||
<View>
|
||||
<Animated.Text
|
||||
style={[styles.genres as TextStyle, { color: colors.mediumEmphasis, textAlign: 'center' }, overlayAnimatedStyle]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.genres.slice(0, 3).join(' • ')}
|
||||
</Animated.Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
|
@ -787,11 +927,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
style={styles.banner as any}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.25)", "rgba(0,0,0,0.85)"]}
|
||||
locations={[0.3, 1]}
|
||||
style={styles.bannerGradient as ViewStyle}
|
||||
/>
|
||||
{/* Overlay removed for performance - readability via text shadows */}
|
||||
</View>
|
||||
<View style={styles.backContent as ViewStyle}>
|
||||
{item.logo && !logoFailed ? (
|
||||
|
|
@ -823,7 +959,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
textShadowRadius: 2,
|
||||
}
|
||||
]}>
|
||||
{item.description || 'No description available'}
|
||||
{item.description || t('home.no_description')}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
|
@ -980,7 +1116,7 @@ const styles = StyleSheet.create({
|
|||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.18)'
|
||||
},
|
||||
|
||||
|
||||
info: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Dimensions
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
|
@ -50,6 +51,7 @@ interface ThisWeekEpisode {
|
|||
vote_average: number;
|
||||
still_path: string | null;
|
||||
season_poster_path: string | null;
|
||||
addonId?: string;
|
||||
// Grouping fields
|
||||
isGroup?: boolean;
|
||||
episodeCount?: number;
|
||||
|
|
@ -57,6 +59,7 @@ interface ThisWeekEpisode {
|
|||
}
|
||||
|
||||
export const ThisWeekSection = React.memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const { calendarData, loading } = useCalendarData();
|
||||
|
|
@ -175,7 +178,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
processedItems.push({
|
||||
...firstEp,
|
||||
id: `group_${firstEp.seriesId}_${firstEp.releaseDate}`, // Unique ID for the group
|
||||
title: `${group.length} New Episodes`,
|
||||
title: t('home.new_episodes', { count: group.length }),
|
||||
isReleased,
|
||||
isGroup: true,
|
||||
episodeCount: group.length,
|
||||
|
|
@ -199,7 +202,8 @@ export const ThisWeekSection = React.memo(() => {
|
|||
if (episode.isGroup) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: episode.seriesId,
|
||||
type: 'series'
|
||||
type: 'series',
|
||||
addonId: episode.addonId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -210,7 +214,8 @@ export const ThisWeekSection = React.memo(() => {
|
|||
navigation.navigate('Metadata', {
|
||||
id: episode.seriesId,
|
||||
type: 'series',
|
||||
episodeId
|
||||
episodeId,
|
||||
addonId: episode.addonId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -220,7 +225,8 @@ export const ThisWeekSection = React.memo(() => {
|
|||
navigation.navigate('Streams', {
|
||||
id: episode.seriesId,
|
||||
type: 'series',
|
||||
episodeId
|
||||
episodeId,
|
||||
addonId: episode.addonId,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -235,7 +241,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => {
|
||||
// Handle episodes without release dates gracefully
|
||||
const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null;
|
||||
const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : 'TBA';
|
||||
const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : t('home.tba');
|
||||
const isReleased = item.isReleased;
|
||||
|
||||
// Use episode still image if available, fallback to series poster
|
||||
|
|
@ -290,12 +296,12 @@ export const ThisWeekSection = React.memo(() => {
|
|||
locations={[0, 0.4, 0.7, 1]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[
|
||||
<View style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: isReleased ? currentTheme.colors.primary : 'rgba(0,0,0,0.6)' }
|
||||
]}>
|
||||
<Text style={styles.statusText}>
|
||||
{isReleased ? (item.isGroup ? 'Released' : 'New') : formattedDate}
|
||||
{isReleased ? (item.isGroup ? t('home.released') : t('home.new')) : formattedDate}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -353,7 +359,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
|
||||
}
|
||||
]}>This Week</Text>
|
||||
]}>{t('home.this_week')}</Text>
|
||||
<View style={[
|
||||
styles.titleUnderline,
|
||||
{
|
||||
|
|
@ -376,7 +382,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 16 : isTablet ? 15 : 14
|
||||
}
|
||||
]}>View All</Text>
|
||||
]}>{t('home.view_all')}</Text>
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
|
||||
|
|
@ -562,4 +568,4 @@ const styles = StyleSheet.create({
|
|||
borderWidth: 1,
|
||||
zIndex: -1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,38 @@
|
|||
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import React, { useEffect, useRef, forwardRef, useImperativeHandle, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
Animated,
|
||||
StatusBar,
|
||||
Easing,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
withSequence,
|
||||
withDelay,
|
||||
Easing,
|
||||
interpolate,
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
// Responsive breakpoints
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
};
|
||||
|
||||
interface MetadataLoadingScreenProps {
|
||||
type?: 'movie' | 'series';
|
||||
onExitComplete?: () => void;
|
||||
|
|
@ -23,44 +42,120 @@ export interface MetadataLoadingScreenRef {
|
|||
exit: () => void;
|
||||
}
|
||||
|
||||
// Animated shimmer skeleton component
|
||||
const ShimmerSkeleton = ({
|
||||
width: elementWidth,
|
||||
height: elementHeight,
|
||||
borderRadius = 8,
|
||||
marginBottom = 8,
|
||||
style = {},
|
||||
delay = 0,
|
||||
shimmerProgress,
|
||||
baseColor,
|
||||
highlightColor,
|
||||
}: {
|
||||
width: number | string;
|
||||
height: number;
|
||||
borderRadius?: number;
|
||||
marginBottom?: number;
|
||||
style?: any;
|
||||
delay?: number;
|
||||
shimmerProgress: SharedValue<number>;
|
||||
baseColor: string;
|
||||
highlightColor: string;
|
||||
}) => {
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
shimmerProgress.value,
|
||||
[0, 1],
|
||||
[-width, width]
|
||||
);
|
||||
return {
|
||||
transform: [{ translateX }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
{
|
||||
width: elementWidth,
|
||||
height: elementHeight,
|
||||
borderRadius,
|
||||
marginBottom,
|
||||
backgroundColor: baseColor,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
style
|
||||
]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'transparent',
|
||||
highlightColor,
|
||||
highlightColor,
|
||||
'transparent',
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[StyleSheet.absoluteFill, { width: width * 2 }]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, MetadataLoadingScreenProps>(({
|
||||
type = 'movie',
|
||||
onExitComplete
|
||||
}, ref) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Animation values - shimmer removed
|
||||
|
||||
// Scene transition animation values (matching tab navigator)
|
||||
const sceneOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sceneScale = useRef(new Animated.Value(0.95)).current;
|
||||
const sceneTranslateY = useRef(new Animated.Value(8)).current;
|
||||
|
||||
// Responsive sizing
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceType = useMemo(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
|
||||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
|
||||
const horizontalPadding = isTV ? 48 : isLargeTablet ? 32 : isTablet ? 24 : 16;
|
||||
|
||||
|
||||
// Shimmer animation
|
||||
const shimmerProgress = useSharedValue(0);
|
||||
|
||||
// Staggered fade-in for sections
|
||||
const heroOpacity = useSharedValue(0);
|
||||
const contentOpacity = useSharedValue(0);
|
||||
const castOpacity = useSharedValue(0);
|
||||
|
||||
// Exit animation value
|
||||
const exitProgress = useSharedValue(0);
|
||||
|
||||
// Colors for skeleton
|
||||
const baseColor = currentTheme.colors.elevation1 || 'rgba(255,255,255,0.08)';
|
||||
const highlightColor = 'rgba(255,255,255,0.12)';
|
||||
|
||||
// Exit animation function
|
||||
const exit = () => {
|
||||
const exitAnimation = Animated.parallel([
|
||||
Animated.timing(sceneOpacity, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sceneScale, {
|
||||
toValue: 0.95,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sceneTranslateY, {
|
||||
toValue: 8,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
exitAnimation.start(() => {
|
||||
onExitComplete?.();
|
||||
exitProgress.value = withTiming(1, {
|
||||
duration: 200,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
}, (finished) => {
|
||||
'worklet';
|
||||
if (finished && onExitComplete) {
|
||||
runOnJS(onExitComplete)();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -70,70 +165,57 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
|
|||
}));
|
||||
|
||||
useEffect(() => {
|
||||
// Scene entrance animation (matching tab navigator)
|
||||
const sceneAnimation = Animated.parallel([
|
||||
Animated.timing(sceneOpacity, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
// Start shimmer animation
|
||||
shimmerProgress.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration: 1500,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0)
|
||||
}),
|
||||
Animated.timing(sceneScale, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sceneTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]);
|
||||
-1, // infinite
|
||||
false
|
||||
);
|
||||
|
||||
sceneAnimation.start();
|
||||
|
||||
// Shimmer effect removed
|
||||
// Staggered entrance animations
|
||||
heroOpacity.value = withTiming(1, { duration: 300 });
|
||||
contentOpacity.value = withDelay(100, withTiming(1, { duration: 300 }));
|
||||
castOpacity.value = withDelay(200, withTiming(1, { duration: 300 }));
|
||||
|
||||
return () => {
|
||||
sceneAnimation.stop();
|
||||
cancelAnimation(shimmerProgress);
|
||||
cancelAnimation(heroOpacity);
|
||||
cancelAnimation(contentOpacity);
|
||||
cancelAnimation(castOpacity);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Shimmer translate removed
|
||||
// Animated styles
|
||||
const containerStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(exitProgress.value, [0, 1], [1, 0]),
|
||||
transform: [
|
||||
{ scale: interpolate(exitProgress.value, [0, 1], [1, 0.98]) },
|
||||
],
|
||||
}));
|
||||
|
||||
const SkeletonElement = ({
|
||||
width: elementWidth,
|
||||
height: elementHeight,
|
||||
borderRadius = 8,
|
||||
marginBottom = 8,
|
||||
style = {},
|
||||
}: {
|
||||
width: number | string;
|
||||
height: number;
|
||||
borderRadius?: number;
|
||||
marginBottom?: number;
|
||||
style?: any;
|
||||
}) => (
|
||||
<View style={[
|
||||
{
|
||||
width: elementWidth,
|
||||
height: elementHeight,
|
||||
borderRadius,
|
||||
marginBottom,
|
||||
backgroundColor: currentTheme.colors.card,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
style
|
||||
]}>
|
||||
{/* Pulsating overlay removed */}
|
||||
{/* Shimmer overlay removed */}
|
||||
</View>
|
||||
);
|
||||
const heroStyle = useAnimatedStyle(() => ({
|
||||
opacity: heroOpacity.value,
|
||||
}));
|
||||
|
||||
const contentStyle = useAnimatedStyle(() => ({
|
||||
opacity: contentOpacity.value,
|
||||
transform: [
|
||||
{ translateY: interpolate(contentOpacity.value, [0, 1], [10, 0]) },
|
||||
],
|
||||
}));
|
||||
|
||||
const castStyle = useAnimatedStyle(() => ({
|
||||
opacity: castOpacity.value,
|
||||
transform: [
|
||||
{ translateY: interpolate(castOpacity.value, [0, 1], [10, 0]) },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
<SafeAreaView
|
||||
style={[styles.container, {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
}]}
|
||||
|
|
@ -144,107 +226,325 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
|
|||
backgroundColor="transparent"
|
||||
barStyle="light-content"
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
opacity: sceneOpacity,
|
||||
transform: [
|
||||
{ scale: sceneScale },
|
||||
{ translateY: sceneTranslateY }
|
||||
],
|
||||
}
|
||||
]}
|
||||
>
|
||||
{/* Hero Skeleton */}
|
||||
<View style={styles.heroSection}>
|
||||
<SkeletonElement
|
||||
width="100%"
|
||||
height={height * 0.6}
|
||||
|
||||
<Animated.View style={[styles.content, containerStyle]}>
|
||||
{/* Hero Section Skeleton */}
|
||||
<Animated.View style={[styles.heroSection, { height: height * 0.65 }, heroStyle]}>
|
||||
<ShimmerSkeleton
|
||||
width="100%"
|
||||
height={height * 0.65}
|
||||
borderRadius={0}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
|
||||
{/* Overlay content on hero */}
|
||||
|
||||
{/* Back Button Skeleton */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'android' ? 40 : 50,
|
||||
left: isTablet ? 32 : 16,
|
||||
zIndex: 10
|
||||
}}>
|
||||
<ShimmerSkeleton
|
||||
width={40}
|
||||
height={40}
|
||||
borderRadius={20}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<View style={styles.heroOverlay}>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'transparent',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.8)',
|
||||
'rgba(0,0,0,0.05)',
|
||||
'rgba(0,0,0,0.15)',
|
||||
'rgba(0,0,0,0.35)',
|
||||
'rgba(0,0,0,0.65)',
|
||||
currentTheme.colors.darkBackground,
|
||||
]}
|
||||
locations={[0, 0.3, 0.55, 0.75, 0.9, 1]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{/* Bottom hero content skeleton */}
|
||||
<View style={styles.heroBottomContent}>
|
||||
<SkeletonElement width="60%" height={32} borderRadius={16} />
|
||||
<SkeletonElement width="40%" height={20} borderRadius={10} />
|
||||
<View style={styles.genresRow}>
|
||||
<SkeletonElement width={80} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} />
|
||||
<SkeletonElement width={90} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} />
|
||||
<SkeletonElement width={70} height={24} borderRadius={12} marginBottom={0} />
|
||||
{/* Hero bottom content - Matches HeroSection.tsx structure */}
|
||||
<View style={[styles.heroBottomContent, { paddingHorizontal: horizontalPadding }]}>
|
||||
{/* Logo placeholder - Centered and larger */}
|
||||
<View style={{ alignItems: 'center', width: '100%', marginBottom: 16 }}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 400 : isLargeTablet ? 300 : width * 0.65}
|
||||
height={isTV ? 120 : isLargeTablet ? 100 : 90}
|
||||
borderRadius={12}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.buttonsRow}>
|
||||
<SkeletonElement width={120} height={44} borderRadius={22} marginBottom={0} style={{ marginRight: 12 }} />
|
||||
<SkeletonElement width={100} height={44} borderRadius={22} marginBottom={0} />
|
||||
|
||||
{/* Watch Progress Placeholder - Centered Glass Bar */}
|
||||
<View style={{ alignItems: 'center', width: '100%', marginBottom: 16 }}>
|
||||
<ShimmerSkeleton
|
||||
width="75%"
|
||||
height={45} // Matches glass background height + padding
|
||||
borderRadius={12}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
style={{ opacity: 0.5 }} // Slight transparency for glass effect
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Genre Info Row - Centered */}
|
||||
<View style={[styles.metaRow, { justifyContent: 'center', marginBottom: 20 }]}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 60 : 50}
|
||||
height={12}
|
||||
borderRadius={6}
|
||||
marginBottom={0}
|
||||
style={{ marginRight: 8 }}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)', marginRight: 8 }} />
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 80 : 70}
|
||||
height={12}
|
||||
borderRadius={6}
|
||||
marginBottom={0}
|
||||
style={{ marginRight: 8 }}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)', marginRight: 8 }} />
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 50 : 40}
|
||||
height={12}
|
||||
borderRadius={6}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Action buttons row - Play, Save, Collection, Rates */}
|
||||
<View style={[styles.buttonsRow, { justifyContent: 'center', gap: 6 }]}>
|
||||
{/* Play Button */}
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 180 : isLargeTablet ? 160 : isTablet ? 150 : (width - 32 - 100 - 24) / 2} // Calc based on screen width
|
||||
height={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
borderRadius={isTV ? 26 : 23}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
|
||||
{/* Save Button */}
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 180 : isLargeTablet ? 160 : isTablet ? 150 : (width - 32 - 100 - 24) / 2}
|
||||
height={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
borderRadius={isTV ? 26 : 23}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
|
||||
{/* Collection Icon */}
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
height={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
borderRadius={isTV ? 26 : 23}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
|
||||
{/* Ratings Icon (if series) - Always show for skeleton consistency */}
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
height={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
borderRadius={isTV ? 26 : 23}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Content Section Skeletons */}
|
||||
<View style={styles.contentSection}>
|
||||
{/* Synopsis skeleton */}
|
||||
<View style={styles.synopsisSection}>
|
||||
<SkeletonElement width="30%" height={24} borderRadius={12} />
|
||||
<SkeletonElement width="100%" height={16} borderRadius={8} />
|
||||
<SkeletonElement width="95%" height={16} borderRadius={8} />
|
||||
<SkeletonElement width="80%" height={16} borderRadius={8} />
|
||||
{/* Content Section */}
|
||||
<Animated.View style={[styles.contentSection, { paddingHorizontal: horizontalPadding }, contentStyle]}>
|
||||
{/* Description skeleton */}
|
||||
<View style={styles.descriptionSection}>
|
||||
<ShimmerSkeleton
|
||||
width="100%"
|
||||
height={isTV ? 18 : 15}
|
||||
borderRadius={4}
|
||||
marginBottom={10}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<ShimmerSkeleton
|
||||
width="95%"
|
||||
height={isTV ? 18 : 15}
|
||||
borderRadius={4}
|
||||
marginBottom={10}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<ShimmerSkeleton
|
||||
width="75%"
|
||||
height={isTV ? 18 : 15}
|
||||
borderRadius={4}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Cast section skeleton */}
|
||||
<View style={styles.castSection}>
|
||||
<SkeletonElement width="20%" height={24} borderRadius={12} />
|
||||
<View style={styles.castRow}>
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<View key={item} style={styles.castItem}>
|
||||
<SkeletonElement width={80} height={80} borderRadius={40} marginBottom={8} />
|
||||
<SkeletonElement width={60} height={12} borderRadius={6} marginBottom={4} />
|
||||
<SkeletonElement width={70} height={10} borderRadius={5} marginBottom={0} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* Cast Section */}
|
||||
<Animated.View style={[styles.castSection, { paddingHorizontal: horizontalPadding }, castStyle]}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 80 : 60}
|
||||
height={isTV ? 24 : 20}
|
||||
borderRadius={4}
|
||||
marginBottom={16}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<View style={styles.castRow}>
|
||||
{[1, 2, 3, 4, 5].map((item) => (
|
||||
<View key={item} style={styles.castItem}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80}
|
||||
height={isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80}
|
||||
borderRadius={isTV ? 50 : isLargeTablet ? 45 : isTablet ? 42 : 40}
|
||||
marginBottom={8}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 70 : 60}
|
||||
height={isTV ? 14 : 12}
|
||||
borderRadius={4}
|
||||
marginBottom={4}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Episodes/Details skeleton based on type */}
|
||||
{type === 'series' ? (
|
||||
<View style={styles.episodesSection}>
|
||||
<SkeletonElement width="25%" height={24} borderRadius={12} />
|
||||
<SkeletonElement width={150} height={36} borderRadius={18} />
|
||||
{/* Episodes/Recommendations Section */}
|
||||
{type === 'series' ? (
|
||||
<Animated.View style={[styles.episodesSection, { paddingHorizontal: horizontalPadding }, castStyle]}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 120 : 100}
|
||||
height={isTV ? 24 : 20}
|
||||
borderRadius={4}
|
||||
marginBottom={16}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
{/* Season selector */}
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 180 : 140}
|
||||
height={isTV ? 40 : 36}
|
||||
borderRadius={20}
|
||||
marginBottom={20}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
{/* Episode cards */}
|
||||
<View style={styles.episodeList}>
|
||||
{[1, 2, 3].map((item) => (
|
||||
<View key={item} style={styles.episodeItem}>
|
||||
<SkeletonElement width={120} height={68} borderRadius={8} marginBottom={0} style={{ marginRight: 12 }} />
|
||||
<View key={item} style={styles.episodeCard}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 140}
|
||||
height={isTV ? 112 : isLargeTablet ? 100 : isTablet ? 90 : 80}
|
||||
borderRadius={8}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<View style={styles.episodeInfo}>
|
||||
<SkeletonElement width="80%" height={16} borderRadius={8} />
|
||||
<SkeletonElement width="60%" height={14} borderRadius={7} />
|
||||
<SkeletonElement width="90%" height={12} borderRadius={6} />
|
||||
<ShimmerSkeleton
|
||||
width="80%"
|
||||
height={isTV ? 16 : 14}
|
||||
borderRadius={4}
|
||||
marginBottom={6}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<ShimmerSkeleton
|
||||
width="60%"
|
||||
height={isTV ? 14 : 12}
|
||||
borderRadius={4}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.detailsSection}>
|
||||
<SkeletonElement width="25%" height={24} borderRadius={12} />
|
||||
<View style={styles.detailsGrid}>
|
||||
<SkeletonElement width="48%" height={60} borderRadius={8} />
|
||||
<SkeletonElement width="48%" height={60} borderRadius={8} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View style={[styles.recommendationsSection, { paddingHorizontal: horizontalPadding }, castStyle]}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 140 : 110}
|
||||
height={isTV ? 24 : 20}
|
||||
borderRadius={4}
|
||||
marginBottom={16}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<View style={styles.posterRow}>
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<ShimmerSkeleton
|
||||
key={item}
|
||||
width={isTV ? 140 : isLargeTablet ? 120 : isTablet ? 110 : 100}
|
||||
height={isTV ? 210 : isLargeTablet ? 180 : isTablet ? 165 : 150}
|
||||
borderRadius={8}
|
||||
marginBottom={0}
|
||||
style={{ marginRight: 12 }}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
|
@ -258,7 +558,6 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
},
|
||||
heroSection: {
|
||||
height: height * 0.6,
|
||||
position: 'relative',
|
||||
},
|
||||
heroOverlay: {
|
||||
|
|
@ -266,54 +565,52 @@ const styles = StyleSheet.create({
|
|||
justifyContent: 'flex-end',
|
||||
},
|
||||
heroBottomContent: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
genresRow: {
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
buttonsRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
contentSection: {
|
||||
padding: 20,
|
||||
paddingTop: 16,
|
||||
},
|
||||
synopsisSection: {
|
||||
marginBottom: 32,
|
||||
descriptionSection: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
castSection: {
|
||||
marginBottom: 32,
|
||||
marginBottom: 24,
|
||||
},
|
||||
castRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 16,
|
||||
},
|
||||
castItem: {
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
episodesSection: {
|
||||
marginBottom: 32,
|
||||
marginBottom: 24,
|
||||
},
|
||||
episodeItem: {
|
||||
episodeList: {
|
||||
gap: 16,
|
||||
},
|
||||
episodeCard: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
episodeInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
detailsSection: {
|
||||
marginBottom: 32,
|
||||
recommendationsSection: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
detailsGrid: {
|
||||
posterRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 16,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
BIN
src/components/metadata/.HeroSection.tsx.swp
Normal file
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -70,6 +71,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
onClose,
|
||||
castMember,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const [personDetails, setPersonDetails] = useState<PersonDetails | null>(null);
|
||||
|
|
@ -82,14 +84,14 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
if (visible && castMember) {
|
||||
modalOpacity.value = withTiming(1, { duration: 250 });
|
||||
modalScale.value = withSpring(1, { damping: 20, stiffness: 200 });
|
||||
|
||||
|
||||
if (!hasFetched || personDetails?.id !== castMember.id) {
|
||||
fetchPersonDetails();
|
||||
}
|
||||
} else {
|
||||
modalOpacity.value = withTiming(0, { duration: 200 });
|
||||
modalScale.value = withTiming(0.9, { duration: 200 });
|
||||
|
||||
|
||||
if (!visible) {
|
||||
setHasFetched(false);
|
||||
setPersonDetails(null);
|
||||
|
|
@ -99,7 +101,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
|
||||
const fetchPersonDetails = async () => {
|
||||
if (!castMember || loading) return;
|
||||
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const details = await tmdbService.getPersonDetails(castMember.id);
|
||||
|
|
@ -150,11 +152,11 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
const birthDate = new Date(birthday);
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
|
||||
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--;
|
||||
}
|
||||
|
||||
|
||||
return age;
|
||||
};
|
||||
|
||||
|
|
@ -196,8 +198,8 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
height: MODAL_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
borderRadius: isTablet ? 32 : 24,
|
||||
backgroundColor: Platform.OS === 'android'
|
||||
? 'rgba(20, 20, 20, 0.95)'
|
||||
backgroundColor: Platform.OS === 'android'
|
||||
? 'rgba(20, 20, 20, 0.95)'
|
||||
: 'transparent',
|
||||
},
|
||||
modalStyle,
|
||||
|
|
@ -280,7 +282,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{
|
||||
color: '#fff',
|
||||
|
|
@ -296,7 +298,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
fontSize: isTablet ? 14 : 13,
|
||||
fontWeight: '500',
|
||||
}} numberOfLines={2}>
|
||||
as {castMember.character}
|
||||
{t('cast.as_character', { character: castMember.character })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
|
@ -336,7 +338,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
fontSize: 14,
|
||||
marginTop: 12,
|
||||
}}>
|
||||
Loading details...
|
||||
{t('cast.loading_details')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
|
|
@ -352,8 +354,8 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
borderColor: 'rgba(255, 255, 255, 0.06)',
|
||||
}}>
|
||||
{personDetails?.birthday && (
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: personDetails?.place_of_birth ? 10 : 0
|
||||
}}>
|
||||
|
|
@ -369,7 +371,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{calculateAge(personDetails.birthday)} years old
|
||||
{t('cast.years_old', { age: calculateAge(personDetails.birthday) })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -389,7 +391,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
fontWeight: '500',
|
||||
flex: 1,
|
||||
}}>
|
||||
Born in {personDetails.place_of_birth}
|
||||
{t('cast.born_in', { place: personDetails.place_of_birth })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -420,7 +422,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
}}>
|
||||
View Filmography
|
||||
{t('cast.view_filmography')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -454,7 +456,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
Also Known As
|
||||
{t('cast.also_known_as')}
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
|
|
@ -480,7 +482,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
No additional information available
|
||||
{t('cast.no_info_available')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ActivityIndicator,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
|
|
@ -35,6 +36,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
|
|||
onSelectCastMember,
|
||||
isTmdbEnrichmentEnabled = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Enhanced responsive sizing for tablets and TV screens
|
||||
|
|
@ -137,7 +139,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
|
|||
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18,
|
||||
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>Cast</Text>
|
||||
]}>{t('metadata.cast')}</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ActivityIndicator,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useNavigation, StackActions } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
|
|
@ -39,6 +40,7 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
|
|||
collectionMovies,
|
||||
loadingCollection
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
||||
|
|
@ -109,9 +111,9 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error navigating to collection item:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Unable to load this content. Please try again later.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => {} }]);
|
||||
setAlertTitle(t('common.error'));
|
||||
setAlertMessage(t('metadata.something_went_wrong'));
|
||||
setAlertActions([{ label: t('common.ok'), onPress: () => {} }]);
|
||||
setAlertVisible(true);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Animated,
|
||||
Linking,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import TraktIcon from '../../../assets/rating-icons/trakt.svg';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
|
@ -186,6 +187,7 @@ const CompactCommentCard: React.FC<{
|
|||
isSpoilerRevealed: boolean;
|
||||
onSpoilerPress: () => void;
|
||||
}> = ({ comment, theme, onPress, isSpoilerRevealed, onSpoilerPress }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const fadeInOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
|
|
@ -200,7 +202,7 @@ const CompactCommentCard: React.FC<{
|
|||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceHeight = Dimensions.get('window').height;
|
||||
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
|
|
@ -208,13 +210,13 @@ const CompactCommentCard: React.FC<{
|
|||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||
|
||||
|
||||
// Enhanced comment card sizing
|
||||
const commentCardWidth = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -228,7 +230,7 @@ const CompactCommentCard: React.FC<{
|
|||
return 280; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const commentCardHeight = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -241,7 +243,7 @@ const CompactCommentCard: React.FC<{
|
|||
return 170; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const commentCardSpacing = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -262,7 +264,7 @@ const CompactCommentCard: React.FC<{
|
|||
|
||||
// Handle missing user data gracefully
|
||||
const user = comment.user || {};
|
||||
const username = user.name || user.username || 'Anonymous';
|
||||
const username = user.name || user.username || t('common.anonymous_user');
|
||||
|
||||
// Handle spoiler content
|
||||
const hasSpoiler = comment.spoiler;
|
||||
|
|
@ -280,10 +282,10 @@ const CompactCommentCard: React.FC<{
|
|||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
if (diffMins < 1) return t('common.time.now');
|
||||
if (diffMins < 60) return t('common.time.minutes_ago', { count: diffMins });
|
||||
if (diffHours < 24) return t('common.time.hours_ago', { count: diffHours });
|
||||
if (diffDays < 7) return t('common.time.days_ago', { count: diffDays });
|
||||
|
||||
// For older dates, show month/day
|
||||
return commentDate.toLocaleDateString('en-US', {
|
||||
|
|
@ -354,156 +356,156 @@ const CompactCommentCard: React.FC<{
|
|||
}}
|
||||
activeOpacity={1}
|
||||
>
|
||||
{/* Trakt Icon - Top Right Corner */}
|
||||
<View style={styles.traktIconContainer}>
|
||||
<TraktIcon width={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} height={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} />
|
||||
</View>
|
||||
|
||||
{/* Header Section - Fixed at top */}
|
||||
<View style={[
|
||||
styles.compactHeader,
|
||||
{
|
||||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
<View style={styles.usernameContainer}>
|
||||
<Text style={[
|
||||
styles.compactUsername,
|
||||
{
|
||||
color: theme.colors.highEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={[
|
||||
styles.miniVipBadge,
|
||||
{
|
||||
paddingHorizontal: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
|
||||
paddingVertical: isTV ? 2 : isLargeTablet ? 2 : isTablet ? 1 : 1,
|
||||
borderRadius: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 6
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.miniVipText,
|
||||
{
|
||||
fontSize: isTV ? 11 : isLargeTablet ? 10 : isTablet ? 9 : 9
|
||||
}
|
||||
]}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Trakt Icon - Top Right Corner */}
|
||||
<View style={styles.traktIconContainer}>
|
||||
<TraktIcon width={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} height={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Rating - Show stars */}
|
||||
{comment.user_stats?.rating && (
|
||||
{/* Header Section - Fixed at top */}
|
||||
<View style={[
|
||||
styles.compactRating,
|
||||
styles.compactHeader,
|
||||
{
|
||||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
{renderCompactStars(comment.user_stats.rating)}
|
||||
<Text style={[
|
||||
styles.compactRatingText,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
|
||||
<View style={styles.usernameContainer}>
|
||||
<Text style={[
|
||||
styles.compactUsername,
|
||||
{
|
||||
color: theme.colors.highEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={[
|
||||
styles.miniVipBadge,
|
||||
{
|
||||
paddingHorizontal: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
|
||||
paddingVertical: isTV ? 2 : isLargeTablet ? 2 : isTablet ? 1 : 1,
|
||||
borderRadius: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 6
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.miniVipText,
|
||||
{
|
||||
fontSize: isTV ? 11 : isLargeTablet ? 10 : isTablet ? 9 : 9
|
||||
}
|
||||
]}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Rating - Show stars */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={[
|
||||
styles.compactRating,
|
||||
{
|
||||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{renderCompactStars(comment.user_stats.rating)}
|
||||
<Text style={[
|
||||
styles.compactRatingText,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
|
||||
}
|
||||
]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Preview - Flexible area that fills space */}
|
||||
<View style={[
|
||||
styles.commentContainer,
|
||||
shouldBlurContent ? styles.blurredContent : undefined,
|
||||
{
|
||||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
{shouldBlurContent ? (
|
||||
<Text style={[
|
||||
styles.compactComment,
|
||||
{
|
||||
color: theme.colors.highEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
|
||||
}
|
||||
]}>⚠️ This comment contains spoilers. Tap to reveal.</Text>
|
||||
) : (
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
numberOfLines={isLargeScreen ? 4 : 3}
|
||||
revealedInlineSpoilers={isSpoilerRevealed}
|
||||
onSpoilerPress={onSpoilerPress}
|
||||
textStyle={[
|
||||
{/* Comment Preview - Flexible area that fills space */}
|
||||
<View style={[
|
||||
styles.commentContainer,
|
||||
shouldBlurContent ? styles.blurredContent : undefined,
|
||||
{
|
||||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
{shouldBlurContent ? (
|
||||
<Text style={[
|
||||
styles.compactComment,
|
||||
{
|
||||
color: theme.colors.highEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
]}>⚠️ This comment contains spoilers. Tap to reveal.</Text>
|
||||
) : (
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
numberOfLines={isLargeScreen ? 4 : 3}
|
||||
revealedInlineSpoilers={isSpoilerRevealed}
|
||||
onSpoilerPress={onSpoilerPress}
|
||||
textStyle={[
|
||||
styles.compactComment,
|
||||
{
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Meta Info - Fixed at bottom */}
|
||||
<View style={[
|
||||
styles.compactMeta,
|
||||
{
|
||||
paddingTop: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 6
|
||||
}
|
||||
]}>
|
||||
<View style={styles.compactBadges}>
|
||||
{comment.spoiler && (
|
||||
{/* Meta Info - Fixed at bottom */}
|
||||
<View style={[
|
||||
styles.compactMeta,
|
||||
{
|
||||
paddingTop: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 6
|
||||
}
|
||||
]}>
|
||||
<View style={styles.compactBadges}>
|
||||
{comment.spoiler && (
|
||||
<Text style={[
|
||||
styles.spoilerMiniText,
|
||||
{
|
||||
color: theme.colors.error,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
|
||||
}
|
||||
]}>Spoiler</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.compactStats}>
|
||||
<Text style={[
|
||||
styles.spoilerMiniText,
|
||||
{
|
||||
color: theme.colors.error,
|
||||
styles.compactTime,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
|
||||
}
|
||||
]}>Spoiler</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.compactStats}>
|
||||
<Text style={[
|
||||
styles.compactTime,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
|
||||
}
|
||||
]}>
|
||||
{formatRelativeTime(comment.created_at)}
|
||||
</Text>
|
||||
{comment.likes > 0 && (
|
||||
<Text style={[
|
||||
styles.compactStat,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
👍 {comment.likes}
|
||||
{formatRelativeTime(comment.created_at)}
|
||||
</Text>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<Text style={[
|
||||
styles.compactStat,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
💬 {comment.replies}
|
||||
</Text>
|
||||
)}
|
||||
{comment.likes > 0 && (
|
||||
<Text style={[
|
||||
styles.compactStat,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
👍 {comment.likes}
|
||||
</Text>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<Text style={[
|
||||
styles.compactStat,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
💬 {comment.replies}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
|
|
@ -614,105 +616,105 @@ const ExpandedCommentBottomSheet: React.FC<{
|
|||
nestedScrollEnabled
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.highEmphasis} />
|
||||
</TouchableOpacity>
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.highEmphasis} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* User Info */}
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.userInfo}>
|
||||
<Text
|
||||
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.vipBadge}>
|
||||
<Text style={styles.vipText}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{(() => {
|
||||
const { datePart, timePart } = formatDateParts(comment.created_at);
|
||||
return (
|
||||
<View style={styles.dateTimeContainer}>
|
||||
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
|
||||
{datePart}
|
||||
</Text>
|
||||
{!!timePart && (
|
||||
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
|
||||
{timePart}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Rating */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={styles.modalRating}>
|
||||
{renderStars(comment.user_stats.rating)}
|
||||
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Full Comment (Markdown with inline spoilers) */}
|
||||
{shouldBlurModalContent ? (
|
||||
<View style={styles.spoilerContainer}>
|
||||
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
|
||||
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
|
||||
{/* User Info */}
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.userInfo}>
|
||||
<Text
|
||||
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.vipBadge}>
|
||||
<Text style={styles.vipText}>VIP</Text>
|
||||
</View>
|
||||
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
|
||||
onPress={onSpoilerPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
revealedInlineSpoilers={true}
|
||||
textStyle={styles.modalComment}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Meta */}
|
||||
<View style={styles.modalMeta}>
|
||||
{comment.spoiler && (
|
||||
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
|
||||
)}
|
||||
<View style={styles.modalStats}>
|
||||
{comment.likes > 0 && (
|
||||
<View style={styles.likesContainer}>
|
||||
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.likes}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<View style={styles.repliesContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.replies}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{(() => {
|
||||
const { datePart, timePart } = formatDateParts(comment.created_at);
|
||||
return (
|
||||
<View style={styles.dateTimeContainer}>
|
||||
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
|
||||
{datePart}
|
||||
</Text>
|
||||
{!!timePart && (
|
||||
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
|
||||
{timePart}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Rating */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={styles.modalRating}>
|
||||
{renderStars(comment.user_stats.rating)}
|
||||
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Full Comment (Markdown with inline spoilers) */}
|
||||
{shouldBlurModalContent ? (
|
||||
<View style={styles.spoilerContainer}>
|
||||
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
|
||||
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
|
||||
</View>
|
||||
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
|
||||
onPress={onSpoilerPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
revealedInlineSpoilers={true}
|
||||
textStyle={styles.modalComment}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Meta */}
|
||||
<View style={styles.modalMeta}>
|
||||
{comment.spoiler && (
|
||||
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
|
||||
)}
|
||||
<View style={styles.modalStats}>
|
||||
{comment.likes > 0 && (
|
||||
<View style={styles.likesContainer}>
|
||||
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.likes}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<View style={styles.repliesContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.replies}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
|
@ -725,6 +727,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
episode,
|
||||
onCommentPress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = React.useState(false);
|
||||
|
|
@ -732,7 +735,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceHeight = Dimensions.get('window').height;
|
||||
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
|
|
@ -740,13 +743,13 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||
|
||||
|
||||
// Enhanced spacing and padding
|
||||
const horizontalPadding = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -772,7 +775,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
} = useTraktComments({
|
||||
imdbId,
|
||||
type: type === 'show' ? (season !== undefined && episode !== undefined ? 'episode' :
|
||||
season !== undefined ? 'season' : 'show') : 'movie',
|
||||
season !== undefined ? 'season' : 'show') : 'movie',
|
||||
season,
|
||||
episode,
|
||||
enabled: true,
|
||||
|
|
@ -823,12 +826,12 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
<View style={styles.emptyContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{error ? 'Comments unavailable' : 'No comments on Trakt yet'}
|
||||
{error ? t('comments.unavailable') : t('comments.no_comments')}
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
|
||||
{error
|
||||
? 'This content may not be in Trakt\'s database yet'
|
||||
: 'Be the first to comment on Trakt.tv'
|
||||
? t('comments.not_in_database')
|
||||
: t('comments.check_trakt')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -924,13 +927,13 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.title,
|
||||
{
|
||||
styles.title,
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
|
||||
}
|
||||
]}>
|
||||
Trakt Comments
|
||||
{t('comments.title')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
|
@ -945,7 +948,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
onPress={refresh}
|
||||
>
|
||||
<Text style={[styles.retryButtonText, { color: currentTheme.colors.error }]}>
|
||||
Retry
|
||||
{t('common.retry')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -992,8 +995,8 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
|
||||
Load More
|
||||
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
|
||||
{t('common.load_more')}
|
||||
</Text>
|
||||
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} />
|
||||
</>
|
||||
|
|
@ -1022,15 +1025,19 @@ export const CommentBottomSheet: React.FC<{
|
|||
}> = ({ comment, visible, onClose, theme, isSpoilerRevealed, onSpoilerPress }) => {
|
||||
const bottomSheetRef = useRef<BottomSheet>(null);
|
||||
|
||||
// Early return before any Reanimated components are rendered
|
||||
// This prevents the BottomSheet from initializing when not needed
|
||||
if (!visible || !comment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('CommentBottomSheet: Rendered with visible:', visible, 'comment:', comment?.id);
|
||||
|
||||
// Calculate the index based on visibility - start at medium height (50%)
|
||||
const sheetIndex = visible && comment ? 1 : -1;
|
||||
const sheetIndex = 1; // Always 1 when visible and comment are truthy
|
||||
|
||||
console.log('CommentBottomSheet: Calculated sheetIndex:', sheetIndex);
|
||||
|
||||
if (!comment) return null;
|
||||
|
||||
const user = comment.user || {};
|
||||
const username = user.name || user.username || 'Anonymous User';
|
||||
const hasSpoiler = comment.spoiler;
|
||||
|
|
@ -1115,100 +1122,100 @@ export const CommentBottomSheet: React.FC<{
|
|||
nestedScrollEnabled
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* User Info */}
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.userInfo}>
|
||||
<Text
|
||||
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.vipBadge}>
|
||||
<Text style={styles.vipText}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{(() => {
|
||||
const { datePart, timePart } = formatDateParts(comment.created_at);
|
||||
return (
|
||||
<View style={styles.dateTimeContainer}>
|
||||
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
|
||||
{datePart}
|
||||
</Text>
|
||||
{!!timePart && (
|
||||
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
|
||||
{timePart}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Rating */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={styles.modalRating}>
|
||||
{renderStars(comment.user_stats.rating)}
|
||||
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Full Comment (Markdown with inline spoilers) */}
|
||||
{shouldBlurModalContent ? (
|
||||
<View style={styles.spoilerContainer}>
|
||||
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
|
||||
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
|
||||
{/* User Info */}
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.userInfo}>
|
||||
<Text
|
||||
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.vipBadge}>
|
||||
<Text style={styles.vipText}>VIP</Text>
|
||||
</View>
|
||||
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
|
||||
onPress={onSpoilerPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
revealedInlineSpoilers={true}
|
||||
textStyle={styles.modalComment}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Meta */}
|
||||
<View style={styles.modalMeta}>
|
||||
{comment.spoiler && (
|
||||
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
|
||||
)}
|
||||
<View style={styles.modalStats}>
|
||||
{comment.likes > 0 && (
|
||||
<View style={styles.likesContainer}>
|
||||
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.likes}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<View style={styles.repliesContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.replies}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{(() => {
|
||||
const { datePart, timePart } = formatDateParts(comment.created_at);
|
||||
return (
|
||||
<View style={styles.dateTimeContainer}>
|
||||
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
|
||||
{datePart}
|
||||
</Text>
|
||||
{!!timePart && (
|
||||
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
|
||||
{timePart}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Rating */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={styles.modalRating}>
|
||||
{renderStars(comment.user_stats.rating)}
|
||||
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Full Comment (Markdown with inline spoilers) */}
|
||||
{shouldBlurModalContent ? (
|
||||
<View style={styles.spoilerContainer}>
|
||||
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
|
||||
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
|
||||
</View>
|
||||
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
|
||||
onPress={onSpoilerPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
revealedInlineSpoilers={true}
|
||||
textStyle={styles.modalComment}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Meta */}
|
||||
<View style={styles.modalMeta}>
|
||||
{comment.spoiler && (
|
||||
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
|
||||
)}
|
||||
<View style={styles.modalStats}>
|
||||
{comment.likes > 0 && (
|
||||
<View style={styles.likesContainer}>
|
||||
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.likes}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<View style={styles.repliesContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.replies}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceHeight = Dimensions.get('window').height;
|
||||
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
|
|
@ -67,13 +67,13 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||
|
||||
|
||||
// Enhanced spacing and padding
|
||||
const horizontalPadding = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -89,8 +89,11 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
}, [deviceType]);
|
||||
|
||||
// Animation values for smooth height transition
|
||||
const animatedHeight = useSharedValue(0);
|
||||
// Start with a reasonable default height (3 lines * 24px line height = 72px) to prevent layout shift
|
||||
const defaultCollapsedHeight = isTV ? 84 : isLargeTablet ? 78 : isTablet ? 72 : 72;
|
||||
const animatedHeight = useSharedValue(defaultCollapsedHeight);
|
||||
const [measuredHeights, setMeasuredHeights] = useState({ collapsed: 0, expanded: 0 });
|
||||
const [hasInitialMeasurement, setHasInitialMeasurement] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMDBListEnabled = async () => {
|
||||
|
|
@ -101,7 +104,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
setIsMDBEnabled(false); // Default to disabled if there's an error
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
checkMDBListEnabled();
|
||||
}, []);
|
||||
|
||||
|
|
@ -114,6 +117,12 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
const handleCollapsedTextLayout = (event: any) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setMeasuredHeights(prev => ({ ...prev, collapsed: height }));
|
||||
// Only set initial measurement flag once we have a valid height
|
||||
if (height > 0 && !hasInitialMeasurement) {
|
||||
setHasInitialMeasurement(true);
|
||||
// Update animated height immediately without animation for first measurement
|
||||
animatedHeight.value = height;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandedTextLayout = (event: any) => {
|
||||
|
|
@ -128,49 +137,53 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
setIsFullDescriptionOpen(!isFullDescriptionOpen);
|
||||
};
|
||||
|
||||
// Initialize height when component mounts or text changes
|
||||
// Update height when measurements change (only after initial measurement)
|
||||
useEffect(() => {
|
||||
if (measuredHeights.collapsed > 0) {
|
||||
animatedHeight.value = measuredHeights.collapsed;
|
||||
if (measuredHeights.collapsed > 0 && hasInitialMeasurement && !isFullDescriptionOpen) {
|
||||
// Only animate if the height actually changed significantly
|
||||
const currentHeight = animatedHeight.value;
|
||||
if (Math.abs(currentHeight - measuredHeights.collapsed) > 5) {
|
||||
animatedHeight.value = measuredHeights.collapsed;
|
||||
}
|
||||
}
|
||||
}, [measuredHeights.collapsed]);
|
||||
}, [measuredHeights.collapsed, hasInitialMeasurement, isFullDescriptionOpen]);
|
||||
|
||||
// Animated style for smooth height transition
|
||||
// Animated style for smooth height transition - use minHeight to prevent collapse to 0
|
||||
const animatedDescriptionStyle = useAnimatedStyle(() => ({
|
||||
height: animatedHeight.value,
|
||||
height: animatedHeight.value > 0 ? animatedHeight.value : defaultCollapsedHeight,
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
function formatRuntime(runtime: string): string {
|
||||
// Try to match formats like "1h55min", "2h 7min", "125 min", etc.
|
||||
const match = runtime.match(/(?:(\d+)\s*h\s*)?(\d+)\s*min/i);
|
||||
if (match) {
|
||||
const h = match[1] ? parseInt(match[1], 10) : 0;
|
||||
const m = match[2] ? parseInt(match[2], 10) : 0;
|
||||
if (h > 0) {
|
||||
return `${h}H ${m}M`;
|
||||
function formatRuntime(runtime: string): string {
|
||||
// Try to match formats like "1h55min", "2h 7min", "125 min", etc.
|
||||
const match = runtime.match(/(?:(\d+)\s*h\s*)?(\d+)\s*min/i);
|
||||
if (match) {
|
||||
const h = match[1] ? parseInt(match[1], 10) : 0;
|
||||
const m = match[2] ? parseInt(match[2], 10) : 0;
|
||||
if (h > 0) {
|
||||
return `${h}H ${m}M`;
|
||||
}
|
||||
if (m < 60) {
|
||||
return `${m} MIN`;
|
||||
}
|
||||
const hours = Math.floor(m / 60);
|
||||
const mins = m % 60;
|
||||
return hours > 0 ? `${hours}H ${mins}M` : `${mins} MIN`;
|
||||
}
|
||||
if (m < 60) {
|
||||
return `${m} MIN`;
|
||||
|
||||
// Fallback: treat as minutes if it's a number
|
||||
const r = parseInt(runtime, 10);
|
||||
if (!isNaN(r)) {
|
||||
if (r < 60) return `${r} MIN`;
|
||||
const h = Math.floor(r / 60);
|
||||
const m = r % 60;
|
||||
return h > 0 ? `${h}H ${m}M` : `${m} MIN`;
|
||||
}
|
||||
const hours = Math.floor(m / 60);
|
||||
const mins = m % 60;
|
||||
return hours > 0 ? `${hours}H ${mins}M` : `${mins} MIN`;
|
||||
}
|
||||
|
||||
// Fallback: treat as minutes if it's a number
|
||||
const r = parseInt(runtime, 10);
|
||||
if (!isNaN(r)) {
|
||||
if (r < 60) return `${r} MIN`;
|
||||
const h = Math.floor(r / 60);
|
||||
const m = r % 60;
|
||||
return h > 0 ? `${h}H ${m}M` : `${m} MIN`;
|
||||
}
|
||||
// If not matched, return as is
|
||||
return runtime;
|
||||
|
||||
// If not matched, return as is
|
||||
return runtime;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -188,17 +201,17 @@ function formatRuntime(runtime: string): string {
|
|||
|
||||
{/* Meta Info */}
|
||||
<View style={[
|
||||
styles.metaInfo,
|
||||
styles.metaInfo,
|
||||
loadingMetadata && styles.dimmed,
|
||||
{
|
||||
{
|
||||
paddingHorizontal: horizontalPadding,
|
||||
gap: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18
|
||||
}
|
||||
]}>
|
||||
{metadata.year && (
|
||||
<Text style={[
|
||||
styles.metaText,
|
||||
{
|
||||
styles.metaText,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
|
||||
}
|
||||
|
|
@ -206,8 +219,8 @@ function formatRuntime(runtime: string): string {
|
|||
)}
|
||||
{metadata.runtime && (
|
||||
<Text style={[
|
||||
styles.metaText,
|
||||
{
|
||||
styles.metaText,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
|
||||
}
|
||||
|
|
@ -220,21 +233,10 @@ function formatRuntime(runtime: string): string {
|
|||
)}
|
||||
{metadata.imdbRating && !isMDBEnabled && (
|
||||
<View style={styles.ratingContainer}>
|
||||
<FastImage
|
||||
source={{ uri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png' }}
|
||||
style={[
|
||||
styles.imdbLogo,
|
||||
{
|
||||
width: isTV ? 42 : isLargeTablet ? 38 : isTablet ? 35 : 35,
|
||||
height: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
|
||||
}
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.ratingText,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
styles.ratingText,
|
||||
{
|
||||
color: '#F5C518',
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
|
||||
}
|
||||
]}>{metadata.imdbRating}</Text>
|
||||
|
|
@ -249,7 +251,7 @@ function formatRuntime(runtime: string): string {
|
|||
<Animated.View
|
||||
entering={FadeIn.duration(300).delay(100)}
|
||||
style={[
|
||||
styles.creatorContainer,
|
||||
styles.creatorContainer,
|
||||
loadingMetadata && styles.dimmed,
|
||||
{ paddingHorizontal: horizontalPadding }
|
||||
]}
|
||||
|
|
@ -263,16 +265,16 @@ function formatRuntime(runtime: string): string {
|
|||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.creatorLabel,
|
||||
{
|
||||
styles.creatorLabel,
|
||||
{
|
||||
color: currentTheme.colors.white,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
|
||||
}
|
||||
]}>Director{metadata.directors.length > 1 ? 's' : ''}:</Text>
|
||||
<Text style={[
|
||||
styles.creatorText,
|
||||
{
|
||||
styles.creatorText,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
|
||||
|
|
@ -289,16 +291,16 @@ function formatRuntime(runtime: string): string {
|
|||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.creatorLabel,
|
||||
{
|
||||
styles.creatorLabel,
|
||||
{
|
||||
color: currentTheme.colors.white,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
|
||||
}
|
||||
]}>Creator{metadata.creators.length > 1 ? 's' : ''}:</Text>
|
||||
<Text style={[
|
||||
styles.creatorText,
|
||||
{
|
||||
styles.creatorText,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
|
||||
|
|
@ -308,11 +310,11 @@ function formatRuntime(runtime: string): string {
|
|||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Description */}
|
||||
{metadata.description && (
|
||||
{/* Description - Show skeleton if no description yet to prevent layout shift */}
|
||||
{metadata.description ? (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.descriptionContainer,
|
||||
styles.descriptionContainer,
|
||||
loadingMetadata && styles.dimmed,
|
||||
{ paddingHorizontal: horizontalPadding }
|
||||
]}
|
||||
|
|
@ -321,10 +323,10 @@ function formatRuntime(runtime: string): string {
|
|||
{/* Hidden text elements to measure heights */}
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
position: 'absolute',
|
||||
styles.description,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
|
||||
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
|
||||
|
|
@ -337,10 +339,10 @@ function formatRuntime(runtime: string): string {
|
|||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
position: 'absolute',
|
||||
styles.description,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
|
||||
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
|
||||
|
|
@ -359,8 +361,8 @@ function formatRuntime(runtime: string): string {
|
|||
<Animated.View style={animatedDescriptionStyle}>
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{
|
||||
styles.description,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
|
||||
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
|
||||
|
|
@ -381,8 +383,8 @@ function formatRuntime(runtime: string): string {
|
|||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.showMoreText,
|
||||
{
|
||||
styles.showMoreText,
|
||||
{
|
||||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
|
||||
}
|
||||
|
|
@ -398,6 +400,20 @@ function formatRuntime(runtime: string): string {
|
|||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
) : (
|
||||
/* Skeleton placeholder for description to prevent layout shift */
|
||||
<View
|
||||
style={[
|
||||
styles.descriptionContainer,
|
||||
{ paddingHorizontal: horizontalPadding, minHeight: defaultCollapsedHeight }
|
||||
]}
|
||||
>
|
||||
<View style={[styles.descriptionSkeleton, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View style={[styles.skeletonLine, { width: '100%', height: isTV ? 18 : 15, backgroundColor: currentTheme.colors.elevation1, marginBottom: 8 }]} />
|
||||
<View style={[styles.skeletonLine, { width: '95%', height: isTV ? 18 : 15, backgroundColor: currentTheme.colors.elevation1, marginBottom: 8 }]} />
|
||||
<View style={[styles.skeletonLine, { width: '80%', height: isTV ? 18 : 15, backgroundColor: currentTheme.colors.elevation1 }]} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -491,6 +507,12 @@ const styles = StyleSheet.create({
|
|||
fontSize: 14,
|
||||
marginRight: 4,
|
||||
},
|
||||
descriptionSkeleton: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
skeletonLine: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(MetadataDetails);
|
||||
|
|
@ -7,13 +7,16 @@ import {
|
|||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useNavigation, StackActions } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { StreamingContent } from '../../services/catalogService';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import { TMDBService } from '../../services/tmdbService';
|
||||
import { catalogService } from '../../services/catalogService';
|
||||
import CustomAlert from '../../components/CustomAlert';
|
||||
|
|
@ -33,12 +36,15 @@ interface MoreLikeThisSectionProps {
|
|||
loadingRecommendations: boolean;
|
||||
}
|
||||
|
||||
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
||||
recommendations,
|
||||
loadingRecommendations
|
||||
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
||||
recommendations,
|
||||
loadingRecommendations
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const borderRadius = settings.posterBorderRadius ?? 12;
|
||||
|
||||
// Determine device type
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
|
|
@ -91,16 +97,16 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
|||
try {
|
||||
// Extract TMDB ID from the tmdb:123456 format
|
||||
const tmdbId = item.id.replace('tmdb:', '');
|
||||
|
||||
|
||||
// Get Stremio ID directly using catalogService
|
||||
// The catalogService.getStremioId method already handles the conversion internally
|
||||
const stremioId = await catalogService.getStremioId(item.type, tmdbId);
|
||||
|
||||
|
||||
if (stremioId) {
|
||||
navigation.dispatch(
|
||||
StackActions.push('Metadata', {
|
||||
id: stremioId,
|
||||
type: item.type
|
||||
StackActions.push('Metadata', {
|
||||
id: stremioId,
|
||||
type: item.type
|
||||
})
|
||||
);
|
||||
} else {
|
||||
|
|
@ -108,21 +114,21 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error navigating to recommendation:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Unable to load this content. Please try again later.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => {} }]);
|
||||
setAlertTitle(t('common.error'));
|
||||
setAlertMessage(t('metadata.something_went_wrong'));
|
||||
setAlertActions([{ label: t('common.ok'), onPress: () => { } }]);
|
||||
setAlertVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: StreamingContent }) => (
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width: posterWidth, marginRight: itemSpacing }]}
|
||||
onPress={() => handleItemPress(item)}
|
||||
>
|
||||
<FastImage
|
||||
source={{ uri: item.poster }}
|
||||
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, width: posterWidth, height: posterHeight, borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8 }]}
|
||||
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, width: posterWidth, height: posterHeight, borderRadius }]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.mediumEmphasis, fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13, lineHeight: isTV ? 20 : 18 }]} numberOfLines={2}>
|
||||
|
|
@ -144,8 +150,8 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingLeft: 0 }] }>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>More Like This</Text>
|
||||
<View style={[styles.container, { paddingLeft: 0 }]}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>{t('metadata.more_like_this')}</Text>
|
||||
<FlatList
|
||||
data={recommendations}
|
||||
renderItem={renderItem}
|
||||
|
|
@ -183,10 +189,17 @@ const styles = StyleSheet.create({
|
|||
marginRight: 12, // will be overridden responsively
|
||||
},
|
||||
poster: {
|
||||
borderRadius: 8, // overridden responsively
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
borderWidth: 1,
|
||||
// Consistent border styling matching ContentItem
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255,255,255,0.15)',
|
||||
// Consistent shadow/elevation
|
||||
elevation: Platform.OS === 'android' ? 1 : 0,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 13, // overridden responsively
|
||||
|
|
|
|||
|
|
@ -8,31 +8,7 @@ interface MovieContentProps {
|
|||
}
|
||||
|
||||
export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0;
|
||||
const castDisplay = hasCast ? metadata.cast!.slice(0, 5).join(', ') : '';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Additional metadata */}
|
||||
<View style={styles.additionalInfo}>
|
||||
{metadata.director && (
|
||||
<View style={styles.metadataRow}>
|
||||
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Director:</Text>
|
||||
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{metadata.director}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
{hasCast && (
|
||||
<View style={styles.metadataRow}>
|
||||
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Cast:</Text>
|
||||
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{castDisplay}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import HDSvg from '../../assets/qualitybadge/HD.svg';
|
||||
import VISIONSvg from '../../assets/qualitybadge/VISION.svg';
|
||||
import ADSvg from '../../assets/qualitybadge/AD.svg';
|
||||
|
||||
interface QualityBadgeProps {
|
||||
|
|
@ -17,8 +16,6 @@ const QualityBadge: React.FC<QualityBadgeProps> = ({ type }) => {
|
|||
switch (type) {
|
||||
case 'HD':
|
||||
return <HDSvg {...svgProps} />;
|
||||
case 'VISION':
|
||||
return <VISIONSvg {...svgProps} />;
|
||||
case 'AD':
|
||||
return <ADSvg {...svgProps} />;
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator, Image, Animated, Dimensions } from 'react-native';
|
||||
import { MaterialIcons as MaterialIconsWrapper } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useMDBListRatings } from '../../hooks/useMDBListRatings';
|
||||
import { mmkvStorage } from '../../services/mmkvStorage';
|
||||
|
|
@ -158,42 +159,49 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
// Define the order and icons/colors for the ratings
|
||||
const ratingConfig = {
|
||||
imdb: {
|
||||
icon: require('../../../assets/rating-icons/imdb.png'),
|
||||
isImage: true,
|
||||
name: 'IMDb',
|
||||
icon: null, // No icon for IMDb
|
||||
isImage: false,
|
||||
color: '#F5C518',
|
||||
transform: (value: number) => value.toFixed(1)
|
||||
},
|
||||
tmdb: {
|
||||
name: 'TMDB',
|
||||
icon: TMDBIcon,
|
||||
isImage: false,
|
||||
color: '#01B4E4',
|
||||
transform: (value: number) => value.toFixed(0)
|
||||
},
|
||||
trakt: {
|
||||
name: 'Trakt',
|
||||
icon: TraktIcon,
|
||||
isImage: false,
|
||||
color: '#ED1C24',
|
||||
transform: (value: number) => value.toFixed(0)
|
||||
},
|
||||
letterboxd: {
|
||||
name: 'Letterboxd',
|
||||
icon: LetterboxdIcon,
|
||||
isImage: false,
|
||||
color: '#00E054',
|
||||
transform: (value: number) => value.toFixed(1)
|
||||
},
|
||||
tomatoes: {
|
||||
name: 'Rotten Tomatoes',
|
||||
icon: RottenTomatoesIcon,
|
||||
isImage: false,
|
||||
color: '#FA320A',
|
||||
transform: (value: number) => Math.round(value).toString() + '%'
|
||||
},
|
||||
audience: {
|
||||
name: 'Audience Score',
|
||||
icon: AudienceScoreIcon,
|
||||
isImage: true,
|
||||
color: '#FA320A',
|
||||
transform: (value: number) => Math.round(value).toString() + '%'
|
||||
},
|
||||
metacritic: {
|
||||
name: 'Metacritic',
|
||||
icon: MetacriticIcon,
|
||||
isImage: true,
|
||||
color: '#FFCC33',
|
||||
|
|
@ -204,15 +212,15 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
// Priority: IMDB, TMDB, Tomatoes, Metacritic
|
||||
const priorityOrder = ['imdb', 'tmdb', 'tomatoes', 'metacritic', 'trakt', 'letterboxd', 'audience'];
|
||||
const displayRatings = priorityOrder
|
||||
.filter(source =>
|
||||
source in ratings &&
|
||||
.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
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
|
|
@ -231,22 +239,32 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
{displayRatings.map(([source, value]) => {
|
||||
const config = ratingConfig[source as keyof typeof ratingConfig];
|
||||
const displayValue = config.transform(parseFloat(value as string));
|
||||
|
||||
|
||||
return (
|
||||
<View key={source} style={[styles.compactRatingItem, { marginRight: itemSpacing }]}>
|
||||
{config.isImage ? (
|
||||
<Image
|
||||
<Image
|
||||
source={config.icon as any}
|
||||
style={[styles.compactRatingIcon, { width: iconSize, height: iconSize, marginRight: iconTextGap }]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
) : config.icon ? (
|
||||
<View style={[styles.compactSvgContainer, { marginRight: iconTextGap }]}>
|
||||
{React.createElement(config.icon as any, {
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
// Text fallback
|
||||
<Text style={{
|
||||
color: config.color,
|
||||
fontSize: textSize,
|
||||
fontWeight: '900',
|
||||
marginRight: iconTextGap
|
||||
}}>
|
||||
{config.name}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.compactRatingValue, { color: config.color, fontSize: textSize }]}>
|
||||
{displayValue}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTrailer } from '../../contexts/TrailerContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
|
@ -19,24 +20,6 @@ import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video'
|
|||
const { width, height } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
||||
// Helper function to format trailer type
|
||||
const formatTrailerType = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return 'Official Trailer';
|
||||
case 'Teaser':
|
||||
return 'Teaser';
|
||||
case 'Clip':
|
||||
return 'Clip';
|
||||
case 'Featurette':
|
||||
return 'Featurette';
|
||||
case 'Behind the Scenes':
|
||||
return 'Behind the Scenes';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
interface TrailerVideo {
|
||||
id: string;
|
||||
key: string;
|
||||
|
|
@ -61,8 +44,28 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
|||
trailer,
|
||||
contentTitle
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const { pauseTrailer, resumeTrailer } = useTrailer();
|
||||
|
||||
// Helper function to format trailer type with translations
|
||||
const formatTrailerType = useCallback((type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return t('trailers.official_trailer');
|
||||
case 'Teaser':
|
||||
return t('trailers.teaser');
|
||||
case 'Clip':
|
||||
return t('trailers.clip');
|
||||
case 'Featurette':
|
||||
return t('trailers.featurette');
|
||||
case 'Behind the Scenes':
|
||||
return t('trailers.behind_the_scenes');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const videoRef = React.useRef<VideoRef>(null);
|
||||
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -126,9 +129,9 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
|||
logger.error('TrailerModal', 'Error loading trailer:', err);
|
||||
|
||||
Alert.alert(
|
||||
'Trailer Unavailable',
|
||||
'This trailer could not be loaded at this time. Please try again later.',
|
||||
[{ text: 'OK', style: 'default' }]
|
||||
t('trailers.unavailable'),
|
||||
t('trailers.unavailable_desc'),
|
||||
[{ text: t('common.ok'), style: 'default' }]
|
||||
);
|
||||
}
|
||||
}, [trailer, contentTitle, pauseTrailer]);
|
||||
|
|
@ -232,7 +235,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
|||
hitSlop={{ top: 10, left: 10, right: 10, bottom: 10 }}
|
||||
>
|
||||
<Text style={[styles.closeButtonText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Close
|
||||
{t('common.close')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -257,7 +260,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
|||
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={loadTrailer}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||
<Text style={styles.retryButtonText}>{t('common.try_again')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ScrollView,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
|
@ -59,6 +60,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
contentId,
|
||||
contentTitle
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const { pauseTrailer } = useTrailer();
|
||||
|
|
@ -74,7 +76,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceHeight = Dimensions.get('window').height;
|
||||
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
|
|
@ -82,13 +84,13 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||
|
||||
|
||||
// Enhanced spacing and padding
|
||||
const horizontalPadding = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -102,7 +104,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
return 16; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
// Enhanced trailer card sizing
|
||||
const trailerCardWidth = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -116,7 +118,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
return 200; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const trailerCardSpacing = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -293,7 +295,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
// Auto-select the first available category, preferring "Trailer"
|
||||
const availableCategories = Object.keys(categorized);
|
||||
const preferredCategory = availableCategories.includes('Trailer') ? 'Trailer' :
|
||||
availableCategories.includes('Teaser') ? 'Teaser' : availableCategories[0];
|
||||
availableCategories.includes('Teaser') ? 'Teaser' : availableCategories[0];
|
||||
setSelectedCategory(preferredCategory);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -379,7 +381,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
} catch (error) {
|
||||
logger.warn('TrailersSection', 'Error pausing hero trailer:', error);
|
||||
}
|
||||
|
||||
|
||||
setSelectedTrailer(trailer);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
|
@ -414,22 +416,22 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
};
|
||||
|
||||
// Format trailer type for display
|
||||
const formatTrailerType = (type: string): string => {
|
||||
const formatTrailerType = useCallback((type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return 'Official Trailers';
|
||||
return t('trailers.official_trailers');
|
||||
case 'Teaser':
|
||||
return 'Teasers';
|
||||
return t('trailers.teasers');
|
||||
case 'Clip':
|
||||
return 'Clips & Scenes';
|
||||
return t('trailers.clips_scenes');
|
||||
case 'Featurette':
|
||||
return 'Featurettes';
|
||||
return t('trailers.featurettes');
|
||||
case 'Behind the Scenes':
|
||||
return 'Behind the Scenes';
|
||||
return t('trailers.behind_the_scenes');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
// Get icon for trailer type
|
||||
const getTrailerTypeIcon = (type: string): string => {
|
||||
|
|
@ -483,12 +485,12 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
<View style={styles.header}>
|
||||
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Trailers
|
||||
{t('trailers.title')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.noTrailersContainer}>
|
||||
<Text style={[styles.noTrailersText, { color: currentTheme.colors.textMuted }]}>
|
||||
No trailers available
|
||||
{t('trailers.no_trailers')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -499,28 +501,28 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
|
||||
return (
|
||||
<Animated.View style={[
|
||||
styles.container,
|
||||
styles.container,
|
||||
sectionAnimatedStyle,
|
||||
{ paddingHorizontal: horizontalPadding }
|
||||
]}>
|
||||
{/* Enhanced Header with Category Selector */}
|
||||
<View style={styles.header}>
|
||||
<Text style={[
|
||||
styles.headerTitle,
|
||||
{
|
||||
styles.headerTitle,
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
|
||||
}
|
||||
]}>
|
||||
Trailers & Videos
|
||||
{t('trailers.title')}
|
||||
</Text>
|
||||
|
||||
{/* Category Selector - Right Aligned */}
|
||||
{trailerCategories.length > 0 && selectedCategory && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.categorySelector,
|
||||
{
|
||||
styles.categorySelector,
|
||||
{
|
||||
borderColor: 'rgba(255,255,255,0.6)',
|
||||
paddingHorizontal: isTV ? 14 : isLargeTablet ? 12 : isTablet ? 10 : 10,
|
||||
paddingVertical: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 5 : 5,
|
||||
|
|
@ -533,8 +535,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.categorySelectorText,
|
||||
{
|
||||
styles.categorySelectorText,
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
|
||||
maxWidth: isTV ? 150 : isLargeTablet ? 130 : isTablet ? 120 : 120
|
||||
|
|
@ -559,6 +561,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
visible={dropdownVisible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
onRequestClose={() => setDropdownVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
|
|
@ -587,7 +590,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
>
|
||||
<View style={styles.dropdownItemContent}>
|
||||
<View style={[
|
||||
styles.categoryIconContainer,
|
||||
styles.categoryIconContainer,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.primary + '15',
|
||||
width: isTV ? 36 : isLargeTablet ? 32 : isTablet ? 28 : 28,
|
||||
|
|
@ -601,18 +604,18 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.dropdownItemText,
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.dropdownItemText,
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
{formatTrailerType(category)}
|
||||
</Text>
|
||||
<Text style={[
|
||||
styles.dropdownItemCount,
|
||||
{
|
||||
styles.dropdownItemCount,
|
||||
{
|
||||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12,
|
||||
paddingHorizontal: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8,
|
||||
|
|
@ -690,8 +693,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
<View style={styles.trailerInfoBelow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.trailerTitle,
|
||||
{
|
||||
styles.trailerTitle,
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
|
||||
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16,
|
||||
|
|
@ -704,8 +707,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
{trailer.displayName || trailer.name}
|
||||
</Text>
|
||||
<Text style={[
|
||||
styles.trailerMeta,
|
||||
{
|
||||
styles.trailerMeta,
|
||||
{
|
||||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 10
|
||||
}
|
||||
|
|
|
|||
172
src/components/onboarding/ShapeAnimation.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useWindowDimensions, StyleSheet } from 'react-native';
|
||||
import {
|
||||
Blur,
|
||||
BlurMask,
|
||||
Canvas,
|
||||
Circle,
|
||||
Extrapolate,
|
||||
interpolate,
|
||||
interpolateColors,
|
||||
LinearGradient,
|
||||
Path,
|
||||
RadialGradient,
|
||||
usePathValue,
|
||||
vec,
|
||||
} from '@shopify/react-native-skia';
|
||||
import {
|
||||
Easing,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
SharedValue,
|
||||
useDerivedValue,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import {
|
||||
type Point3D,
|
||||
N_POINTS,
|
||||
ALL_SHAPES,
|
||||
ALL_SHAPES_X,
|
||||
ALL_SHAPES_Y,
|
||||
ALL_SHAPES_Z,
|
||||
} from './shapes';
|
||||
|
||||
// Color palettes for each shape (gradient stops)
|
||||
const COLOR_STOPS = [
|
||||
{ start: '#FFD700', end: '#FF6B00' }, // Star: Gold → Orange
|
||||
{ start: '#7C3AED', end: '#EC4899' }, // Plugin: Purple → Pink
|
||||
{ start: '#00D9FF', end: '#0EA5E9' }, // Search: Cyan → Blue
|
||||
{ start: '#FF006E', end: '#FB7185' }, // Heart: Pink → Rose
|
||||
];
|
||||
|
||||
// ============ 3D UTILITIES ============
|
||||
const rotateX = (p: Point3D, angle: number): Point3D => {
|
||||
'worklet';
|
||||
return {
|
||||
x: p.x,
|
||||
y: p.y * Math.cos(angle) - p.z * Math.sin(angle),
|
||||
z: p.y * Math.sin(angle) + p.z * Math.cos(angle),
|
||||
};
|
||||
};
|
||||
|
||||
const rotateY = (p: Point3D, angle: number): Point3D => {
|
||||
'worklet';
|
||||
return {
|
||||
x: p.x * Math.cos(angle) + p.z * Math.sin(angle),
|
||||
y: p.y,
|
||||
z: -p.x * Math.sin(angle) + p.z * Math.cos(angle),
|
||||
};
|
||||
};
|
||||
|
||||
interface ShapeAnimationProps {
|
||||
scrollX: SharedValue<number>;
|
||||
}
|
||||
|
||||
export const ShapeAnimation: React.FC<ShapeAnimationProps> = ({ scrollX }) => {
|
||||
const iTime = useSharedValue(0.0);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
|
||||
// Pre-compute input range once
|
||||
const shapeWidth = windowWidth;
|
||||
const inputRange = ALL_SHAPES.map((_, idx) => shapeWidth * idx);
|
||||
|
||||
// Single optimized path - all 4 shapes batched into one Skia Path
|
||||
const morphPath = usePathValue(skPath => {
|
||||
'worklet';
|
||||
const centerX = windowWidth / 2;
|
||||
const centerY = windowHeight * 0.65;
|
||||
const distance = 350;
|
||||
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
// Interpolate 3D coordinates between all shapes
|
||||
const baseX = interpolate(scrollX.value, inputRange, ALL_SHAPES_X[i], Extrapolate.CLAMP);
|
||||
const baseY = interpolate(scrollX.value, inputRange, ALL_SHAPES_Y[i], Extrapolate.CLAMP);
|
||||
const baseZ = interpolate(scrollX.value, inputRange, ALL_SHAPES_Z[i], Extrapolate.CLAMP);
|
||||
|
||||
// Apply 3D rotation
|
||||
let p: Point3D = { x: baseX, y: baseY, z: baseZ };
|
||||
p = rotateX(p, 0.2); // Fixed X tilt
|
||||
p = rotateY(p, iTime.value); // Animated Y rotation
|
||||
|
||||
// Perspective projection
|
||||
const scale = distance / (distance + p.z);
|
||||
const screenX = centerX + p.x * scale;
|
||||
const screenY = centerY + p.y * scale;
|
||||
|
||||
// Depth-based radius for parallax effect
|
||||
const radius = Math.max(0.2, 0.5 * scale);
|
||||
skPath.addCircle(screenX, screenY, radius);
|
||||
}
|
||||
|
||||
return skPath;
|
||||
});
|
||||
|
||||
// Interpolate gradient colors based on scroll position
|
||||
const gradientColors = useDerivedValue(() => {
|
||||
const startColors = COLOR_STOPS.map(c => c.start);
|
||||
const endColors = COLOR_STOPS.map(c => c.end);
|
||||
|
||||
const start = interpolateColors(scrollX.value, inputRange, startColors);
|
||||
const end = interpolateColors(scrollX.value, inputRange, endColors);
|
||||
|
||||
return [start, end];
|
||||
});
|
||||
|
||||
// Rotation animation - infinite loop
|
||||
useEffect(() => {
|
||||
iTime.value = 0;
|
||||
iTime.value = withRepeat(
|
||||
withTiming(2 * Math.PI, {
|
||||
duration: 12000,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
style={[
|
||||
styles.canvas,
|
||||
{
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
},
|
||||
]}>
|
||||
{/* Background glow */}
|
||||
<Circle
|
||||
cx={windowWidth / 2}
|
||||
cy={windowHeight * 0.65}
|
||||
r={windowWidth * 0.6}>
|
||||
<RadialGradient
|
||||
c={vec(windowWidth / 2, windowHeight * 0.65)}
|
||||
r={windowWidth * 0.6}
|
||||
colors={['#ffffff20', 'transparent']}
|
||||
/>
|
||||
<Blur blur={60} />
|
||||
</Circle>
|
||||
|
||||
{/* Single optimized path with interpolated gradient */}
|
||||
<Path path={morphPath} style="fill">
|
||||
<LinearGradient
|
||||
start={vec(0, windowHeight * 0.4)}
|
||||
end={vec(windowWidth, windowHeight * 0.9)}
|
||||
colors={gradientColors}
|
||||
/>
|
||||
<BlurMask blur={5} style="solid" />
|
||||
</Path>
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
canvas: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default ShapeAnimation;
|
||||
8
src/components/onboarding/shapes/constants.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Fixed number of points for all shapes (for interpolation)
|
||||
// Lower = better FPS, 1000 points is a good balance for smooth 60fps
|
||||
export const N_POINTS = 1000;
|
||||
|
||||
export const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2;
|
||||
|
||||
// Normalize a shape to have height TARGET_HEIGHT
|
||||
export const TARGET_HEIGHT = 200;
|
||||
35
src/components/onboarding/shapes/cube.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Cube - map sphere to cube
|
||||
const generateCubePoints = (size: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
const s = size / 2;
|
||||
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi } = fibonacciPoint(i, N_POINTS);
|
||||
// Point on unit sphere
|
||||
const sx = Math.sin(phi) * Math.cos(theta);
|
||||
const sy = Math.sin(phi) * Math.sin(theta);
|
||||
const sz = Math.cos(phi);
|
||||
|
||||
// Map to cube (cube mapping)
|
||||
const absX = Math.abs(sx);
|
||||
const absY = Math.abs(sy);
|
||||
const absZ = Math.abs(sz);
|
||||
const max = Math.max(absX, absY, absZ);
|
||||
|
||||
points.push({
|
||||
x: (sx / max) * s,
|
||||
y: (sy / max) * s,
|
||||
z: (sz / max) * s,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const CUBE_POINTS = scaleShape(
|
||||
normalizeShape(generateCubePoints(150)),
|
||||
0.75,
|
||||
);
|
||||
35
src/components/onboarding/shapes/heart.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape } from './utils';
|
||||
|
||||
// Heart - starts from Fibonacci sphere, deforms into heart
|
||||
const generateHeartPoints = (scale: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi } = fibonacciPoint(i, N_POINTS);
|
||||
|
||||
// Use same angular coordinates as sphere
|
||||
const u = theta;
|
||||
const v = phi;
|
||||
const sinV = Math.sin(v);
|
||||
|
||||
// Heart surface with same angular correspondence
|
||||
const hx = sinV * (15 * Math.sin(u) - 4 * Math.sin(3 * u));
|
||||
const hz = 8 * Math.cos(v);
|
||||
const hy =
|
||||
sinV *
|
||||
(15 * Math.cos(u) -
|
||||
5 * Math.cos(2 * u) -
|
||||
2 * Math.cos(3 * u) -
|
||||
Math.cos(4 * u));
|
||||
|
||||
points.push({
|
||||
x: hx * scale * 0.06,
|
||||
y: -hy * scale * 0.06,
|
||||
z: hz * scale * 0.06,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const HEART_POINTS = normalizeShape(generateHeartPoints(120));
|
||||
28
src/components/onboarding/shapes/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export { type Point3D } from './types';
|
||||
export { N_POINTS } from './constants';
|
||||
|
||||
import { N_POINTS } from './constants';
|
||||
import { STAR_POINTS } from './star'; // Welcome to Nuvio
|
||||
import { PLUGIN_POINTS } from './plugin'; // Powerful Addons
|
||||
import { SEARCH_POINTS } from './search'; // Smart Discovery
|
||||
import { HEART_POINTS } from './heart'; // Your Library (favorites)
|
||||
|
||||
// Array of all shapes - ordered to match onboarding slides
|
||||
export const ALL_SHAPES = [
|
||||
STAR_POINTS, // Slide 1: Welcome
|
||||
PLUGIN_POINTS, // Slide 2: Addons
|
||||
SEARCH_POINTS, // Slide 3: Discovery
|
||||
HEART_POINTS, // Slide 4: Library
|
||||
];
|
||||
|
||||
export const POINTS_ARRAY = new Array(N_POINTS).fill(0);
|
||||
|
||||
export const ALL_SHAPES_X = POINTS_ARRAY.map((_, pointIndex) =>
|
||||
ALL_SHAPES.map(shape => shape[pointIndex].x),
|
||||
);
|
||||
export const ALL_SHAPES_Y = POINTS_ARRAY.map((_, pointIndex) =>
|
||||
ALL_SHAPES.map(shape => shape[pointIndex].y),
|
||||
);
|
||||
export const ALL_SHAPES_Z = POINTS_ARRAY.map((_, pointIndex) =>
|
||||
ALL_SHAPES.map(shape => shape[pointIndex].z),
|
||||
);
|
||||
96
src/components/onboarding/shapes/plugin.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// LEGO Brick shape - perfectly represents "Addons" or "Plugins"
|
||||
const generateLegoPoints = (): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
|
||||
// Dimensions
|
||||
const width = 160;
|
||||
const depth = 80;
|
||||
const height = 48;
|
||||
const studRadius = 12;
|
||||
const studHeight = 16;
|
||||
|
||||
// Distribute points: 70% body, 30% studs
|
||||
const bodyPoints = Math.floor(N_POINTS * 0.7);
|
||||
const studPoints = N_POINTS - bodyPoints;
|
||||
const pointsPerStud = Math.floor(studPoints / 8); // 8 studs (2x4 brick)
|
||||
|
||||
// 1. Main Brick Body (Rectangular Prism)
|
||||
for (let i = 0; i < bodyPoints; i++) {
|
||||
const t1 = Math.random();
|
||||
const t2 = Math.random();
|
||||
const t3 = Math.random();
|
||||
|
||||
// Create density concentration on edges for better definition
|
||||
const x = (Math.pow(t1, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * width / 2;
|
||||
const y = (Math.pow(t2, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * height / 2;
|
||||
const z = (Math.pow(t3, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * depth / 2;
|
||||
|
||||
// Snapping to faces to make it look solid
|
||||
const face = Math.floor(Math.random() * 6);
|
||||
let px = x, py = y, pz = z;
|
||||
|
||||
if (face === 0) px = width / 2;
|
||||
else if (face === 1) px = -width / 2;
|
||||
else if (face === 2) py = height / 2;
|
||||
else if (face === 3) py = -height / 2;
|
||||
else if (face === 4) pz = depth / 2;
|
||||
else if (face === 5) pz = -depth / 2;
|
||||
|
||||
// Add some random noise inside/surface
|
||||
if (Math.random() > 0.8) {
|
||||
points.push({ x: x, y: y, z: z });
|
||||
} else {
|
||||
points.push({ x: px, y: py, z: pz });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Studs (Cylinders on top)
|
||||
// 2x4 Grid positions
|
||||
const studPositions = [
|
||||
{ x: -width * 0.375, z: -depth * 0.25 }, { x: -width * 0.125, z: -depth * 0.25 },
|
||||
{ x: width * 0.125, z: -depth * 0.25 }, { x: width * 0.375, z: -depth * 0.25 },
|
||||
{ x: -width * 0.375, z: depth * 0.25 }, { x: -width * 0.125, z: depth * 0.25 },
|
||||
{ x: width * 0.125, z: depth * 0.25 }, { x: width * 0.375, z: depth * 0.25 },
|
||||
];
|
||||
|
||||
studPositions.forEach((pos, studIndex) => {
|
||||
for (let j = 0; j < pointsPerStud; j++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * studRadius;
|
||||
|
||||
// Top face of stud
|
||||
if (Math.random() > 0.5) {
|
||||
points.push({
|
||||
x: pos.x + r * Math.cos(angle),
|
||||
y: -height / 2 - studHeight, // Top
|
||||
z: pos.z + r * Math.sin(angle),
|
||||
});
|
||||
} else {
|
||||
// Side of stud
|
||||
const h = Math.random() * studHeight;
|
||||
points.push({
|
||||
x: pos.x + studRadius * Math.cos(angle),
|
||||
y: -height / 2 - h,
|
||||
z: pos.z + studRadius * Math.sin(angle),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// FILL remaining points to prevent "undefined" errors
|
||||
while (points.length < N_POINTS) {
|
||||
points.push(points[points.length - 1] || { x: 0, y: 0, z: 0 });
|
||||
}
|
||||
|
||||
// Slice to guarantee exact count
|
||||
return points.slice(0, N_POINTS);
|
||||
};
|
||||
|
||||
export const PLUGIN_POINTS = scaleShape(
|
||||
normalizeShape(generateLegoPoints()),
|
||||
0.4,
|
||||
);
|
||||
57
src/components/onboarding/shapes/search.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Magnifying glass/search shape - for "Discovery" page
|
||||
const generateSearchPoints = (radius: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
const handleLength = radius * 0.8;
|
||||
const handleWidth = radius * 0.15;
|
||||
|
||||
// Split points between ring and handle
|
||||
const ringPoints = Math.floor(N_POINTS * 0.7);
|
||||
const handlePoints = N_POINTS - ringPoints;
|
||||
|
||||
// Create the circular ring (lens)
|
||||
for (let i = 0; i < ringPoints; i++) {
|
||||
const t = i / ringPoints;
|
||||
const mainAngle = t * Math.PI * 2;
|
||||
const tubeAngle = (i * 17) % 20 / 20 * Math.PI * 2; // Distribute around tube
|
||||
|
||||
const tubeRadius = radius * 0.12;
|
||||
const centerRadius = radius;
|
||||
|
||||
const cx = centerRadius * Math.cos(mainAngle);
|
||||
const cy = centerRadius * Math.sin(mainAngle);
|
||||
|
||||
points.push({
|
||||
x: cx + tubeRadius * Math.cos(tubeAngle) * Math.cos(mainAngle),
|
||||
y: cy + tubeRadius * Math.cos(tubeAngle) * Math.sin(mainAngle),
|
||||
z: tubeRadius * Math.sin(tubeAngle),
|
||||
});
|
||||
}
|
||||
|
||||
// Create the handle
|
||||
for (let i = 0; i < handlePoints; i++) {
|
||||
const t = i / handlePoints;
|
||||
const handleAngle = (i * 13) % 12 / 12 * Math.PI * 2;
|
||||
|
||||
// Handle position (extends from bottom-right of ring)
|
||||
const handleStart = radius * 0.7;
|
||||
const hx = handleStart + t * handleLength;
|
||||
const hy = handleStart + t * handleLength;
|
||||
|
||||
points.push({
|
||||
x: hx + handleWidth * Math.cos(handleAngle) * 0.3,
|
||||
y: hy + handleWidth * Math.cos(handleAngle) * 0.3,
|
||||
z: handleWidth * Math.sin(handleAngle),
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
export const SEARCH_POINTS = scaleShape(
|
||||
normalizeShape(generateSearchPoints(80)),
|
||||
1.0,
|
||||
);
|
||||
19
src/components/onboarding/shapes/sphere.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape } from './utils';
|
||||
|
||||
// Sphere
|
||||
const generateSpherePoints = (radius: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi } = fibonacciPoint(i, N_POINTS);
|
||||
points.push({
|
||||
x: radius * Math.sin(phi) * Math.cos(theta),
|
||||
y: radius * Math.sin(phi) * Math.sin(theta),
|
||||
z: radius * Math.cos(phi),
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const SPHERE_POINTS = normalizeShape(generateSpherePoints(100));
|
||||
31
src/components/onboarding/shapes/star.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Star shape - for "Welcome" page
|
||||
const generateStarPoints = (outerRadius: number, innerRadius: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
const numPoints = 5; // 5-pointed star
|
||||
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi, t } = fibonacciPoint(i, N_POINTS);
|
||||
|
||||
// Create star cross-section
|
||||
const angle = theta * numPoints;
|
||||
const radiusFactor = 0.5 + 0.5 * Math.cos(angle);
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * radiusFactor;
|
||||
|
||||
const sinPhi = Math.sin(phi);
|
||||
points.push({
|
||||
x: radius * sinPhi * Math.cos(theta),
|
||||
y: radius * sinPhi * Math.sin(theta),
|
||||
z: radius * Math.cos(phi) * 0.3, // Flatten z for star shape
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const STAR_POINTS = scaleShape(
|
||||
normalizeShape(generateStarPoints(100, 40)),
|
||||
0.9,
|
||||
);
|
||||
48
src/components/onboarding/shapes/torus.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Torus - uniform grid with same index correspondence
|
||||
const generateTorusPoints = (major: number, minor: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
|
||||
// Calculate approximate grid dimensions
|
||||
const ratio = major / minor;
|
||||
const minorSegments = Math.round(Math.sqrt(N_POINTS / ratio));
|
||||
const majorSegments = Math.round(N_POINTS / minorSegments);
|
||||
|
||||
let idx = 0;
|
||||
for (let i = 0; i < majorSegments && idx < N_POINTS; i++) {
|
||||
const u = (i / majorSegments) * Math.PI * 2;
|
||||
|
||||
for (let j = 0; j < minorSegments && idx < N_POINTS; j++) {
|
||||
const v = (j / minorSegments) * Math.PI * 2;
|
||||
|
||||
points.push({
|
||||
x: (major + minor * Math.cos(v)) * Math.cos(u),
|
||||
y: (major + minor * Math.cos(v)) * Math.sin(u),
|
||||
z: minor * Math.sin(v),
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill missing points if necessary
|
||||
while (points.length < N_POINTS) {
|
||||
const t = points.length / N_POINTS;
|
||||
const u = t * Math.PI * 2 * majorSegments;
|
||||
const v = t * Math.PI * 2 * minorSegments;
|
||||
points.push({
|
||||
x: (major + minor * Math.cos(v)) * Math.cos(u),
|
||||
y: (major + minor * Math.cos(v)) * Math.sin(u),
|
||||
z: minor * Math.sin(v),
|
||||
});
|
||||
}
|
||||
|
||||
return points.slice(0, N_POINTS);
|
||||
};
|
||||
|
||||
export const TORUS_POINTS = scaleShape(
|
||||
normalizeShape(generateTorusPoints(50, 25)),
|
||||
1.2,
|
||||
);
|
||||
1
src/components/onboarding/shapes/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type Point3D = { x: number; y: number; z: number };
|
||||
54
src/components/onboarding/shapes/utils.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { GOLDEN_RATIO, TARGET_HEIGHT } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
|
||||
// Generate Fibonacci points on unit sphere, then map to shape
|
||||
export const fibonacciPoint = (
|
||||
i: number,
|
||||
total: number,
|
||||
): { theta: number; phi: number; t: number } => {
|
||||
const t = i / total;
|
||||
const theta = (2 * Math.PI * i) / GOLDEN_RATIO;
|
||||
const phi = Math.acos(1 - 2 * t);
|
||||
return { theta, phi, t };
|
||||
};
|
||||
|
||||
export const normalizeShape = (points: Point3D[]): Point3D[] => {
|
||||
// Find min/max for each axis
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity;
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity;
|
||||
let minZ = Infinity,
|
||||
maxZ = -Infinity;
|
||||
|
||||
for (const p of points) {
|
||||
minX = Math.min(minX, p.x);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
minZ = Math.min(minZ, p.z);
|
||||
maxZ = Math.max(maxZ, p.z);
|
||||
}
|
||||
|
||||
// Calculate current dimensions
|
||||
const currentHeight = maxY - minY;
|
||||
const scale = TARGET_HEIGHT / currentHeight;
|
||||
|
||||
// Center and scale uniformly
|
||||
const centerY = (minY + maxY) / 2;
|
||||
|
||||
return points.map(p => ({
|
||||
x: (p.x - (minX + maxX) / 2) * scale,
|
||||
y: (p.y - centerY) * scale,
|
||||
z: (p.z - (minZ + maxZ) / 2) * scale,
|
||||
}));
|
||||
};
|
||||
|
||||
// Additional scale for single shape
|
||||
export const scaleShape = (points: Point3D[], factor: number): Point3D[] => {
|
||||
return points.map(p => ({
|
||||
x: p.x * factor,
|
||||
y: p.y * factor,
|
||||
z: p.z * factor,
|
||||
}));
|
||||
};
|
||||
|
|
@ -17,6 +17,9 @@ interface KSPlayerViewProps {
|
|||
usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
|
||||
subtitleBottomOffset?: number;
|
||||
subtitleFontSize?: number;
|
||||
subtitleTextColor?: string;
|
||||
subtitleBackgroundColor?: string;
|
||||
subtitleOutlineEnabled?: boolean;
|
||||
resizeMode?: 'contain' | 'cover' | 'stretch';
|
||||
onLoad?: (data: any) => void;
|
||||
onProgress?: (data: any) => void;
|
||||
|
|
@ -56,6 +59,9 @@ export interface KSPlayerProps {
|
|||
usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
|
||||
subtitleBottomOffset?: number;
|
||||
subtitleFontSize?: number;
|
||||
subtitleTextColor?: string;
|
||||
subtitleBackgroundColor?: string;
|
||||
subtitleOutlineEnabled?: boolean;
|
||||
resizeMode?: 'contain' | 'cover' | 'stretch';
|
||||
onLoad?: (data: any) => void;
|
||||
onProgress?: (data: any) => void;
|
||||
|
|
@ -120,17 +126,24 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
|
|||
}
|
||||
},
|
||||
setTextTrack: (trackId: number) => {
|
||||
console.log('[KSPlayerComponent] setTextTrack called with trackId:', trackId);
|
||||
if (nativeRef.current) {
|
||||
const node = findNodeHandle(nativeRef.current);
|
||||
console.log('[KSPlayerComponent] setTextTrack dispatching command to node:', node);
|
||||
// @ts-ignore legacy UIManager commands path for Paper
|
||||
const commandId = UIManager.getViewManagerConfig('KSPlayerView').Commands.setTextTrack;
|
||||
console.log('[KSPlayerComponent] setTextTrack commandId:', commandId);
|
||||
UIManager.dispatchViewManagerCommand(node, commandId, [trackId]);
|
||||
} else {
|
||||
console.warn('[KSPlayerComponent] setTextTrack: nativeRef.current is null');
|
||||
}
|
||||
},
|
||||
getTracks: async () => {
|
||||
if (nativeRef.current) {
|
||||
const node = findNodeHandle(nativeRef.current);
|
||||
return await KSPlayerModule.getTracks(node);
|
||||
if (node) {
|
||||
return await KSPlayerModule.getTracks(node);
|
||||
}
|
||||
}
|
||||
return { audioTracks: [], textTracks: [] };
|
||||
},
|
||||
|
|
@ -153,15 +166,21 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
|
|||
getAirPlayState: async () => {
|
||||
if (nativeRef.current) {
|
||||
const node = findNodeHandle(nativeRef.current);
|
||||
return await KSPlayerModule.getAirPlayState(node);
|
||||
if (node) {
|
||||
return await KSPlayerModule.getAirPlayState(node);
|
||||
}
|
||||
}
|
||||
return { allowsExternalPlayback: false, usesExternalPlaybackWhileExternalScreenIsActive: false, isExternalPlaybackActive: false };
|
||||
},
|
||||
showAirPlayPicker: () => {
|
||||
if (nativeRef.current) {
|
||||
const node = findNodeHandle(nativeRef.current);
|
||||
console.log('[KSPlayerComponent] Calling showAirPlayPicker with node:', node);
|
||||
KSPlayerModule.showAirPlayPicker(node);
|
||||
if (node) {
|
||||
console.log('[KSPlayerComponent] Calling showAirPlayPicker with node:', node);
|
||||
KSPlayerModule.showAirPlayPicker(node);
|
||||
} else {
|
||||
console.warn('[KSPlayerComponent] Cannot call showAirPlayPicker: node is null');
|
||||
}
|
||||
} else {
|
||||
console.log('[KSPlayerComponent] nativeRef.current is null');
|
||||
}
|
||||
|
|
@ -191,6 +210,9 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
|
|||
usesExternalPlaybackWhileExternalScreenIsActive={props.usesExternalPlaybackWhileExternalScreenIsActive}
|
||||
subtitleBottomOffset={props.subtitleBottomOffset}
|
||||
subtitleFontSize={props.subtitleFontSize}
|
||||
subtitleTextColor={props.subtitleTextColor}
|
||||
subtitleBackgroundColor={props.subtitleBackgroundColor}
|
||||
subtitleOutlineEnabled={props.subtitleOutlineEnabled}
|
||||
resizeMode={props.resizeMode}
|
||||
onLoad={(e: any) => props.onLoad?.(e?.nativeEvent ?? e)}
|
||||
onProgress={(e: any) => props.onProgress?.(e?.nativeEvent ?? e)}
|
||||
|
|
|
|||
|
|
@ -1,364 +0,0 @@
|
|||
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { View, Dimensions } from 'react-native';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Dynamic import to avoid iOS loading Android native module
|
||||
let LibVlcPlayerViewComponent: any = null;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mod = require('expo-libvlc-player');
|
||||
LibVlcPlayerViewComponent = mod?.LibVlcPlayerView || null;
|
||||
} catch {
|
||||
LibVlcPlayerViewComponent = null;
|
||||
}
|
||||
|
||||
interface VlcVideoPlayerProps {
|
||||
source: string;
|
||||
volume: number;
|
||||
playbackSpeed: number;
|
||||
zoomScale: number;
|
||||
resizeMode: 'contain' | 'cover' | 'none';
|
||||
onLoad: (data: any) => void;
|
||||
onProgress: (data: any) => void;
|
||||
onSeek: (data: any) => void;
|
||||
onEnd: () => void;
|
||||
onError: (error: any) => void;
|
||||
onTracksUpdate: (tracks: { audio: any[], subtitle: any[] }) => void;
|
||||
selectedAudioTrack?: number | null;
|
||||
selectedSubtitleTrack?: number | null;
|
||||
restoreTime?: number | null;
|
||||
forceRemount?: boolean;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface VlcTrack {
|
||||
id: number;
|
||||
name: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface VlcPlayerRef {
|
||||
seek: (timeInSeconds: number) => void;
|
||||
pause: () => void;
|
||||
play: () => void;
|
||||
}
|
||||
|
||||
const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
||||
source,
|
||||
volume,
|
||||
playbackSpeed,
|
||||
zoomScale,
|
||||
resizeMode,
|
||||
onLoad,
|
||||
onProgress,
|
||||
onSeek,
|
||||
onEnd,
|
||||
onError,
|
||||
onTracksUpdate,
|
||||
selectedAudioTrack,
|
||||
selectedSubtitleTrack,
|
||||
restoreTime,
|
||||
forceRemount,
|
||||
key,
|
||||
}, ref) => {
|
||||
const vlcRef = useRef<any>(null);
|
||||
const [vlcActive, setVlcActive] = useState(true);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
|
||||
|
||||
// Expose imperative methods to parent component
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek: (timeInSeconds: number) => {
|
||||
if (vlcRef.current && typeof vlcRef.current.seek === 'function') {
|
||||
const fraction = Math.min(Math.max(timeInSeconds / (duration || 1), 0), 0.999);
|
||||
vlcRef.current.seek(fraction);
|
||||
logger.log(`[VLC] Seeked to ${timeInSeconds}s (${fraction.toFixed(3)})`);
|
||||
}
|
||||
},
|
||||
pause: () => {
|
||||
if (vlcRef.current && typeof vlcRef.current.pause === 'function') {
|
||||
vlcRef.current.pause();
|
||||
logger.log('[VLC] Paused');
|
||||
}
|
||||
},
|
||||
play: () => {
|
||||
if (vlcRef.current && typeof vlcRef.current.play === 'function') {
|
||||
vlcRef.current.play();
|
||||
logger.log('[VLC] Played');
|
||||
}
|
||||
}
|
||||
}), [duration]);
|
||||
|
||||
// Compute aspect ratio string for VLC
|
||||
const toVlcRatio = useCallback((w: number, h: number): string => {
|
||||
const a = Math.max(1, Math.round(w));
|
||||
const b = Math.max(1, Math.round(h));
|
||||
const gcd = (x: number, y: number): number => (y === 0 ? x : gcd(y, x % y));
|
||||
const g = gcd(a, b);
|
||||
return `${Math.floor(a / g)}:${Math.floor(b / g)}`;
|
||||
}, []);
|
||||
|
||||
const screenDimensions = Dimensions.get('screen');
|
||||
|
||||
const vlcAspectRatio = useMemo(() => {
|
||||
// For VLC, no forced aspect ratio - let it preserve natural aspect
|
||||
return undefined;
|
||||
}, [resizeMode, screenDimensions.width, screenDimensions.height, toVlcRatio]);
|
||||
|
||||
const clientScale = useMemo(() => {
|
||||
if (!videoAspectRatio || screenDimensions.width <= 0 || screenDimensions.height <= 0) {
|
||||
return 1;
|
||||
}
|
||||
if (resizeMode === 'cover') {
|
||||
const screenAR = screenDimensions.width / screenDimensions.height;
|
||||
return Math.max(screenAR / videoAspectRatio, videoAspectRatio / screenAR);
|
||||
}
|
||||
return 1;
|
||||
}, [resizeMode, videoAspectRatio, screenDimensions.width, screenDimensions.height]);
|
||||
|
||||
// VLC options for better playback
|
||||
const vlcOptions = useMemo(() => {
|
||||
return [
|
||||
'--network-caching=2000',
|
||||
'--clock-jitter=0',
|
||||
'--http-reconnect',
|
||||
'--sout-mux-caching=2000'
|
||||
];
|
||||
}, []);
|
||||
|
||||
// VLC tracks prop
|
||||
const vlcTracks = useMemo(() => ({
|
||||
audio: selectedAudioTrack,
|
||||
video: 0, // Use first video track
|
||||
subtitle: selectedSubtitleTrack
|
||||
}), [selectedAudioTrack, selectedSubtitleTrack]);
|
||||
|
||||
const handleFirstPlay = useCallback((info: any) => {
|
||||
try {
|
||||
logger.log('[VLC] Video loaded, extracting tracks...');
|
||||
logger.log('[AndroidVideoPlayer][VLC] Video loaded successfully');
|
||||
|
||||
// Process VLC tracks using optimized function
|
||||
if (info?.tracks) {
|
||||
processVlcTracks(info.tracks);
|
||||
}
|
||||
|
||||
const lenSec = (info?.length ?? 0) / 1000;
|
||||
const width = info?.width || 0;
|
||||
const height = info?.height || 0;
|
||||
setDuration(lenSec);
|
||||
onLoad({ duration: lenSec, naturalSize: width && height ? { width, height } : undefined });
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
setVideoAspectRatio(width / height);
|
||||
}
|
||||
|
||||
// Restore playback position after remount (workaround for surface detach)
|
||||
if (restoreTime !== undefined && restoreTime !== null && restoreTime > 0) {
|
||||
setTimeout(() => {
|
||||
if (vlcRef.current && typeof vlcRef.current.seek === 'function') {
|
||||
const seekPosition = Math.min(restoreTime / lenSec, 0.999); // Convert to fraction
|
||||
vlcRef.current.seek(seekPosition);
|
||||
logger.log('[VLC] Seeked to restore position');
|
||||
}
|
||||
}, 500); // Small delay to ensure player is ready
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[VLC] onFirstPlay error:', e);
|
||||
logger.warn('[AndroidVideoPlayer][VLC] onFirstPlay parse error', e);
|
||||
}
|
||||
}, [onLoad, restoreTime]);
|
||||
|
||||
const handlePositionChanged = useCallback((ev: any) => {
|
||||
const pos = typeof ev?.position === 'number' ? ev.position : 0;
|
||||
// We need duration to calculate current time, but it's not available here
|
||||
// The parent component should handle this calculation
|
||||
onProgress({ position: pos });
|
||||
}, [onProgress]);
|
||||
|
||||
const handlePlaying = useCallback(() => {
|
||||
setVlcActive(true);
|
||||
}, []);
|
||||
|
||||
const handlePaused = useCallback(() => {
|
||||
setVlcActive(false);
|
||||
}, []);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
onEnd();
|
||||
}, [onEnd]);
|
||||
|
||||
const handleEncounteredError = useCallback((e: any) => {
|
||||
logger.error('[AndroidVideoPlayer][VLC] Encountered error:', e);
|
||||
onError(e);
|
||||
}, [onError]);
|
||||
|
||||
const handleBackground = useCallback(() => {
|
||||
logger.log('[VLC] App went to background');
|
||||
}, []);
|
||||
|
||||
const handleESAdded = useCallback((tracks: any) => {
|
||||
try {
|
||||
logger.log('[VLC] ES Added - processing tracks...');
|
||||
processVlcTracks(tracks);
|
||||
} catch (e) {
|
||||
logger.error('[VLC] onESAdded error:', e);
|
||||
logger.warn('[AndroidVideoPlayer][VLC] onESAdded parse error', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Format VLC tracks to match RN Video format - raw version
|
||||
const formatVlcTracks = useCallback((vlcTracks: Array<{id: number, name: string}>): VlcTrack[] => {
|
||||
if (!Array.isArray(vlcTracks)) return [];
|
||||
return vlcTracks.map(track => {
|
||||
// Just extract basic language info if available, but keep the full name
|
||||
let language = undefined;
|
||||
let displayName = track.name || `Track ${track.id + 1}`;
|
||||
|
||||
// Log the raw track data for debugging
|
||||
if (__DEV__) {
|
||||
logger.log(`[VLC] Raw track data:`, { id: track.id, name: track.name });
|
||||
}
|
||||
|
||||
// Only extract language from brackets if present, but keep full name
|
||||
const languageMatch = track.name?.match(/\[([^\]]+)\]/);
|
||||
if (languageMatch && languageMatch[1]) {
|
||||
language = languageMatch[1].trim();
|
||||
}
|
||||
|
||||
return {
|
||||
id: track.id,
|
||||
name: displayName, // Show exactly what VLC provides
|
||||
language: language
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Optimized VLC track processing function with reduced JSON operations
|
||||
const processVlcTracks = useCallback((tracks: any) => {
|
||||
if (!tracks) return;
|
||||
|
||||
// Log raw VLC tracks data for debugging
|
||||
if (__DEV__) {
|
||||
logger.log(`[VLC] Raw tracks data:`, tracks);
|
||||
}
|
||||
|
||||
const { audio = [], subtitle = [] } = tracks;
|
||||
|
||||
// Process audio tracks
|
||||
if (Array.isArray(audio) && audio.length > 0) {
|
||||
const formattedAudio = formatVlcTracks(audio);
|
||||
if (__DEV__) {
|
||||
logger.log(`[VLC] Audio tracks updated:`, formattedAudio.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Process subtitle tracks
|
||||
if (Array.isArray(subtitle) && subtitle.length > 0) {
|
||||
const formattedSubs = formatVlcTracks(subtitle);
|
||||
if (__DEV__) {
|
||||
logger.log(`[VLC] Subtitle tracks updated:`, formattedSubs.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify parent of track updates
|
||||
onTracksUpdate({ audio, subtitle });
|
||||
}, [formatVlcTracks, onTracksUpdate]);
|
||||
|
||||
// Process URL for VLC compatibility
|
||||
const processUrlForVLC = useCallback((url: string): string => {
|
||||
if (!url || typeof url !== 'string') {
|
||||
logger.warn('[AndroidVideoPlayer][VLC] Invalid URL provided:', url);
|
||||
return url || '';
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if URL is already properly formatted
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Handle special characters in the pathname that might cause issues
|
||||
const pathname = urlObj.pathname;
|
||||
const search = urlObj.search;
|
||||
const hash = urlObj.hash;
|
||||
|
||||
// Decode and re-encode the pathname to handle double-encoding
|
||||
const decodedPathname = decodeURIComponent(pathname);
|
||||
const encodedPathname = encodeURI(decodedPathname);
|
||||
|
||||
// Reconstruct the URL
|
||||
const processedUrl = `${urlObj.protocol}//${urlObj.host}${encodedPathname}${search}${hash}`;
|
||||
|
||||
logger.log(`[AndroidVideoPlayer][VLC] URL processed: ${url} -> ${processedUrl}`);
|
||||
return processedUrl;
|
||||
} catch (error) {
|
||||
logger.warn(`[AndroidVideoPlayer][VLC] URL processing failed, using original: ${error}`);
|
||||
return url;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const processedSource = useMemo(() => processUrlForVLC(source), [source, processUrlForVLC]);
|
||||
|
||||
if (!LibVlcPlayerViewComponent) {
|
||||
return (
|
||||
<View style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#000'
|
||||
}}>
|
||||
{/* VLC not available fallback */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<LibVlcPlayerViewComponent
|
||||
ref={vlcRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
transform: [{ scale: clientScale }]
|
||||
}}
|
||||
// Force remount when surfaces are recreated
|
||||
key={key || 'vlc-default'}
|
||||
source={processedSource}
|
||||
aspectRatio={vlcAspectRatio}
|
||||
// Let VLC auto-fit the video to the view to prevent flicker on mode changes
|
||||
scale={0}
|
||||
options={vlcOptions}
|
||||
tracks={vlcTracks}
|
||||
volume={Math.round(Math.max(0, Math.min(1, volume)) * 100)}
|
||||
mute={false}
|
||||
repeat={false}
|
||||
rate={playbackSpeed}
|
||||
autoplay={false}
|
||||
onFirstPlay={handleFirstPlay}
|
||||
onPositionChanged={handlePositionChanged}
|
||||
onPlaying={handlePlaying}
|
||||
onPaused={handlePaused}
|
||||
onEndReached={handleEndReached}
|
||||
onEncounteredError={handleEncounteredError}
|
||||
onBackground={handleBackground}
|
||||
onESAdded={handleESAdded}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
VlcVideoPlayer.displayName = 'VlcVideoPlayer';
|
||||
|
||||
export default VlcVideoPlayer;
|
||||
143
src/components/player/android/MpvPlayer.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||
import { View, StyleSheet, requireNativeComponent, Platform, UIManager, findNodeHandle } from 'react-native';
|
||||
|
||||
// Only available on Android
|
||||
const MpvPlayerNative = Platform.OS === 'android'
|
||||
? requireNativeComponent<any>('MpvPlayer')
|
||||
: null;
|
||||
|
||||
export interface MpvPlayerRef {
|
||||
seek: (positionSeconds: number) => void;
|
||||
setAudioTrack: (trackId: number) => void;
|
||||
setSubtitleTrack: (trackId: number) => void;
|
||||
}
|
||||
|
||||
export interface MpvPlayerProps {
|
||||
source: string;
|
||||
headers?: { [key: string]: string };
|
||||
paused?: boolean;
|
||||
volume?: number;
|
||||
rate?: number;
|
||||
resizeMode?: 'contain' | 'cover' | 'stretch';
|
||||
style?: any;
|
||||
onLoad?: (data: { duration: number; width: number; height: number }) => void;
|
||||
onProgress?: (data: { currentTime: number; duration: number }) => void;
|
||||
onEnd?: () => void;
|
||||
onError?: (error: { error: string }) => void;
|
||||
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
|
||||
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
|
||||
gpuMode?: 'gpu' | 'gpu-next';
|
||||
// Subtitle Styling
|
||||
subtitleSize?: number;
|
||||
subtitleColor?: string;
|
||||
subtitleBackgroundOpacity?: number;
|
||||
subtitleBorderSize?: number;
|
||||
subtitleBorderColor?: string;
|
||||
subtitleShadowEnabled?: boolean;
|
||||
subtitlePosition?: number;
|
||||
subtitleDelay?: number;
|
||||
subtitleAlignment?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
|
||||
const nativeRef = useRef<any>(null);
|
||||
|
||||
const dispatchCommand = useCallback((commandName: string, args: any[] = []) => {
|
||||
if (nativeRef.current && Platform.OS === 'android') {
|
||||
const handle = findNodeHandle(nativeRef.current);
|
||||
if (handle) {
|
||||
UIManager.dispatchViewManagerCommand(
|
||||
handle,
|
||||
commandName,
|
||||
args
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek: (positionSeconds: number) => {
|
||||
dispatchCommand('seek', [positionSeconds]);
|
||||
},
|
||||
setAudioTrack: (trackId: number) => {
|
||||
dispatchCommand('setAudioTrack', [trackId]);
|
||||
},
|
||||
setSubtitleTrack: (trackId: number) => {
|
||||
dispatchCommand('setSubtitleTrack', [trackId]);
|
||||
},
|
||||
}), [dispatchCommand]);
|
||||
|
||||
if (Platform.OS !== 'android' || !MpvPlayerNative) {
|
||||
// Fallback for iOS or if native component is not available
|
||||
return (
|
||||
<View style={[styles.container, props.style, { backgroundColor: 'black' }]} />
|
||||
);
|
||||
}
|
||||
|
||||
// Debug logging removed to prevent console spam
|
||||
|
||||
const handleLoad = (event: any) => {
|
||||
console.log('[MpvPlayer] Native onLoad event:', event?.nativeEvent);
|
||||
props.onLoad?.(event?.nativeEvent);
|
||||
};
|
||||
|
||||
const handleProgress = (event: any) => {
|
||||
props.onProgress?.(event?.nativeEvent);
|
||||
};
|
||||
|
||||
const handleEnd = (event: any) => {
|
||||
console.log('[MpvPlayer] Native onEnd event');
|
||||
props.onEnd?.();
|
||||
};
|
||||
|
||||
const handleError = (event: any) => {
|
||||
console.log('[MpvPlayer] Native onError event:', event?.nativeEvent);
|
||||
props.onError?.(event?.nativeEvent);
|
||||
};
|
||||
|
||||
const handleTracksChanged = (event: any) => {
|
||||
console.log('[MpvPlayer] Native onTracksChanged event:', event?.nativeEvent);
|
||||
props.onTracksChanged?.(event?.nativeEvent);
|
||||
};
|
||||
|
||||
return (
|
||||
<MpvPlayerNative
|
||||
ref={nativeRef}
|
||||
style={[styles.container, props.style]}
|
||||
source={props.source}
|
||||
headers={props.headers}
|
||||
paused={props.paused ?? true}
|
||||
volume={props.volume ?? 1.0}
|
||||
rate={props.rate ?? 1.0}
|
||||
resizeMode={props.resizeMode ?? 'contain'}
|
||||
onLoad={handleLoad}
|
||||
onProgress={handleProgress}
|
||||
onEnd={handleEnd}
|
||||
onError={handleError}
|
||||
onTracksChanged={handleTracksChanged}
|
||||
decoderMode={props.decoderMode ?? 'auto'}
|
||||
gpuMode={props.gpuMode ?? 'gpu'}
|
||||
// Subtitle Styling
|
||||
subtitleSize={props.subtitleSize ?? 48}
|
||||
subtitleColor={props.subtitleColor ?? '#FFFFFF'}
|
||||
subtitleBackgroundOpacity={props.subtitleBackgroundOpacity ?? 0}
|
||||
subtitleBorderSize={props.subtitleBorderSize ?? 3}
|
||||
subtitleBorderColor={props.subtitleBorderColor ?? '#000000'}
|
||||
subtitleShadowEnabled={props.subtitleShadowEnabled ?? true}
|
||||
subtitlePosition={props.subtitlePosition ?? 100}
|
||||
subtitleDelay={props.subtitleDelay ?? 0}
|
||||
subtitleAlignment={props.subtitleAlignment ?? 'center'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: 'black',
|
||||
},
|
||||
});
|
||||
|
||||
MpvPlayer.displayName = 'MpvPlayer';
|
||||
|
||||
export default MpvPlayer;
|
||||
194
src/components/player/android/components/GestureControls.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import {
|
||||
TapGestureHandler,
|
||||
PanGestureHandler,
|
||||
LongPressGestureHandler,
|
||||
State
|
||||
} from 'react-native-gesture-handler';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { styles as localStyles } from '../../utils/playerStyles';
|
||||
|
||||
interface GestureControlsProps {
|
||||
screenDimensions: { width: number, height: number };
|
||||
gestureControls: any;
|
||||
onLongPressActivated: () => void;
|
||||
onLongPressEnd: () => void;
|
||||
onLongPressStateChange: (event: any) => void;
|
||||
toggleControls: () => void;
|
||||
showControls: boolean;
|
||||
hideControls: () => void;
|
||||
volume: number;
|
||||
brightness: number;
|
||||
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
}
|
||||
|
||||
export const GestureControls: React.FC<GestureControlsProps> = ({
|
||||
screenDimensions,
|
||||
gestureControls,
|
||||
onLongPressActivated,
|
||||
onLongPressEnd,
|
||||
onLongPressStateChange,
|
||||
toggleControls,
|
||||
showControls,
|
||||
hideControls,
|
||||
volume,
|
||||
brightness,
|
||||
controlsTimeout
|
||||
}) => {
|
||||
|
||||
const getVolumeIcon = (value: number) => {
|
||||
if (value === 0) return 'volume-off';
|
||||
if (value < 0.3) return 'volume-mute';
|
||||
if (value < 0.6) return 'volume-down';
|
||||
return 'volume-up';
|
||||
};
|
||||
|
||||
const getBrightnessIcon = (value: number) => {
|
||||
if (value < 0.3) return 'brightness-low';
|
||||
if (value < 0.7) return 'brightness-medium';
|
||||
return 'brightness-high';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left side gesture handler - brightness + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onBrightnessGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Right side gesture handler - volume + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onVolumeGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
right: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Center area tap handler */}
|
||||
<TapGestureHandler
|
||||
onActivated={() => {
|
||||
if (showControls) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hideControls();
|
||||
}, 0);
|
||||
if (controlsTimeout.current) {
|
||||
clearTimeout(controlsTimeout.current);
|
||||
}
|
||||
controlsTimeout.current = timeoutId;
|
||||
} else {
|
||||
toggleControls();
|
||||
}
|
||||
}}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: screenDimensions.width * 0.4,
|
||||
width: screenDimensions.width * 0.2,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 5,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Volume/Brightness Pill Overlay */}
|
||||
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View
|
||||
style={[
|
||||
localStyles.iconWrapper,
|
||||
{
|
||||
backgroundColor: gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(242, 184, 181)'
|
||||
: 'rgba(59, 59, 59)'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={
|
||||
gestureControls.showVolumeOverlay
|
||||
? getVolumeIcon(volume)
|
||||
: getBrightnessIcon(brightness)
|
||||
}
|
||||
size={24}
|
||||
color={
|
||||
gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(96, 20, 16)'
|
||||
: 'rgba(255, 255, 255)'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
localStyles.gestureText,
|
||||
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181)' }
|
||||
]}
|
||||
>
|
||||
{gestureControls.showVolumeOverlay && volume === 0
|
||||
? "Muted"
|
||||
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%`
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
428
src/components/player/android/components/VideoSurface.tsx
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
import React, { useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native';
|
||||
import { PinchGestureHandler } from 'react-native-gesture-handler';
|
||||
import Video, { VideoRef, SelectedTrack, SelectedVideoTrack, ResizeMode } from 'react-native-video';
|
||||
import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer';
|
||||
import { styles } from '../../utils/playerStyles';
|
||||
import { ResizeModeType } from '../../utils/playerTypes';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
|
||||
// Codec error patterns that indicate we should fallback to MPV
|
||||
const CODEC_ERROR_PATTERNS = [
|
||||
'exceeds_capabilities',
|
||||
'no_exceeds_capabilities',
|
||||
'decoder_exception',
|
||||
'decoder.*error',
|
||||
'codec.*error',
|
||||
'unsupported.*codec',
|
||||
'mediacodec.*exception',
|
||||
'omx.*error',
|
||||
'dolby.*vision',
|
||||
'hevc.*error',
|
||||
'no suitable decoder',
|
||||
'decoder initialization failed',
|
||||
'format.no_decoder',
|
||||
'no_decoder',
|
||||
'decoding_failed',
|
||||
'error_code_decoding',
|
||||
'exoplaybackexception',
|
||||
'mediacodecvideodecoder',
|
||||
'mediacodecvideodecoderexception',
|
||||
'decoder failed',
|
||||
];
|
||||
|
||||
interface VideoSurfaceProps {
|
||||
processedStreamUrl: string;
|
||||
headers?: { [key: string]: string };
|
||||
volume: number;
|
||||
playbackSpeed: number;
|
||||
resizeMode: ResizeModeType;
|
||||
paused: boolean;
|
||||
currentStreamUrl: string;
|
||||
|
||||
// Callbacks
|
||||
toggleControls: () => void;
|
||||
onLoad: (data: any) => void;
|
||||
onProgress: (data: any) => void;
|
||||
onSeek: (data: any) => void;
|
||||
onEnd: () => void;
|
||||
onError: (err: any) => void;
|
||||
onBuffer: (buf: any) => void;
|
||||
|
||||
// Refs
|
||||
mpvPlayerRef?: React.RefObject<MpvPlayerRef>;
|
||||
exoPlayerRef?: React.RefObject<VideoRef>;
|
||||
pinchRef: any;
|
||||
|
||||
// Handlers
|
||||
onPinchGestureEvent: any;
|
||||
onPinchHandlerStateChange: any;
|
||||
screenDimensions: { width: number, height: number };
|
||||
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
|
||||
selectedAudioTrack?: SelectedTrack;
|
||||
selectedTextTrack?: SelectedTrack;
|
||||
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
|
||||
gpuMode?: 'gpu' | 'gpu-next';
|
||||
|
||||
// Dual Engine Props
|
||||
useExoPlayer?: boolean;
|
||||
onCodecError?: () => void;
|
||||
onEngineChange?: (engine: 'exoplayer' | 'mpv') => void;
|
||||
|
||||
// Subtitle Styling
|
||||
subtitleSize?: number;
|
||||
subtitleColor?: string;
|
||||
subtitleBackgroundOpacity?: number;
|
||||
subtitleBorderSize?: number;
|
||||
subtitleBorderColor?: string;
|
||||
subtitleShadowEnabled?: boolean;
|
||||
subtitlePosition?: number;
|
||||
subtitleBottomOffset?: number;
|
||||
subtitleDelay?: number;
|
||||
subtitleAlignment?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
// Helper function to check if error is a codec error
|
||||
const isCodecError = (errorString: string): boolean => {
|
||||
const lowerError = errorString.toLowerCase();
|
||||
return CODEC_ERROR_PATTERNS.some(pattern => {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
return regex.test(lowerError);
|
||||
});
|
||||
};
|
||||
|
||||
export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
||||
processedStreamUrl,
|
||||
headers,
|
||||
volume,
|
||||
playbackSpeed,
|
||||
resizeMode,
|
||||
paused,
|
||||
currentStreamUrl,
|
||||
toggleControls,
|
||||
onLoad,
|
||||
onProgress,
|
||||
onSeek,
|
||||
onEnd,
|
||||
onError,
|
||||
onBuffer,
|
||||
mpvPlayerRef,
|
||||
exoPlayerRef,
|
||||
pinchRef,
|
||||
onPinchGestureEvent,
|
||||
onPinchHandlerStateChange,
|
||||
screenDimensions,
|
||||
onTracksChanged,
|
||||
selectedAudioTrack,
|
||||
selectedTextTrack,
|
||||
decoderMode,
|
||||
gpuMode,
|
||||
// Dual Engine
|
||||
useExoPlayer = true,
|
||||
onCodecError,
|
||||
onEngineChange,
|
||||
// Subtitle Styling
|
||||
subtitleSize,
|
||||
subtitleColor,
|
||||
subtitleBackgroundOpacity,
|
||||
subtitleBorderSize,
|
||||
subtitleBorderColor,
|
||||
subtitleShadowEnabled,
|
||||
subtitlePosition,
|
||||
subtitleBottomOffset,
|
||||
subtitleDelay,
|
||||
subtitleAlignment,
|
||||
}) => {
|
||||
// Use the actual stream URL
|
||||
const streamUrl = currentStreamUrl || processedStreamUrl;
|
||||
|
||||
// ========== MPV Handlers ==========
|
||||
const handleMpvLoad = (data: { duration: number; width: number; height: number }) => {
|
||||
console.log('[VideoSurface] MPV onLoad received:', data);
|
||||
onLoad({
|
||||
duration: data.duration,
|
||||
naturalSize: {
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleMpvProgress = (data: { currentTime: number; duration: number }) => {
|
||||
onProgress({
|
||||
currentTime: data.currentTime,
|
||||
playableDuration: data.currentTime,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMpvError = (error: { error: string }) => {
|
||||
console.log('[VideoSurface] MPV onError received:', error);
|
||||
onError({
|
||||
error: {
|
||||
errorString: error.error,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleMpvEnd = () => {
|
||||
console.log('[VideoSurface] MPV onEnd received');
|
||||
onEnd();
|
||||
};
|
||||
|
||||
// ========== ExoPlayer Handlers ==========
|
||||
const handleExoLoad = (data: any) => {
|
||||
console.log('[VideoSurface] ExoPlayer onLoad received:', data);
|
||||
console.log('[VideoSurface] ExoPlayer textTracks raw:', JSON.stringify(data.textTracks, null, 2));
|
||||
|
||||
// Extract track information
|
||||
// IMPORTANT:
|
||||
// react-native-video expects selected*Track with { type: 'index', value: <0-based array index> }.
|
||||
// Some RNVideo/Exo track objects expose `index`, but it is not guaranteed to be unique or
|
||||
// aligned with the list index. Using it can cause only the first item to render/select.
|
||||
const audioTracks = data.audioTracks?.map((t: any, i: number) => ({
|
||||
id: i,
|
||||
name: t.title || t.language || `Track ${i + 1}`,
|
||||
language: t.language,
|
||||
})) ?? [];
|
||||
|
||||
const subtitleTracks = data.textTracks?.map((t: any, i: number) => {
|
||||
const track = {
|
||||
id: i,
|
||||
name: t.title || t.language || `Track ${i + 1}`,
|
||||
language: t.language,
|
||||
};
|
||||
console.log('[VideoSurface] Mapped subtitle track:', track, 'original:', t);
|
||||
return track;
|
||||
}) ?? [];
|
||||
|
||||
if (onTracksChanged && (audioTracks.length > 0 || subtitleTracks.length > 0)) {
|
||||
onTracksChanged({ audioTracks, subtitleTracks });
|
||||
}
|
||||
|
||||
onLoad({
|
||||
duration: data.duration,
|
||||
naturalSize: data.naturalSize || { width: 1920, height: 1080 },
|
||||
audioTracks: data.audioTracks,
|
||||
textTracks: data.textTracks,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExoProgress = (data: any) => {
|
||||
onProgress({
|
||||
currentTime: data.currentTime,
|
||||
playableDuration: data.playableDuration || data.currentTime,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExoError = (error: any) => {
|
||||
console.log('[VideoSurface] ExoPlayer onError received:', JSON.stringify(error, null, 2));
|
||||
|
||||
// Extract error string - try multiple paths
|
||||
let errorString = 'Unknown error';
|
||||
const errorParts: string[] = [];
|
||||
|
||||
if (typeof error?.error === 'string') {
|
||||
errorParts.push(error.error);
|
||||
}
|
||||
if (error?.error?.errorString) {
|
||||
errorParts.push(error.error.errorString);
|
||||
}
|
||||
if (error?.error?.errorCode) {
|
||||
errorParts.push(String(error.error.errorCode));
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
errorParts.push(error);
|
||||
}
|
||||
if (error?.nativeStackAndroid) {
|
||||
errorParts.push(error.nativeStackAndroid.join(' '));
|
||||
}
|
||||
if (error?.message) {
|
||||
errorParts.push(error.message);
|
||||
}
|
||||
|
||||
// Combine all error parts for comprehensive checking
|
||||
errorString = errorParts.length > 0 ? errorParts.join(' ') : JSON.stringify(error);
|
||||
|
||||
console.log('[VideoSurface] Extracted error string:', errorString);
|
||||
console.log('[VideoSurface] isCodecError result:', isCodecError(errorString));
|
||||
|
||||
// Check if this is a codec error that should trigger fallback
|
||||
if (isCodecError(errorString)) {
|
||||
logger.warn('[VideoSurface] ExoPlayer codec error detected, triggering MPV fallback:', errorString);
|
||||
onCodecError?.();
|
||||
return; // Don't propagate codec errors - we're falling back silently
|
||||
}
|
||||
|
||||
// Non-codec errors should be propagated
|
||||
onError({
|
||||
error: {
|
||||
errorString: errorString,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExoBuffer = (data: any) => {
|
||||
onBuffer({ isBuffering: data.isBuffering });
|
||||
};
|
||||
|
||||
const handleExoEnd = () => {
|
||||
console.log('[VideoSurface] ExoPlayer onEnd received');
|
||||
onEnd();
|
||||
};
|
||||
|
||||
const handleExoSeek = (data: any) => {
|
||||
onSeek({ currentTime: data.currentTime });
|
||||
};
|
||||
|
||||
// Map ResizeModeType to react-native-video ResizeMode
|
||||
const getExoResizeMode = (): ResizeMode => {
|
||||
switch (resizeMode) {
|
||||
case 'cover':
|
||||
return ResizeMode.COVER;
|
||||
case 'stretch':
|
||||
return ResizeMode.STRETCH;
|
||||
case 'contain':
|
||||
default:
|
||||
return ResizeMode.CONTAIN;
|
||||
}
|
||||
};
|
||||
|
||||
const alphaHex = (opacity01: number) => {
|
||||
const a = Math.max(0, Math.min(1, opacity01));
|
||||
return Math.round(a * 255).toString(16).padStart(2, '0').toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.videoContainer, {
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
}]}>
|
||||
{useExoPlayer ? (
|
||||
/* ExoPlayer via react-native-video */
|
||||
<Video
|
||||
ref={exoPlayerRef}
|
||||
source={{
|
||||
uri: streamUrl,
|
||||
headers: headers,
|
||||
}}
|
||||
paused={paused}
|
||||
volume={volume}
|
||||
rate={playbackSpeed}
|
||||
resizeMode={getExoResizeMode()}
|
||||
selectedAudioTrack={selectedAudioTrack}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
style={localStyles.player}
|
||||
onLoad={handleExoLoad}
|
||||
onProgress={handleExoProgress}
|
||||
onEnd={handleExoEnd}
|
||||
onError={handleExoError}
|
||||
onBuffer={handleExoBuffer}
|
||||
onSeek={handleExoSeek}
|
||||
progressUpdateInterval={500}
|
||||
playInBackground={false}
|
||||
playWhenInactive={false}
|
||||
ignoreSilentSwitch="ignore"
|
||||
automaticallyWaitsToMinimizeStalling={true}
|
||||
useTextureView={true}
|
||||
// Subtitle Styling for ExoPlayer
|
||||
// ExoPlayer (via our patched react-native-video) supports:
|
||||
// - fontSize, paddingTop/Bottom/Left/Right, opacity, subtitlesFollowVideo
|
||||
// - PLUS: textColor, backgroundColor, edgeType, edgeColor (outline/shadow)
|
||||
subtitleStyle={{
|
||||
// Convert MPV-scaled size back to UI size (AndroidVideoPlayer passes MPV-scaled values here)
|
||||
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 28,
|
||||
paddingTop: 0,
|
||||
// IMPORTANT:
|
||||
// Use the same unit as external subtitles (RN CustomSubtitles uses dp bottomOffset directly).
|
||||
// Using MPV's subtitlePosition mapping makes internal/external offsets feel inconsistent.
|
||||
paddingBottom: Math.max(0, Math.round(subtitleBottomOffset ?? 0)),
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
// Opacity controls entire subtitle view visibility
|
||||
// Always keep text visible (opacity 1), background control is limited in ExoPlayer
|
||||
opacity: 1,
|
||||
subtitlesFollowVideo: false,
|
||||
// Extended styling (requires our patched RNVideo on Android)
|
||||
textColor: subtitleColor || '#FFFFFFFF',
|
||||
// Android Color.parseColor doesn't accept rgba(...). Use #AARRGGBB.
|
||||
backgroundColor:
|
||||
subtitleBackgroundOpacity && subtitleBackgroundOpacity > 0
|
||||
? `#${alphaHex(subtitleBackgroundOpacity)}000000`
|
||||
: '#00000000',
|
||||
edgeType:
|
||||
subtitleBorderSize && subtitleBorderSize > 0
|
||||
? 'outline'
|
||||
: (subtitleShadowEnabled ? 'shadow' : 'none'),
|
||||
edgeColor:
|
||||
(subtitleBorderSize && subtitleBorderSize > 0 && subtitleBorderColor)
|
||||
? subtitleBorderColor
|
||||
: (subtitleShadowEnabled ? '#FF000000' : 'transparent'),
|
||||
} as any}
|
||||
/>
|
||||
) : (
|
||||
/* MPV Player fallback */
|
||||
<MpvPlayer
|
||||
ref={mpvPlayerRef}
|
||||
source={streamUrl}
|
||||
headers={headers}
|
||||
paused={paused}
|
||||
volume={volume}
|
||||
rate={playbackSpeed}
|
||||
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
|
||||
style={localStyles.player}
|
||||
onLoad={handleMpvLoad}
|
||||
onProgress={handleMpvProgress}
|
||||
onEnd={handleMpvEnd}
|
||||
onError={handleMpvError}
|
||||
onTracksChanged={onTracksChanged}
|
||||
decoderMode={decoderMode}
|
||||
gpuMode={gpuMode}
|
||||
// Subtitle Styling
|
||||
subtitleSize={subtitleSize}
|
||||
subtitleColor={subtitleColor}
|
||||
subtitleBackgroundOpacity={subtitleBackgroundOpacity}
|
||||
subtitleBorderSize={subtitleBorderSize}
|
||||
subtitleBorderColor={subtitleBorderColor}
|
||||
subtitleShadowEnabled={subtitleShadowEnabled}
|
||||
subtitlePosition={subtitlePosition}
|
||||
subtitleDelay={subtitleDelay}
|
||||
subtitleAlignment={subtitleAlignment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gesture overlay - transparent, on top of the player */}
|
||||
<PinchGestureHandler
|
||||
ref={pinchRef}
|
||||
onGestureEvent={onPinchGestureEvent}
|
||||
onHandlerStateChange={onPinchHandlerStateChange}
|
||||
>
|
||||
<View style={localStyles.gestureOverlay} pointerEvents="box-only">
|
||||
<TouchableWithoutFeedback onPress={toggleControls}>
|
||||
<View style={localStyles.touchArea} />
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</PinchGestureHandler>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const localStyles = StyleSheet.create({
|
||||
player: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
gestureOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
touchArea: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||