Compare commits

...

148 commits
1.3.3 ... main

Author SHA1 Message Date
tapframe
7bf3a344f3 release: 1.3.5 (33) 2026-01-12 00:13:29 +05:30
Nayif
14e8e90ee3
Merge pull request #391 from saifshaikh1805/patch-issue301
fix: issue #301
2026-01-11 22:48:15 +05:30
Nayif
d52c202518
Merge pull request #394 from AdityasahuX07/patch-20
Implement save and load for discover settings
2026-01-11 22:47:38 +05:30
tapframe
c728f4ea8d updated exoplayer sub behaviour 2026-01-11 22:46:54 +05:30
tapframe
c20c2713d0 updated exosub 2026-01-11 20:45:36 +05:30
tapframe
d398c73214 removed debrid integration 2026-01-11 20:45:32 +05:30
tapframe
9e6b455323 temp disable splash 2026-01-11 20:45:28 +05:30
AdityasahuX07
5a2271c64e
Implement save and load for discover settings
Added functionality to save and load discover settings including type, catalog, and genre.
2026-01-11 18:21:56 +05:30
tapframe
eb6fcf639f ksp sub updates 2026-01-11 00:46:30 +05:30
tapframe
a85cc93026 internal sub offset, bg fix android 2026-01-10 23:43:32 +05:30
tapframe
56fd18a8e9 Merge branch 'main' of https://github.com/tapframe/NuvioStreaming 2026-01-10 23:31:47 +05:30
tapframe
82d0ebb714 Fix ExoPlayer subtitle styling and iOS MPV config
- Fix subtitle track selection (only first track worked)
- Fix subtitle styling (background, outline, bottom offset)
- Update iOS MPV to match Wayve settings (Vulkan, HDR, stability options)
- Add patch-package for react-native-video fixes
2026-01-10 23:31:08 +05:30
Saif Shaikh
df5772d40b fix: issue #301 2026-01-10 08:10:20 -08:00
Nayif
3030d5961d
Merge pull request #388 from saifshaikh1805/patch/bottomSheetIssuesAndRefactor
patch: bottom sheet back button behavior, SettingsScreen.tsx refactor
2026-01-10 13:36:20 +05:30
Saif Shaikh
6974768457 patch: bottom sheet back button behavior, SettingsScreen.tsx refactor 2026-01-09 23:46:31 -08:00
Nayif
d31cd2fcdc
Merge pull request #386 from AdityasahuX07/patch-19
Adjust padding and margin values in LibraryScreen and CatalogSection
2026-01-09 23:17:48 +05:30
AdityasahuX07
b916bdbcca
Adjust item separator height in CatalogScreen 2026-01-09 20:19:48 +05:30
AdityasahuX07
67d53cf5ce
Adjust padding and margin values in LibraryScreen and CatalogSection
make the uniform gap between the posters to look symmetric.
2026-01-09 20:15:56 +05:30
tapframe
175d6a173e update doc 2026-01-09 18:27:19 +05:30
tapframe
b7140e15a5 added an AppState listener to the player 2026-01-09 17:49:39 +05:30
tapframe
76310dae1b updated AI model 2026-01-09 17:02:08 +05:30
tapframe
01a041aebf fix: fixed autoplay stream 2026-01-09 16:46:46 +05:30
tapframe
031c0c8772 added dev options to prod builds 2026-01-09 01:11:29 +05:30
tapframe
fd1e303403 Merge branch 'main' of https://github.com/tapframe/NuvioStreaming 2026-01-09 00:44:40 +05:30
tapframe
45b63cb33f fixed skip bacwards icon render cache issue 2026-01-09 00:44:32 +05:30
Nayif
c9dfecb68c
Merge pull request #381 from AdityasahuX07/patch-18
Move libraryBadge to its correct position.
2026-01-08 23:29:20 +05:30
tapframe
aa6406eae0 added dev docs 2026-01-08 21:38:48 +05:30
tapframe
26e4c6db88 updated docs 2026-01-08 21:37:01 +05:30
tapframe
2439bd1cd8 added deeplink support for plugin installation 2026-01-08 16:50:18 +05:30
tapframe
1fdcdd02bf updated tablet ui for plugin test screen 2026-01-08 16:15:15 +05:30
tapframe
bb94a49662 Fix: Critical Tablet screen crash fix upon app opening 2026-01-08 15:54:40 +05:30
tapframe
2ebec55bbc updated continue watching logic to render only last 30 watch progress 2026-01-08 15:03:59 +05:30
tapframe
5fe23c7ad1 fixed tmdb enrichment logic overrding addons meta while turned off. 2026-01-08 14:10:43 +05:30
tapframe
b6a5c108de All slide_from_right animations for Android have been replaced with 'default' 2026-01-08 13:09:44 +05:30
tapframe
83ce7cf44d fix: updated stremioservice to handle empty meta addon cases 2026-01-08 13:05:36 +05:30
tapframe
28632d192f updated plugin tester localization 2026-01-08 13:00:42 +05:30
tapframe
2a265bf716 update dev tools translation 2026-01-08 12:35:37 +05:30
AdityasahuX07
b06800860c
move libraryBadge to its correct position.
Add styles to libraryBadge in SearchResultItem.

Fixes issue #377
2026-01-08 12:30:55 +05:30
tapframe
75702d823f plugintest: added player testing support 2026-01-08 03:49:38 +05:30
tapframe
f865b737e6 adjusted plugintest screen layout for tablets 2026-01-08 03:44:39 +05:30
tapframe
2169354f0d refactored plugintest screen 2026-01-08 03:41:32 +05:30
tapframe
8dc1217c36 added repo testing 2026-01-08 03:16:37 +05:30
tapframe
0a1511f09f plugintest screen init 2026-01-08 02:41:43 +05:30
tapframe
73030f150a fix: android seekbar to show timestamp as we drag 2026-01-08 01:30:58 +05:30
tapframe
a1f4702647 reanimated warnings: Fixed by removing the direct .value read in
SkipIntroButton
2026-01-08 00:31:33 +05:30
tapframe
2ddfe63fa4 added polyfill to follow redirect "manual" 2026-01-08 00:27:26 +05:30
tapframe
79ffe92864 fixed searchscreen handlers after the refactor. 2026-01-07 22:29:21 +05:30
tapframe
e5178c9414 Added the missing malId and kitsuId props to
KSPlayerCore.tsx
2026-01-07 22:18:27 +05:30
Nayif
f779febc32
Merge pull request #367 from AdityasahuX07/patch-17
[Updated]Added double tap on search button to open keyboard for ready to search feature.
2026-01-07 22:13:23 +05:30
Nayif
5afd3d6b08
Merge pull request #358 from paregi12/feature/ani-skip
feat: implement AniSkip support in video player
2026-01-07 22:12:54 +05:30
tapframe
6005574019 added german and pt-Portugal in localisation 2026-01-07 18:13:37 +05:30
AdityasahuX07
645dcecaca
Merge branch 'main' into patch-17 2026-01-07 17:33:53 +05:30
AdityasahuX07
1686138499
Update AppNavigator.tsx 2026-01-07 17:32:52 +05:30
AdityasahuX07
cd1ed27f1e
Update print statement from 'Hello' to 'Goodbye' 2026-01-07 17:19:19 +05:30
AdityasahuX07
3b210b06d5
Update AppNavigator.tsx 2026-01-07 17:18:37 +05:30
tapframe
0f9c1b03a5 added trakt attribution 2026-01-07 16:38:04 +05:30
tapframe
217244c367 removing unattributed logos 2026-01-07 15:45:24 +05:30
tapframe
852868cf89 updated legalscreen with localization 2026-01-07 14:22:31 +05:30
tapframe
a52a2ccc31 update readme 2026-01-07 14:16:18 +05:30
tapframe
210ae6b0ee updated pluginscreen terminology 2026-01-07 14:01:02 +05:30
tapframe
c6e55429e4 Merge branch 'main' of https://github.com/tapframe/NuvioStreaming 2026-01-07 13:31:20 +05:30
tapframe
07b27dd485 fixed the issue where common.settings was displayed as a raw key 2026-01-07 13:31:05 +05:30
paregi12
5166dbd446
Merge branch 'tapframe:main' into feature/ani-skip 2026-01-07 13:18:43 +05:30
Nayif
0722923a78
Merge pull request #370 from Eazvy/main
fix escape key crashing on macOS
2026-01-07 12:50:16 +05:30
tapframe
a85698b009 added localization to themescreen. 2026-01-07 12:44:42 +05:30
tapframe
9b2b619121 added italian language to UI. 2026-01-07 12:37:22 +05:30
Nayif
ac097f6513
Merge pull request #371 from albyalex96/patch-1
Add Italian Localization
2026-01-07 12:32:14 +05:30
paregi12
a383289457
Merge branch 'tapframe:main' into feature/ani-skip 2026-01-07 07:47:50 +05:30
albyalex96
e76b44cff1
Created it locale 2026-01-07 01:03:24 +01:00
Eazvy
0f9f6bbe5d
fix accidental override of buffering 2026-01-06 18:23:01 -05:00
Eazvy
c48670fa74
fix escape key crashing on macOS
just adds an ignore listener so it doesn't crash, nor do anything
2026-01-06 18:13:04 -05:00
Nayif
c530619039
Merge pull request #368 from saimuelbr/main
Correcting forgotten parameter in json i18n
2026-01-07 01:06:57 +05:30
saimuelbr
5e221e7e97 minor fix 2026-01-06 16:03:52 -03:00
tapframe
65909a5f2e catalogscreen optimization for heavy render list 2026-01-07 00:23:03 +05:30
tapframe
bbdd4c0504 updated remaining contents for localization 2026-01-07 00:05:02 +05:30
tapframe
9924d26ff6 refactor search screen 2026-01-06 23:48:22 +05:30
tapframe
b10aab6057 release: 1.3.4 2026-01-06 19:44:54 +05:30
paregi12
ccad48fbb4
Merge branch 'tapframe:main' into feature/ani-skip 2026-01-06 18:29:04 +05:30
tapframe
91e9549ec6 type fix 2026-01-06 17:43:03 +05:30
AdityasahuX07
066bf6f15d
Enhance Search tab behavior with event emitter
Added DeviceEventEmitter to handle search input focus on tab press for Search tab.
2026-01-06 17:08:42 +05:30
AdityasahuX07
56df30a4da
Implement focus event listener for Search tab
Added a focus event listener to handle when the Search tab is pressed while already on the Search screen, focusing the input and clearing previous results.
2026-01-06 17:02:38 +05:30
tapframe
27ce25f5c5 added french 2026-01-06 16:11:54 +05:30
tapframe
334d0b1863 added arabic 2026-01-06 15:56:27 +05:30
tapframe
437645d5fd updated remaining files 2026-01-06 15:32:23 +05:30
tapframe
280536e93c updated remaining pages for localization. metascreen and player components 2026-01-06 15:26:13 +05:30
tapframe
611b37c847 added to remaining. metascreen 2026-01-06 14:57:08 +05:30
tapframe
5e3198c9c6 metascreen/streamscrean localization init 2026-01-06 14:46:11 +05:30
tapframe
6ef047db3c updated remaining main screens for localization 2026-01-06 14:04:16 +05:30
tapframe
cdab715463 updated tab navigator for localization 2026-01-06 13:18:31 +05:30
tapframe
96ac361c8e completed settingscreen localization 2026-01-06 13:15:07 +05:30
tapframe
ed4950cd1f updated sub pages 2026-01-06 12:07:37 +05:30
tapframe
afddf4bf2d updated settinsgcreen and it's sub-pages to support localization 2026-01-06 11:39:12 +05:30
tapframe
9c37ad8b94 multi-lang init 2026-01-06 11:34:05 +05:30
tapframe
9877f513e2 up next logic improvements 2026-01-06 10:07:58 +05:30
tapframe
f4b5082827 chore: updated continue watching card hold behaviour 2026-01-06 09:40:42 +05:30
tapframe
1627928fb2 added back up next 2026-01-06 09:17:56 +05:30
tapframe
6ff5aa9e02 updated react native video patch file 2026-01-06 00:25:18 +05:30
Nayif
20601cd7ba
Merge pull request #361 from chrisk325/patch-8
Several optimizations for exoplayer for preventing crashes with heavy file sizes
2026-01-06 00:17:12 +05:30
tapframe
2d6b4afa2d fix: added timeout for tabletstreamscreen to prevent blackscreen until backdrop is fetched 2026-01-06 00:12:00 +05:30
tapframe
4ce14ec4cc optimized perf 2026-01-06 00:00:33 +05:30
tapframe
0f1d736716 slight onboarding screen Ui change 2026-01-05 23:42:51 +05:30
tapframe
edeb6ebe3c feat: added new poster like layout for continue watching card 2026-01-05 17:54:17 +05:30
tapframe
ab7f008bbb added toggle to control this week sections 2026-01-05 13:39:02 +05:30
paregi12
1e60af1ffb feat: prioritize IntroDB and implement ARM API for faster MAL ID resolution 2026-01-05 00:33:33 +05:30
tapframe
4dd1fca0a7 increased cache buffer ksplayer 2026-01-05 00:02:03 +05:30
tapframe
81b97da75e chore: trakt update 2026-01-04 20:50:37 +05:30
paregi12
6a7d6a1458 feat: implement robust IMDb to MAL resolution for AniSkip support 2026-01-04 19:23:53 +05:30
tapframe
2835ede747 Changed Trakt Continue watch Sync Behaviour. now fetches directly from api when authenticated and doesn't merges to local storage. 2026-01-04 18:43:44 +05:30
chrisk325
59f77ac831
optimisations for exo 2026-01-04 16:17:16 +05:30
tapframe
3e63efc178 added parallel season fetching 2026-01-04 15:57:23 +05:30
tapframe
4aa22cc1c3 chore: improved tmdb enrichment logic 2026-01-04 15:37:49 +05:30
chrisk325
4fdda9a184
several exoplayer optimizations to prevent crashes with huge file sizes 2026-01-04 15:25:27 +05:30
chrisk325
5bd9f41104
decreasing player refresh time from 4 times per second to 2 times , to prevent crashes with heavy files 2026-01-04 14:36:50 +05:30
chrisk325
486ea63a8a
fixing exo crash and some UI flaws 2026-01-04 14:33:16 +05:30
paregi12
0919a40c75 fix: correct AniSkip API query parameters 2026-01-04 11:58:54 +05:30
paregi12
3de2fb4809 feat: implement AniSkip support in video player 2026-01-04 11:45:05 +05:30
Nayif
3d5a9ebf42
Merge pull request #355 from chrisk325/patch-7 2026-01-04 11:13:38 +05:30
chrisk325
be3e111e63
small fix 2026-01-04 05:44:56 +05:30
chrisk325
8a0bed7238
ironed out a ui flaw + fix 2026-01-04 05:17:40 +05:30
chrisk325
d2556b6c36
rework 2026-01-04 04:46:13 +05:30
chrisk325
506ca4f95c
rework the trakt sync logic 2026-01-04 03:49:06 +05:30
Nayif
5b2c57d5c7
Merge pull request #351 from tapframe/revert-347-feature/improved10secSkipAndRewind
Revert "patch: incremental 10 sec skip/rewind on multiple taps"
2026-01-04 00:11:43 +05:30
Nayif
7c2b1ac73d
Merge pull request #352 from tapframe/revert-345-feature/seekingTimestamp
Revert "add: current timestamp update while sliding the seek bar on Android"
2026-01-04 00:11:31 +05:30
Nayif
a55669d16f
Revert "add: current timestamp update while sliding the seek bar on Android" 2026-01-04 00:11:13 +05:30
Nayif
656062bc25
Revert "patch: incremental 10 sec skip/rewind on multiple taps" 2026-01-04 00:10:45 +05:30
Nayif
b42401a909
Merge pull request #350 from chrisk325/patch-6
Complete fix for trakt up next thanx to @oceanm8 on discord for the idea
2026-01-03 22:05:06 +05:30
chrisk325
2c6c110265
fix 2026-01-03 20:03:03 +05:30
chrisk325
e7b3458f34
small fix 2026-01-03 19:54:46 +05:30
chrisk325
e0ad949141
small fix 2026-01-03 19:47:53 +05:30
chrisk325
28d27128d1
fix local data overriding trakt progress 2026-01-03 19:11:35 +05:30
chrisk325
ebbe715581
small fix 2026-01-03 18:52:53 +05:30
chrisk325
af138944b5
fix trakt sync to local for upnext 2026-01-03 18:45:43 +05:30
chrisk325
4603d1dc2a
redo how trakt marks stuff as watched to local 2026-01-03 18:33:06 +05:30
chrisk325
e323906083
fix up next 2026-01-03 18:11:27 +05:30
Nayif
6cb115ed74
Merge pull request #348 from chrisk325/patch-5 2026-01-03 10:36:11 +05:30
chrisk325
0149068126
fix continue watching metadata 2026-01-03 02:25:15 +05:30
Nayif
7894258a26
Merge pull request #347 from saifshaikh1805/feature/improved10secSkipAndRewind 2026-01-03 01:54:46 +05:30
Nayif
775242255a
Merge pull request #345 from saifshaikh1805/feature/seekingTimestamp 2026-01-03 01:54:37 +05:30
chrisk325
faa4f341e6
fix up next yet again (final fix probably) 2026-01-03 01:50:43 +05:30
chrisk325
a079649563
fix trakt syncing watched shows/movies back to trakt's recent history 2026-01-03 01:23:40 +05:30
Saif Shaikh
63359532a3 patch: incremental 10 sec skip/rewind on multiple taps 2026-01-02 11:44:50 -08:00
Saif Shaikh
5d42a828d2 add: current timestamp update while sliding the seek bar on Android 2026-01-01 23:32:32 -08:00
Nayif
2da03d4931
Merge pull request #334 from chrisk325/patch-3
fixes trakt up next
2026-01-01 16:12:53 +05:30
chrisk325
4235e327fc
fixes trakt up next 2026-01-01 15:45:49 +05:30
tapframe
0d3454cd24 updated source.json 2026-01-01 04:05:23 +05:30
tapframe
5850650713 1.3.3 2026-01-01 03:49:32 +05:30
tapframe
47f3cb4b71 Merge branch 'main' of https://github.com/tapframe/NuvioStreaming 2026-01-01 03:32:38 +05:30
tapframe
a6a0a8b1b1 removed sandboxed environment 2026-01-01 02:00:54 +05:30
169 changed files with 24108 additions and 9312 deletions

3
.gitignore vendored
View file

@ -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

54
App.tsx
View file

@ -13,6 +13,7 @@ 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';
@ -42,7 +43,6 @@ 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 AnnouncementOverlay from './src/components/AnnouncementOverlay';
import { CampaignManager } from './src/components/promotions/CampaignManager';
Sentry.init({
@ -90,7 +90,6 @@ const ThemedApp = () => {
const { currentTheme } = useTheme();
const [isAppReady, setIsAppReady] = useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState<boolean | null>(null);
const [showAnnouncement, setShowAnnouncement] = useState(false);
// Update popup functionality
const {
@ -105,16 +104,6 @@ const ThemedApp = () => {
// GitHub major/minor release overlay
const githubUpdate = useGithubMajorUpdate();
// Announcement data
const announcements = [
{
icon: 'zap',
title: 'Debrid Integration',
description: 'Unlock 4K high-quality streams with lightning-fast speeds. Connect your TorBox account to access cached premium content with zero buffering.',
tag: 'NEW',
},
];
// Check onboarding status and initialize services
useEffect(() => {
const initializeApp = async () => {
@ -134,15 +123,6 @@ const ThemedApp = () => {
await aiService.initialize();
console.log('AI service initialized');
// Check if announcement should be shown (version 1.0.0)
const announcementShown = await mmkvStorage.getItem('announcement_v1.0.0_shown');
if (!announcementShown && onboardingCompleted === 'true') {
// Show announcement only after app is ready
setTimeout(() => {
setShowAnnouncement(true);
}, 1000);
}
} catch (error) {
console.error('Error initializing app:', error);
// Default to showing onboarding if we can't check
@ -180,20 +160,6 @@ const ThemedApp = () => {
// Navigation reference
const navigationRef = React.useRef<any>(null);
// Handler for navigating to debrid integration
const handleNavigateToDebrid = () => {
if (navigationRef.current) {
navigationRef.current.navigate('DebridIntegration');
}
};
// Handler for announcement close
const handleAnnouncementClose = async () => {
setShowAnnouncement(false);
// Mark announcement as shown
await mmkvStorage.setItem('announcement_v1.0.0_shown', 'true');
};
// Don't render anything until we know the onboarding status
const shouldShowApp = isAppReady && hasCompletedOnboarding !== null;
const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding';
@ -204,7 +170,16 @@ const ThemedApp = () => {
<NavigationContainer
ref={navigationRef}
theme={customNavigationTheme}
linking={undefined}
linking={{
prefixes: ['nuvio://'],
config: {
screens: {
ScraperSettings: {
path: 'repo',
},
},
},
}}
>
<DownloadsProvider>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
@ -227,13 +202,6 @@ const ThemedApp = () => {
onDismiss={githubUpdate.onDismiss}
onLater={githubUpdate.onLater}
/>
<AnnouncementOverlay
visible={showAnnouncement}
announcements={announcements}
onClose={handleAnnouncementClose}
onActionPress={handleNavigateToDebrid}
actionButtonText="Connect Now"
/>
<CampaignManager />
</View>
</DownloadsProvider>

@ -1 +0,0 @@
Subproject commit e8c62a9a8fc48ff11c89b85b55fd979cc59bd0c4

View file

@ -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">
@ -23,9 +22,9 @@
<br />
<br />
<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>
@ -41,7 +40,9 @@
<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>
@ -83,7 +84,7 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
<!-- 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
@ -139,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:**

View file

@ -95,8 +95,8 @@ android {
applicationId 'com.nuvio.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 30
versionName "1.3.2"
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 = 30 // Current versionCode 30 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

View file

@ -8,6 +8,9 @@ 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,
@ -40,9 +43,27 @@ class MPVView @JvmOverloads constructor(
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) {
@ -80,6 +101,7 @@ class MPVView @JvmOverloads constructor(
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
Log.d(TAG, "Surface texture destroyed")
(context as? ReactContext)?.removeLifecycleEventListener(lifeCycleListener)
if (isMpvInitialized) {
MPVLib.removeObserver(this)
MPVLib.detachSurface()

View file

@ -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.3.2</string>
<string name="expo_runtime_version">1.3.5</string>
</resources>

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Nuvio",
"slug": "nuvio",
"version": "1.3.2",
"version": "1.3.5",
"orientation": "default",
"backgroundColor": "#020404",
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
@ -17,7 +17,7 @@
"ios": {
"supportsTablet": true,
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
"buildNumber": "30",
"buildNumber": "33",
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
@ -51,7 +51,7 @@
"android.permission.WRITE_SETTINGS"
],
"package": "com.nuvio.app",
"versionCode": 30,
"versionCode": 33,
"architectures": [
"arm64-v8a",
"armeabi-v7a",
@ -89,8 +89,7 @@
"receiverAppId": "CC1AD845",
"iosStartDiscoveryAfterFirstTapOnCastButton": true
}
],
"./plugins/mpv-bridge/withMpvBridge"
]
],
"updates": {
"enabled": true,
@ -98,6 +97,6 @@
"fallbackToCacheTimeout": 30000,
"url": "https://ota.nuvioapp.space/api/manifest"
},
"runtimeVersion": "1.3.2"
"runtimeVersion": "1.3.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

244
docs/DOCUMENTATION.md Normal file
View 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. |

View file

@ -187,12 +187,25 @@
}
.screenshots-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
column-count: 1;
column-gap: 2rem;
}
@media (min-width: 640px) {
.screenshots-grid {
column-count: 2;
}
}
@media (min-width: 1024px) {
.screenshots-grid {
column-count: 3;
}
}
.screenshot {
break-inside: avoid;
margin-bottom: 2rem;
position: relative;
border-radius: 16px;
overflow: hidden;
@ -913,6 +926,67 @@
</div>
</section>
<!-- Legal Section -->
<section id="legal" class="privacy-policy">
<div class="container">
<div class="privacy-content">
<button class="privacy-back-btn" onclick="hideAllDetailSections()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Back
</button>
<h1>Legal & Disclaimer</h1>
<p class="last-updated">Last updated: January 2026</p>
<div class="privacy-section">
<h2>Nature of the Application</h2>
<p>Nuvio is a media player and metadata management application. It acts solely as a client-side
interface for browsing publicly available metadata (movies, TV shows, etc.) and playing media
files
provided by the user or third-party extensions. Nuvio itself does not host, store, distribute,
or
index any media content.</p>
</div>
<div class="privacy-section">
<h2>Third-Party Extensions</h2>
<p>Nuvio uses an extensible architecture that allows users to install third-party plugins
(extensions).
These extensions are developed and maintained by independent developers not affiliated with
Nuvio.
We have no control over, and assume no responsibility for, the content, legality, or
functionality
of any third-party extension.</p>
</div>
<div class="privacy-section">
<h2>User Responsibility</h2>
<p>Users are solely responsible for the extensions they install and the content they access. By
using
this application, you agree to ensure that you have the legal right to access any content you
view
using Nuvio. The developers of Nuvio do not endorse or encourage copyright infringement.</p>
</div>
<div class="privacy-section">
<h2>Copyright & DMCA</h2>
<p>We respect the intellectual property rights of others. Since Nuvio does not host any content, we
cannot remove content from the internet. However, if you believe that the application interface
itself infringes on your rights, please contact us.</p>
</div>
<div class="privacy-section">
<h2>No Warranty</h2>
<p>This software is provided "as is", without warranty of any kind, express or implied. In no event
shall the authors or copyright holders be liable for any claim, damages, or other liability
arising
from the use of this software.</p>
</div>
</div>
</div>
</section>
<footer class="footer">
<div class="container">
<div class="footer-content">
@ -945,9 +1019,12 @@
<img src="https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png" alt="GitHub">
GitHub
</a>
<a href="#privacy-policy" class="footer-link" onclick="showPrivacyPolicy()">
<a href="#privacy-policy" class="footer-link" onclick="showSection('privacy-policy')">
Privacy
</a>
<a href="#legal" class="footer-link" onclick="showSection('legal')">
Legal
</a>
</div>
<p class="footer-copyright">© 2025 Nuvio. GNU GPLv3. Free and Open Source.</p>
@ -1035,26 +1112,42 @@
});
});
// Privacy Policy
function showPrivacyPolicy() {
document.querySelectorAll('body > *:not(#privacy-policy)').forEach(el => el.style.display = 'none');
document.getElementById('privacy-policy').style.display = 'block';
// Detail Sections (Privacy, Legal)
const detailSections = ['privacy-policy', 'legal'];
function showSection(sectionId) {
// Hide everything except the requested section
document.querySelectorAll('body > *:not(#' + sectionId + ')').forEach(el => el.style.display = 'none');
document.getElementById(sectionId).style.display = 'block';
window.scrollTo(0, 0);
}
function hidePrivacyPolicy() {
document.getElementById('privacy-policy').style.display = 'none';
document.querySelectorAll('body > *:not(#privacy-policy)').forEach(el => el.style.display = '');
function hideAllDetailSections() {
detailSections.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
// Show main content
document.querySelectorAll('body > *').forEach(el => {
if (!detailSections.includes(el.id)) el.style.display = '';
});
window.scrollTo(0, 0);
}
window.addEventListener('popstate', function () {
if (window.location.hash === '#privacy-policy') showPrivacyPolicy();
else hidePrivacyPolicy();
const hash = window.location.hash.substring(1); // remove #
if (detailSections.includes(hash)) {
showSection(hash);
} else {
hideAllDetailSections();
}
});
document.addEventListener('DOMContentLoaded', function () {
if (window.location.hash === '#privacy-policy') showPrivacyPolicy();
const hash = window.location.hash.substring(1);
if (detailSections.includes(hash)) {
showSection(hash);
}
});
</script>
</body>

View file

@ -1,69 +0,0 @@
//
// KSPlayerManager.m
// Nuvio
//
// Created by KSPlayer integration
//
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
#import <React/RCTViewManager.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(subtitleTextColor, NSString)
RCT_EXPORT_VIEW_PROPERTY(subtitleBackgroundColor, NSString)
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 : (NSNumber *)nodeTag resolve : (
RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getAirPlayState : (NSNumber *)nodeTag resolve : (
RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(showAirPlayPicker : (NSNumber *)nodeTag)
@end

View file

@ -1,71 +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) {
guard let nodeTag = nodeTag else {
reject("INVALID_ARGUMENT", "nodeTag must not be nil", nil)
return
}
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) {
guard let nodeTag = nodeTag else {
reject("INVALID_ARGUMENT", "nodeTag must not be nil", nil)
return
}
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?) {
guard let nodeTag = nodeTag else {
print("[KSPlayerModule] showAirPlayPicker called with nil nodeTag")
return
}
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")
}
}
}
}

View file

@ -1,981 +0,0 @@
//
// KSPlayerView.swift
// Nuvio
//
// Created by KSPlayer integration
//
import Foundation
import KSPlayer
import React
import AVKit
@objc(KSPlayerView)
class KSPlayerView: UIView {
private var playerView: IOSVideoPlayerView!
private var currentSource: NSDictionary?
private var isPaused = false
private var currentVolume: Float = 1.0
weak var viewManager: KSPlayerViewManager?
// Event blocks for Fabric
@objc var onLoad: RCTDirectEventBlock?
@objc var onProgress: RCTDirectEventBlock?
@objc var onBuffering: RCTDirectEventBlock?
@objc var onEnd: RCTDirectEventBlock?
@objc var onError: RCTDirectEventBlock?
@objc var onBufferingProgress: RCTDirectEventBlock?
// Property setters that React Native will call
@objc var source: NSDictionary? {
didSet {
if let source = source {
setSource(source)
}
}
}
@objc var paused: Bool = false {
didSet {
setPaused(paused)
}
}
@objc var volume: NSNumber = 1.0 {
didSet {
setVolume(volume.floatValue)
}
}
@objc var rate: NSNumber = 1.0 {
didSet {
setPlaybackRate(rate.floatValue)
}
}
@objc var audioTrack: NSNumber = -1 {
didSet {
setAudioTrack(audioTrack.intValue)
}
}
@objc var textTrack: NSNumber = -1 {
didSet {
setTextTrack(textTrack.intValue)
}
}
// AirPlay properties
@objc var allowsExternalPlayback: Bool = true {
didSet {
setAllowsExternalPlayback(allowsExternalPlayback)
}
}
@objc var usesExternalPlaybackWhileExternalScreenIsActive: Bool = true {
didSet {
setUsesExternalPlaybackWhileExternalScreenIsActive(usesExternalPlaybackWhileExternalScreenIsActive)
}
}
// Subtitle customization props removed - using native KSPlayer styling
@objc var subtitleBottomOffset: NSNumber = 60
@objc var subtitleFontSize: NSNumber = 16
@objc var subtitleTextColor: NSString = "#FFFFFF"
@objc var subtitleBackgroundColor: NSString = "rgba(0,0,0,0.7)"
@objc var resizeMode: NSString = "contain" {
didSet {
print("KSPlayerView: [PROP SETTER] resizeMode setter called with value: \(resizeMode)")
applyVideoGravity()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setupPlayerView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupPlayerView()
}
private func setupPlayerView() {
playerView = IOSVideoPlayerView()
playerView.translatesAutoresizingMaskIntoConstraints = false
// Hide native controls - we use custom React Native controls
playerView.isUserInteractionEnabled = false
// Hide KSPlayer's built-in overlay/controls
playerView.controllerView.isHidden = true
playerView.contentOverlayView.isHidden = true
playerView.controllerView.alpha = 0
playerView.contentOverlayView.alpha = 0
playerView.controllerView.gestureRecognizers?.forEach { $0.isEnabled = false }
addSubview(playerView)
NSLayoutConstraint.activate([
playerView.topAnchor.constraint(equalTo: topAnchor),
playerView.leadingAnchor.constraint(equalTo: leadingAnchor),
playerView.trailingAnchor.constraint(equalTo: trailingAnchor),
playerView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
// Let KSPlayer handle subtitles natively - no custom positioning
// Just set up player delegates and callbacks
setupPlayerCallbacks()
}
private func applyVideoGravity() {
print("KSPlayerView: [VIDEO GRAVITY] Applying resizeMode: \(resizeMode)")
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let contentMode: UIViewContentMode
switch self.resizeMode.lowercased {
case "cover":
contentMode = .scaleAspectFill
case "stretch":
contentMode = .scaleToFill
case "contain":
contentMode = .scaleAspectFit
default:
contentMode = .scaleAspectFit
}
// Set contentMode on the player itself, not the view
self.playerView.playerLayer?.player.contentMode = contentMode
print("KSPlayerView: [VIDEO GRAVITY] Set player contentMode to: \(contentMode)")
}
}
private func setupPlayerCallbacks() {
// Configure KSOptions
KSOptions.isAutoPlay = false
KSOptions.asynchronousDecompression = true
KSOptions.hardwareDecode = true
// Set default subtitle font size - use smaller size for mobile
SubtitleModel.textFontSize = 16.0
SubtitleModel.textBold = false
print("KSPlayerView: [PERF] Global settings: asyncDecomp=\(KSOptions.asynchronousDecompression), hwDecode=\(KSOptions.hardwareDecode)")
}
func setSource(_ source: NSDictionary) {
currentSource = source
guard let uri = source["uri"] as? String else {
print("KSPlayerView: No URI provided")
sendEvent("onError", ["error": "No URI provided in source"])
return
}
// Validate URL before proceeding
guard let url = URL(string: uri), url.scheme != nil else {
print("KSPlayerView: Invalid URL format: \(uri)")
sendEvent("onError", ["error": "Invalid URL format: \(uri)"])
return
}
var headers: [String: String] = [:]
if let headersDict = source["headers"] as? [String: String] {
headers = headersDict
}
// Choose player pipeline based on format
let isMKV = uri.lowercased().contains(".mkv")
if isMKV {
// Prefer MEPlayer (FFmpeg) for MKV
KSOptions.firstPlayerType = KSMEPlayer.self
KSOptions.secondPlayerType = nil
} else {
KSOptions.firstPlayerType = KSAVPlayer.self
KSOptions.secondPlayerType = KSMEPlayer.self
}
// Create KSPlayerResource with validated URL
let resource = KSPlayerResource(url: url, options: createOptions(with: headers), name: "Video")
print("KSPlayerView: Setting source: \(uri)")
print("KSPlayerView: URL scheme: \(url.scheme ?? "unknown"), host: \(url.host ?? "unknown")")
playerView.set(resource: resource)
// Set up delegate after setting the resource
if let playerLayer = playerView.playerLayer {
playerLayer.delegate = self
print("KSPlayerView: Delegate set successfully on playerLayer")
// Apply video gravity after player is set up
applyVideoGravity()
} else {
print("KSPlayerView: ERROR - playerLayer is nil, cannot set delegate")
}
// Apply current state
if isPaused {
playerView.pause()
} else {
playerView.play()
}
setVolume(currentVolume)
// Ensure AirPlay is properly configured after setting source
DispatchQueue.main.async {
self.setAllowsExternalPlayback(self.allowsExternalPlayback)
self.setUsesExternalPlaybackWhileExternalScreenIsActive(self.usesExternalPlaybackWhileExternalScreenIsActive)
}
}
private func createOptions(with headers: [String: String]) -> KSOptions {
// Use custom HighPerformanceOptions subclass for frame buffer optimization
let options = HighPerformanceOptions()
// Disable native player remote control center integration; use RN controls
options.registerRemoteControll = false
// PERFORMANCE OPTIMIZATION: Buffer durations for smooth high bitrate playback
// preferredForwardBufferDuration = 3.0s: Slightly increased to reduce rebuffering during playback
options.preferredForwardBufferDuration = 1.0
// maxBufferDuration = 120.0s: Increased to allow the player to cache more content ahead of time (2 minutes)
options.maxBufferDuration = 120.0
// Enable "second open" to relax startup/seek buffering thresholds (already enabled)
options.isSecondOpen = true
// PERFORMANCE OPTIMIZATION: Fast stream analysis for high bitrate content
// Reduces startup latency significantly for large high-bitrate streams
options.probesize = 50_000_000 // 50MB for faster format detection
options.maxAnalyzeDuration = 5_000_000 // 5 seconds in microseconds for faster stream structure analysis
// PERFORMANCE OPTIMIZATION: Decoder thread optimization
// Use all available CPU cores for parallel decoding
options.decoderOptions["threads"] = "0" // Use all CPU cores instead of "auto"
// refcounted_frames already set to "1" in KSOptions init for memory efficiency
// PERFORMANCE OPTIMIZATION: Hardware decode explicitly enabled
// Ensure VideoToolbox hardware acceleration is always preferred for non-simulator
// Hardware decode and async decompression
options.hardwareDecode = true
options.asynchronousDecompression = true
// HDR handling: Let KSPlayer automatically detect content's native dynamic range
// Setting destinationDynamicRange to nil allows KSPlayer to use the content's actual HDR/SDR mode
// This prevents forcing HDR tone mapping on SDR content (which causes oversaturation)
// KSPlayer will automatically detect HDR10/Dolby Vision/HLG from the video format description
options.destinationDynamicRange = nil
// Configure audio for proper dialogue mixing using FFmpeg's pan filter
// This approach uses standard audio engineering practices for multi-channel downmixing
// Use conservative center channel mixing that preserves spatial audio
// c0 (Left) = 70% original left + 30% center (dialogue) + 20% rear left
// c1 (Right) = 70% original right + 30% center (dialogue) + 20% rear right
// This creates natural dialogue presence without the "playing on both ears" effect
options.audioFilters.append("pan=stereo|c0=0.7*c0+0.3*c2+0.2*c4|c1=0.7*c1+0.3*c2+0.2*c5")
// Alternative: Use FFmpeg's surround filter for more sophisticated downmixing
// This provides better spatial audio processing and natural dialogue mixing
// options.audioFilters.append("surround=ang=45")
if !headers.isEmpty {
// Clean and validate headers before adding
var cleanHeaders: [String: String] = [:]
for (key, value) in headers {
// Remove any null or empty values
if !value.isEmpty && value != "null" {
cleanHeaders[key] = value
}
}
if !cleanHeaders.isEmpty {
options.appendHeader(cleanHeaders)
print("KSPlayerView: Added headers: \(cleanHeaders.keys.joined(separator: ", "))")
if let referer = cleanHeaders["Referer"] ?? cleanHeaders["referer"] {
options.referer = referer
print("KSPlayerView: Set referer: \(referer)")
}
}
}
print("KSPlayerView: [PERF] High-performance options configured: asyncDecomp=\(options.asynchronousDecompression), hwDecode=\(options.hardwareDecode), buffer=\(options.preferredForwardBufferDuration)s/\(options.maxBufferDuration)s, HDR=\(options.destinationDynamicRange?.description ?? "auto")")
return options
}
func setPaused(_ paused: Bool) {
isPaused = paused
if paused {
playerView.pause()
} else {
playerView.play()
}
}
func setVolume(_ volume: Float) {
currentVolume = volume
playerView.playerLayer?.player.playbackVolume = volume
}
func setPlaybackRate(_ rate: Float) {
playerView.playerLayer?.player.playbackRate = rate
print("KSPlayerView: Set playback rate to \(rate)x")
}
func seek(to time: TimeInterval) {
guard let playerLayer = playerView.playerLayer,
playerLayer.player.isReadyToPlay,
playerLayer.player.seekable else {
print("KSPlayerView: Cannot seek - player not ready or not seekable")
return
}
// Capture the current paused state before seeking
let wasPaused = isPaused
print("KSPlayerView: Seeking to \(time), paused state before seek: \(wasPaused)")
playerView.seek(time: time) { [weak self] success in
guard let self = self else { return }
if success {
print("KSPlayerView: Seek successful to \(time)")
// Restore the paused state after seeking
// KSPlayer's seek may resume playback, so we need to re-apply the paused state
if wasPaused {
DispatchQueue.main.async {
self.playerView.pause()
print("KSPlayerView: Restored paused state after seek")
}
}
} else {
print("KSPlayerView: Seek failed to \(time)")
}
}
}
func setAudioTrack(_ trackId: Int) {
if let player = playerView.playerLayer?.player {
let audioTracks = player.tracks(mediaType: .audio)
print("KSPlayerView: Available audio tracks count: \(audioTracks.count)")
print("KSPlayerView: Requested track ID: \(trackId)")
// Debug: Print all track information
for (index, track) in audioTracks.enumerated() {
print("KSPlayerView: Track \(index) - ID: \(track.trackID), Name: '\(track.name)', Language: '\(track.language ?? "nil")', isEnabled: \(track.isEnabled)")
}
// First try to find track by trackID (proper way)
var selectedTrack: MediaPlayerTrack? = nil
var trackIndex: Int = -1
// Try to find by exact trackID match
if let track = audioTracks.first(where: { Int($0.trackID) == trackId }) {
selectedTrack = track
trackIndex = audioTracks.firstIndex(where: { $0.trackID == track.trackID }) ?? -1
print("KSPlayerView: Found track by trackID \(trackId) at index \(trackIndex)")
}
// Fallback: treat trackId as array index
else if trackId >= 0 && trackId < audioTracks.count {
selectedTrack = audioTracks[trackId]
trackIndex = trackId
print("KSPlayerView: Found track by array index \(trackId) (fallback)")
}
if let track = selectedTrack {
print("KSPlayerView: Selecting track \(trackId) (index: \(trackIndex)): '\(track.name)' (ID: \(track.trackID))")
// Use KSPlayer's select method which properly handles track selection
player.select(track: track)
print("KSPlayerView: Successfully selected audio track \(trackId)")
// Verify the selection worked
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let tracksAfter = player.tracks(mediaType: .audio)
for (index, track) in tracksAfter.enumerated() {
print("KSPlayerView: After selection - Track \(index) (ID: \(track.trackID)) isEnabled: \(track.isEnabled)")
}
}
// Configure audio downmixing for multi-channel tracks
configureAudioDownmixing(for: track)
} else if trackId == -1 {
// Disable all audio tracks (mute)
for track in audioTracks { track.isEnabled = false }
print("KSPlayerView: Disabled all audio tracks")
} else {
print("KSPlayerView: Track \(trackId) not found. Available track IDs: \(audioTracks.map { Int($0.trackID) }), array indices: 0..\(audioTracks.count - 1)")
}
} else {
print("KSPlayerView: No player available for audio track selection")
}
}
private func configureAudioDownmixing(for track: MediaPlayerTrack) {
// Check if this is a multi-channel audio track that needs downmixing
// This is a simplified check - in practice, you might want to check the actual channel layout
let trackName = track.name.lowercased()
let isMultiChannel = trackName.contains("5.1") || trackName.contains("7.1") ||
trackName.contains("truehd") || trackName.contains("dts") ||
trackName.contains("dolby") || trackName.contains("atmos")
if isMultiChannel {
print("KSPlayerView: Detected multi-channel audio track '\(track.name)', ensuring proper dialogue mixing")
print("KSPlayerView: Using FFmpeg pan filter for natural stereo downmixing")
} else {
print("KSPlayerView: Stereo or mono audio track '\(track.name)', no additional downmixing needed")
}
}
func setTextTrack(_ trackId: Int) {
NSLog("KSPlayerView: [SET TEXT TRACK] Starting setTextTrack with trackId: %d", trackId)
// Small delay to ensure player is ready
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else {
NSLog("KSPlayerView: [SET TEXT TRACK] self is nil, aborting")
return
}
NSLog("KSPlayerView: [SET TEXT TRACK] Executing track selection")
if let player = self.playerView.playerLayer?.player {
let textTracks = player.tracks(mediaType: .subtitle)
NSLog("KSPlayerView: Available text tracks count: %d", textTracks.count)
NSLog("KSPlayerView: Requested text track ID: %d", trackId)
// First try to find track by trackID (proper way)
var selectedTrack: MediaPlayerTrack? = nil
var trackIndex: Int = -1
// Try to find by exact trackID match
if let track = textTracks.first(where: { Int($0.trackID) == trackId }) {
selectedTrack = track
trackIndex = textTracks.firstIndex(where: { $0.trackID == track.trackID }) ?? -1
NSLog("KSPlayerView: Found text track by trackID %d at index %d", trackId, trackIndex)
}
// Fallback: treat trackId as array index
else if trackId >= 0 && trackId < textTracks.count {
selectedTrack = textTracks[trackId]
trackIndex = trackId
NSLog("KSPlayerView: Found text track by array index %d (fallback)", trackId)
}
if let track = selectedTrack {
NSLog("KSPlayerView: Selecting text track %d (index: %d): '%@' (ID: %d)", trackId, trackIndex, track.name, track.trackID)
// Disable all tracks first
for t in textTracks {
t.isEnabled = false
}
// Enable the selected track
track.isEnabled = true
// Use KSPlayer's select method to update player state
player.select(track: track)
// CRITICAL: Cast MediaPlayerTrack to SubtitleInfo and set on srtControl
// FFmpegAssetTrack conforms to SubtitleInfo via extension
if let subtitleInfo = track as? SubtitleInfo {
self.playerView.srtControl.selectedSubtitleInfo = subtitleInfo
NSLog("KSPlayerView: Set srtControl.selectedSubtitleInfo to track '%@'", track.name)
} else {
NSLog("KSPlayerView: Warning - track could not be cast to SubtitleInfo")
}
// Ensure subtitle views are visible
self.playerView.subtitleLabel.isHidden = false
self.playerView.subtitleBackView.isHidden = false
NSLog("KSPlayerView: Successfully selected and enabled text track %d", trackId)
} else if trackId == -1 {
// Disable all subtitles
for track in textTracks {
track.isEnabled = false
}
self.playerView.srtControl.selectedSubtitleInfo = nil
self.playerView.subtitleLabel.isHidden = true
self.playerView.subtitleBackView.isHidden = true
NSLog("KSPlayerView: Disabled all text tracks")
} else {
NSLog("KSPlayerView: Text track %d not found. Available count: %d", trackId, textTracks.count)
}
} else {
NSLog("KSPlayerView: No player available for text track selection")
}
}
}
// Get available tracks for React Native
func getAvailableTracks() -> [String: Any] {
guard let player = playerView.playerLayer?.player else {
return ["audioTracks": [], "textTracks": []]
}
let audioTracks = player.tracks(mediaType: .audio).enumerated().map { index, track in
return [
"id": Int(track.trackID), // Use actual track ID, not array index
"index": index, // Keep index for backward compatibility
"name": track.name,
"language": track.language ?? "Unknown",
"languageCode": track.languageCode ?? "",
"isEnabled": track.isEnabled,
"bitRate": track.bitRate,
"bitDepth": track.bitDepth
]
}
let textTracks = player.tracks(mediaType: .subtitle).enumerated().map { index, track in
// Create a better display name for subtitles
var displayName = track.name
if displayName.isEmpty || displayName == "Unknown" {
if let language = track.language, !language.isEmpty && language != "Unknown" {
displayName = language
} else if let languageCode = track.languageCode, !languageCode.isEmpty {
displayName = languageCode.uppercased()
} else {
displayName = "Subtitle \(index + 1)"
}
}
// Add language info if not already in the name
if let language = track.language, !language.isEmpty && language != "Unknown" && !displayName.lowercased().contains(language.lowercased()) {
displayName += " (\(language))"
}
return [
"id": Int(track.trackID), // Use actual track ID, not array index
"index": index, // Keep index for backward compatibility
"name": displayName,
"language": track.language ?? "Unknown",
"languageCode": track.languageCode ?? "",
"isEnabled": track.isEnabled,
"isImageSubtitle": track.isImageSubtitle
]
}
return [
"audioTracks": audioTracks,
"textTracks": textTracks
]
}
// AirPlay methods
func setAllowsExternalPlayback(_ allows: Bool) {
print("[KSPlayerView] Setting allowsExternalPlayback: \(allows)")
playerView.playerLayer?.player.allowsExternalPlayback = allows
}
func setUsesExternalPlaybackWhileExternalScreenIsActive(_ uses: Bool) {
print("[KSPlayerView] Setting usesExternalPlaybackWhileExternalScreenIsActive: \(uses)")
playerView.playerLayer?.player.usesExternalPlaybackWhileExternalScreenIsActive = uses
}
func showAirPlayPicker() {
print("[KSPlayerView] showAirPlayPicker called")
DispatchQueue.main.async {
// Create a temporary route picker view for triggering AirPlay
let routePickerView = AVRoutePickerView()
routePickerView.tintColor = .white
routePickerView.alpha = 0.01 // Nearly invisible but still interactive
// Find the current view controller
guard let viewController = self.findHostViewController() else {
print("[KSPlayerView] Could not find view controller for AirPlay picker")
return
}
// Add to the view controller's view temporarily
viewController.view.addSubview(routePickerView)
// Position it off-screen but still in the view hierarchy
routePickerView.frame = CGRect(x: -100, y: -100, width: 44, height: 44)
// Force layout
viewController.view.setNeedsLayout()
viewController.view.layoutIfNeeded()
// Wait a bit for the view to be ready, then trigger
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// Find and trigger the AirPlay button
self.triggerAirPlayButton(routePickerView)
// Clean up after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
routePickerView.removeFromSuperview()
print("[KSPlayerView] Cleaned up temporary AirPlay picker")
}
}
}
}
private func triggerAirPlayButton(_ routePickerView: AVRoutePickerView) {
// Recursively find the button in the route picker view
func findButton(in view: UIView) -> UIButton? {
if let button = view as? UIButton {
return button
}
for subview in view.subviews {
if let button = findButton(in: subview) {
return button
}
}
return nil
}
if let button = findButton(in: routePickerView) {
print("[KSPlayerView] Found AirPlay button, triggering tap")
button.sendActions(for: .touchUpInside)
} else {
print("[KSPlayerView] Could not find AirPlay button in route picker")
}
}
func getAirPlayState() -> [String: Any] {
guard let player = playerView.playerLayer?.player else {
return [
"allowsExternalPlayback": false,
"usesExternalPlaybackWhileExternalScreenIsActive": false,
"isExternalPlaybackActive": false
]
}
return [
"allowsExternalPlayback": player.allowsExternalPlayback,
"usesExternalPlaybackWhileExternalScreenIsActive": player.usesExternalPlaybackWhileExternalScreenIsActive,
"isExternalPlaybackActive": player.isExternalPlaybackActive
]
}
// Get current player state for React Native
func getCurrentState() -> [String: Any] {
guard let player = playerView.playerLayer?.player else {
return [:]
}
return [
"currentTime": player.currentPlaybackTime,
"duration": player.duration,
"buffered": player.playableTime,
"isPlaying": !isPaused,
"volume": currentVolume
]
}
// MARK: - Performance Optimization Helpers
}
// MARK: - High Performance KSOptions Subclass
/// Custom KSOptions subclass that overrides frame buffer capacity for high bitrate content
/// More buffered frames absorb decode spikes and network hiccups without quality loss
private class HighPerformanceOptions: KSOptions {
/// Override to increase frame buffer capacity for high bitrate content
/// - Parameters:
/// - fps: Video frame rate
/// - naturalSize: Video resolution
/// - isLive: Whether this is a live stream
/// - Returns: Number of frames to buffer
override func videoFrameMaxCount(fps: Float, naturalSize: CGSize, isLive: Bool) -> UInt8 {
if isLive {
// Increased from 4 to 8 for better live stream stability
return 8
}
// For high bitrate VOD: increase buffer based on resolution
if naturalSize.width >= 3840 || naturalSize.height >= 2160 {
// 4K needs more buffer frames to handle decode spikes
return 32
} else if naturalSize.width >= 1920 || naturalSize.height >= 1080 {
// 1080p benefits from more frames
return 24
}
// Default for lower resolutions
return 16
}
}
extension KSPlayerView: KSPlayerLayerDelegate {
func player(layer: KSPlayerLayer, state: KSPlayerState) {
switch state {
case .readyToPlay:
// Ensure AirPlay is properly configured when player is ready
layer.player.allowsExternalPlayback = allowsExternalPlayback
layer.player.usesExternalPlaybackWhileExternalScreenIsActive = usesExternalPlaybackWhileExternalScreenIsActive
// Debug: Check subtitle data source connection
let hasSubtitleDataSource = layer.player.subtitleDataSouce != nil
print("KSPlayerView: [READY TO PLAY] subtitle data source available: \(hasSubtitleDataSource)")
// Keep subtitle views hidden until actual content is displayed
// They will be shown in the subtitle rendering callback when there's text to display
playerView.subtitleLabel.isHidden = true
playerView.subtitleBackView.isHidden = true
print("KSPlayerView: [READY TO PLAY] Subtitle views kept hidden until content available")
// Manually connect subtitle data source to srtControl (this is the missing piece!)
if let subtitleDataSouce = layer.player.subtitleDataSouce {
print("KSPlayerView: [READY TO PLAY] Connecting subtitle data source to srtControl")
print("KSPlayerView: [READY TO PLAY] subtitleDataSouce type: \(type(of: subtitleDataSouce))")
// Check if subtitle data source has any subtitle infos
print("KSPlayerView: [READY TO PLAY] subtitleDataSouce has \(subtitleDataSouce.infos.count) subtitle infos")
for (index, info) in subtitleDataSouce.infos.enumerated() {
print("KSPlayerView: [READY TO PLAY] subtitleDataSouce info[\(index)]: ID=\(info.subtitleID), Name='\(info.name)', Enabled=\(info.isEnabled)")
}
// Wait 1 second like the original KSPlayer code does
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) { [weak self] in
guard let self = self else { return }
print("KSPlayerView: [READY TO PLAY] About to add subtitle data source to srtControl")
self.playerView.srtControl.addSubtitle(dataSouce: subtitleDataSouce)
print("KSPlayerView: [READY TO PLAY] Subtitle data source connected to srtControl")
print("KSPlayerView: [READY TO PLAY] srtControl.subtitleInfos.count: \(self.playerView.srtControl.subtitleInfos.count)")
// Log all subtitle infos
for (index, info) in self.playerView.srtControl.subtitleInfos.enumerated() {
print("KSPlayerView: [READY TO PLAY] SubtitleInfo[\(index)]: name=\(info.name), isEnabled=\(info.isEnabled), subtitleID=\(info.subtitleID)")
}
// Try to manually trigger subtitle parsing for the current time
let currentTime = self.playerView.playerLayer?.player.currentPlaybackTime ?? 0
print("KSPlayerView: [READY TO PLAY] Current playback time: \(currentTime)")
// Force subtitle search for current time
let hasSubtitle = self.playerView.srtControl.subtitle(currentTime: currentTime)
print("KSPlayerView: [READY TO PLAY] Manual subtitle search result: \(hasSubtitle)")
print("KSPlayerView: [READY TO PLAY] Parts count after manual search: \(self.playerView.srtControl.parts.count)")
if let firstPart = self.playerView.srtControl.parts.first {
print("KSPlayerView: [READY TO PLAY] Found subtitle part: start=\(firstPart.start), end=\(firstPart.end), text='\(firstPart.text?.string ?? "nil")'")
}
// Only auto-select first enabled subtitle if textTrack prop is NOT set to -1 (disabled)
// If React Native explicitly set textTrack=-1, user wants subtitles off
if self.textTrack.intValue != -1 {
// Auto-select first enabled subtitle if none selected
if self.playerView.srtControl.selectedSubtitleInfo == nil {
self.playerView.srtControl.selectedSubtitleInfo = self.playerView.srtControl.subtitleInfos.first { $0.isEnabled }
if let selected = self.playerView.srtControl.selectedSubtitleInfo {
print("KSPlayerView: [READY TO PLAY] Auto-selected subtitle: \(selected.name)")
} else {
print("KSPlayerView: [READY TO PLAY] No enabled subtitle found for auto-selection")
}
} else {
print("KSPlayerView: [READY TO PLAY] Subtitle already selected: \(self.playerView.srtControl.selectedSubtitleInfo?.name ?? "unknown")")
}
} else {
print("KSPlayerView: [READY TO PLAY] textTrack=-1 (disabled), skipping auto-selection")
// Ensure subtitles are disabled
self.playerView.srtControl.selectedSubtitleInfo = nil
self.playerView.subtitleLabel.isHidden = true
self.playerView.subtitleBackView.isHidden = true
}
}
} else {
print("KSPlayerView: [READY TO PLAY] ERROR: No subtitle data source available")
}
// Determine player backend type from actual player instance
let playerBackend: String
if let _ = layer.player as? KSMEPlayer {
playerBackend = "KSMEPlayer"
} else {
playerBackend = "KSAVPlayer"
}
// Send onLoad event to React Native with track information
let p = layer.player
let tracks = getAvailableTracks()
sendEvent("onLoad", [
"duration": p.duration,
"currentTime": p.currentPlaybackTime,
"naturalSize": [
"width": p.naturalSize.width,
"height": p.naturalSize.height
],
"audioTracks": tracks["audioTracks"] ?? [],
"textTracks": tracks["textTracks"] ?? [],
"playerBackend": playerBackend
])
case .buffering:
sendEvent("onBuffering", ["isBuffering": true])
case .bufferFinished:
sendEvent("onBuffering", ["isBuffering": false])
case .playedToTheEnd:
sendEvent("onEnd", [:])
case .error:
// Error will be handled by the finish delegate method
break
default:
break
}
}
func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {
// Debug: Confirm delegate method is being called
if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 {
print("KSPlayerView: [DELEGATE CALLED] time=\(currentTime), total=\(totalTime)")
}
// Manually implement subtitle rendering logic from VideoPlayerView
// This is the critical missing piece that was preventing subtitle rendering
// Debug: Check srtControl state
let subtitleInfoCount = playerView.srtControl.subtitleInfos.count
let selectedSubtitle = playerView.srtControl.selectedSubtitleInfo
// Always log subtitle state every 10 seconds to see when it gets populated
if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 {
print("KSPlayerView: [SUBTITLE DEBUG] time=\(currentTime.truncatingRemainder(dividingBy: 10.0)), subtitleInfos=\(subtitleInfoCount), selected=\(selectedSubtitle?.name ?? "none")")
// Also check if player has subtitle data source
let player = layer.player
let hasSubtitleDataSource = player.subtitleDataSouce != nil
print("KSPlayerView: [SUBTITLE DEBUG] player has subtitle data source: \(hasSubtitleDataSource)")
// Log subtitle view states
print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.isHidden: \(playerView.subtitleLabel.isHidden)")
print("KSPlayerView: [SUBTITLE DEBUG] subtitleBackView.isHidden: \(playerView.subtitleBackView.isHidden)")
print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.text: '\(playerView.subtitleLabel.text ?? "nil")'")
print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.attributedText: \(playerView.subtitleLabel.attributedText != nil ? "exists" : "nil")")
print("KSPlayerView: [SUBTITLE DEBUG] subtitleBackView.image: \(playerView.subtitleBackView.image != nil ? "exists" : "nil")")
// Log all subtitle infos
for (index, info) in playerView.srtControl.subtitleInfos.enumerated() {
print("KSPlayerView: [SUBTITLE DEBUG] SubtitleInfo[\(index)]: name=\(info.name), isEnabled=\(info.isEnabled)")
}
}
let hasSubtitleParts = playerView.srtControl.subtitle(currentTime: currentTime)
// Debug: Check subtitle timing every 10 seconds
if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 && subtitleInfoCount > 0 {
print("KSPlayerView: [SUBTITLE TIMING] time=\(currentTime), hasParts=\(hasSubtitleParts), partsCount=\(playerView.srtControl.parts.count)")
if let firstPart = playerView.srtControl.parts.first {
print("KSPlayerView: [SUBTITLE TIMING] firstPart start=\(firstPart.start), end=\(firstPart.end)")
print("KSPlayerView: [SUBTITLE TIMING] firstPart text='\(firstPart.text?.string ?? "nil")'")
print("KSPlayerView: [SUBTITLE TIMING] firstPart hasImage=\(firstPart.image != nil)")
} else {
print("KSPlayerView: [SUBTITLE TIMING] No parts available")
}
}
if hasSubtitleParts {
if let part = playerView.srtControl.parts.first {
print("KSPlayerView: [SUBTITLE RENDER] time=\(currentTime), text='\(part.text?.string ?? "nil")', hasImage=\(part.image != nil)")
playerView.subtitleBackView.image = part.image
// Normalize font size for all subtitles to ensure consistent display
if let originalText = part.text {
let mutableText = NSMutableAttributedString(attributedString: originalText)
// Apply consistent font across the entire text
let font = UIFont.systemFont(ofSize: 20.0)
mutableText.addAttributes([.font: font], range: NSRange(location: 0, length: mutableText.length))
playerView.subtitleLabel.attributedText = mutableText
} else {
playerView.subtitleLabel.attributedText = nil
}
playerView.subtitleBackView.isHidden = false
playerView.subtitleLabel.isHidden = false
print("KSPlayerView: [SUBTITLE RENDER] Set subtitle text and made views visible")
print("KSPlayerView: [SUBTITLE RENDER] subtitleLabel.isHidden after: \(playerView.subtitleLabel.isHidden)")
print("KSPlayerView: [SUBTITLE RENDER] subtitleBackView.isHidden after: \(playerView.subtitleBackView.isHidden)")
} else {
print("KSPlayerView: [SUBTITLE RENDER] hasParts=true but no parts available - hiding views")
playerView.subtitleBackView.image = nil
playerView.subtitleLabel.attributedText = nil
playerView.subtitleBackView.isHidden = true
playerView.subtitleLabel.isHidden = true
}
} else {
// Only log this occasionally to avoid spam
if currentTime.truncatingRemainder(dividingBy: 30.0) < 0.1 {
print("KSPlayerView: [SUBTITLE RENDER] time=\(currentTime), hasParts=false - no subtitle at this time")
}
}
let p = layer.player
// Ensure we have valid duration before sending progress updates
if totalTime > 0 {
sendEvent("onProgress", [
"currentTime": currentTime,
"duration": totalTime,
"bufferTime": p.playableTime,
"airPlayState": getAirPlayState()
])
}
}
func player(layer: KSPlayerLayer, finish error: Error?) {
if let error = error {
let errorMessage = error.localizedDescription
print("KSPlayerView: Player finished with error: \(errorMessage)")
// Provide more specific error messages for common issues
var detailedError = errorMessage
if errorMessage.contains("avformat can't open input") {
detailedError = "Unable to open video stream. This could be due to:\n• Invalid or malformed URL\n• Network connectivity issues\n• Server blocking the request\n• Unsupported video format\n• Missing required headers"
} else if errorMessage.contains("timeout") {
detailedError = "Stream connection timed out. The server may be slow or unreachable."
} else if errorMessage.contains("404") || errorMessage.contains("Not Found") {
detailedError = "Video stream not found. The URL may be expired or incorrect."
} else if errorMessage.contains("403") || errorMessage.contains("Forbidden") {
detailedError = "Access denied. The server may be blocking requests or require authentication."
}
sendEvent("onError", ["error": detailedError])
}
}
func player(layer: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) {
// Handle buffering progress if needed
sendEvent("onBufferingProgress", [
"bufferedCount": bufferedCount,
"consumeTime": consumeTime
])
}
}
extension KSPlayerView {
private func sendEvent(_ eventName: String, _ body: [String: Any]) {
DispatchQueue.main.async {
switch eventName {
case "onLoad":
self.onLoad?(body)
case "onProgress":
self.onProgress?(body)
case "onBuffering":
self.onBuffering?(body)
case "onEnd":
self.onEnd?([:])
case "onError":
self.onError?(body)
case "onBufferingProgress":
self.onBufferingProgress?(body)
default:
break
}
}
}
// Renamed to avoid clashing with React's UIView category method
private func findHostViewController() -> UIViewController? {
var responder: UIResponder? = self
while let nextResponder = responder?.next {
if let viewController = nextResponder as? UIViewController {
return viewController
}
responder = nextResponder
}
return nil
}
}

View file

@ -1,152 +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) {
NSLog("[KSPlayerViewManager] setTextTrack called - node: %@, trackId: %@", node, trackId)
DispatchQueue.main.async {
NSLog("[KSPlayerViewManager] setTextTrack on main queue - looking for view with tag: %@", node)
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
NSLog("[KSPlayerViewManager] Found view, calling setTextTrack(%d)", Int(truncating: trackId))
view.setTextTrack(Int(truncating: trackId))
} else {
NSLog("[KSPlayerViewManager] ERROR - Could not find KSPlayerView for tag: %@", node)
}
}
}
@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)")
}
}
}
}

View file

@ -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 */; };
72D4090694139E0DAD9B066E /* libPods-Nuvio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 406CD0CEF46F8EAEEAD92BF7 /* 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>"; };
15864A7148A4384BAA9F0B37 /* 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>"; };
406CD0CEF46F8EAEEAD92BF7 /* libPods-Nuvio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Nuvio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
819F6DCD44DFE0C72440FDCF /* 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 = 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 = (
72D4090694139E0DAD9B066E /* libPods-Nuvio.a in Frameworks */,
D66ACCC72CB69F1FF14A2585 /* libPods-Nuvio.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -76,7 +76,7 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
406CD0CEF46F8EAEEAD92BF7 /* libPods-Nuvio.a */,
436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */,
);
name = Frameworks;
sourceTree = "<group>";
@ -131,8 +131,8 @@
D90A3959C97EE9926C513293 /* Pods */ = {
isa = PBXGroup;
children = (
819F6DCD44DFE0C72440FDCF /* Pods-Nuvio.debug.xcconfig */,
15864A7148A4384BAA9F0B37 /* 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 = (
E060D0359630A7C0F4793812 /* [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 */,
B84E40F81DF927F34032D68B /* [CP] Embed Pods Frameworks */,
AA47AE6072D35F0490B53926 /* [CP] Copy Pods Resources */,
9F740EE07B5F97C85979C145 /* [CP] Embed Pods Frameworks */,
550CD54859274FE505BA4957 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@ -234,45 +234,29 @@
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";
};
99A79B70155E84EE1FB7F466 /* [Expo] Configure project */ = {
3B2D9C1D63379C2F30AC0F2B /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/Nuvio/Nuvio.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-Nuvio/expo-configure-project.sh",
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[Expo] Configure project";
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-Nuvio/ExpoModulesProvider.swift",
"$(DERIVED_FILE_DIR)/Pods-Nuvio-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Nuvio/expo-configure-project.sh\"\n";
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;
};
9B977D89FE30470F8C59964C /* Upload Debug Symbols to Sentry */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Upload Debug Symbols to Sentry";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
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'\"`";
};
AA47AE6072D35F0490B53926 /* [CP] Copy Pods Resources */ = {
550CD54859274FE505BA4957 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -384,7 +368,45 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Nuvio/Pods-Nuvio-resources.sh\"\n";
showEnvVarsInLog = 0;
};
B84E40F81DF927F34032D68B /* [CP] Embed Pods Frameworks */ = {
99A79B70155E84EE1FB7F466 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/Nuvio/Nuvio.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-Nuvio/expo-configure-project.sh",
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-Nuvio/ExpoModulesProvider.swift",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Nuvio/expo-configure-project.sh\"\n";
};
9B977D89FE30470F8C59964C /* Upload Debug Symbols to Sentry */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Upload Debug Symbols to Sentry";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
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'\"`";
};
9F740EE07B5F97C85979C145 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -406,28 +428,6 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Nuvio/Pods-Nuvio-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
E060D0359630A7C0F4793812 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Nuvio-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
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;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -449,7 +449,7 @@
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 819F6DCD44DFE0C72440FDCF /* Pods-Nuvio.debug.xcconfig */;
baseConfigurationReference = 904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
@ -487,7 +487,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 15864A7148A4384BAA9F0B37 /* Pods-Nuvio.release.xcconfig */;
baseConfigurationReference = 5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;

View file

@ -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'

View file

@ -1902,6 +1902,30 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-skia (2.4.14):
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React
- React-callinvoker
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-slider (5.1.1):
- hermes-engine
- RCTRequired
@ -2736,7 +2760,7 @@ PODS:
- 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`)
@ -2776,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`)
@ -2822,6 +2846,7 @@ DEPENDENCIES:
- react-native-google-cast (from `../node_modules/react-native-google-cast`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- "react-native-skia (from `../node_modules/@shopify/react-native-skia`)"
- "react-native-slider (from `../node_modules/@react-native-community/slider`)"
- react-native-video (from `../node_modules/react-native-video`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
@ -2885,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:
@ -2968,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
@ -3059,6 +3082,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/netinfo"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-skia:
:path: "../node_modules/@shopify/react-native-skia"
react-native-slider:
:path: "../node_modules/@react-native-community/slider"
react-native-video:
@ -3147,15 +3172,9 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
CHECKOUT OPTIONS:
DisplayCriteria:
:commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3
:git: https://github.com/kingslay/KSPlayer.git
FFmpegKit:
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
:git: https://github.com/kingslay/FFmpegKit.git
KSPlayer:
:commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3
:git: https://github.com/kingslay/KSPlayer.git
Libass:
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
:git: https://github.com/kingslay/FFmpegKit.git
@ -3254,6 +3273,7 @@ SPEC CHECKSUMS:
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
react-native-skia: 268f183f849742e9da216743ee234bd7ad81c69b
react-native-slider: f954578344106f0a732a4358ce3a3e11015eb6e1
react-native-video: f5982e21efab0dc356d92541a8a9e19581307f58
React-NativeModulesApple: a9464983ccc0f66f45e93558671f60fc7536e438
@ -3304,6 +3324,6 @@ SPEC CHECKSUMS:
SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d
Yoga: 051f086b5ccf465ff2ed38a2cf5a558ae01aaaa1
PODFILE CHECKSUM: 7c74c9cd2c7f3df7ab68b4284d9f324282e54542
PODFILE CHECKSUM: b884d1ff07ac4a43323bce2e2e1342592513858c
COCOAPODS: 1.16.2

View file

@ -1,5 +1,6 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "true"
}
"newArchEnabled": "true",
"ios.deploymentTarget": "16.0"
}

View 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
}
}
}

View 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)
}
}
}

View file

@ -30,6 +30,30 @@
"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",

166
package-lock.json generated
View file

@ -29,6 +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.4.14",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.12.2",
@ -63,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",
@ -86,7 +91,7 @@
"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.7.1"
@ -100,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"
}
},
@ -3642,6 +3647,33 @@
"react-native": "*"
}
},
"node_modules/@shopify/react-native-skia": {
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.14.tgz",
"integrity": "sha512-zFxjAQbfrdOxoNJoaOCZQzZliuAWXjFkrNZv2PtofG2RAUPWIxWmk2J/oOROpTwXgkmh1JLvFp3uONccTXUthQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"canvaskit-wasm": "0.40.0",
"react-reconciler": "0.31.0"
},
"bin": {
"setup-skia-web": "scripts/setup-canvaskit.js"
},
"peerDependencies": {
"react": ">=19.0",
"react-native": ">=0.78",
"react-native-reanimated": ">=3.19.1"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
},
"react-native-reanimated": {
"optional": true
}
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@ -4304,6 +4336,12 @@
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz",
"integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==",
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
@ -5158,6 +5196,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvaskit-wasm": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz",
"integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==",
"license": "BSD-3-Clause",
"dependencies": {
"@webgpu/types": "0.1.21"
}
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
@ -7462,6 +7509,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/htmlparser2-without-node-native": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/htmlparser2-without-node-native/-/htmlparser2-without-node-native-3.9.2.tgz",
@ -7573,6 +7629,37 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/i18next": {
"version": "25.7.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -7700,6 +7787,12 @@
"css-in-js-utils": "^3.1.0"
}
},
"node_modules/intl-pluralrules": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/intl-pluralrules/-/intl-pluralrules-2.0.1.tgz",
"integrity": "sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg==",
"license": "ISC"
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@ -10482,6 +10575,18 @@
}
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"node_modules/react-freeze": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
@ -10494,6 +10599,33 @@
"react": ">=17.0.0"
}
},
"node_modules/react-i18next": {
"version": "16.5.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz",
"integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
@ -11307,6 +11439,27 @@
"async-limiter": "~1.0.0"
}
},
"node_modules/react-reconciler": {
"version": "0.31.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz",
"integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.25.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/react-reconciler/node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@ -13186,6 +13339,15 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT"
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",

View file

@ -29,6 +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.4.14",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.12.2",
@ -63,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",
@ -86,7 +91,7 @@
"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.7.1"
@ -100,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

File diff suppressed because it is too large Load diff

View file

@ -1,94 +0,0 @@
const { withDangerousMod, withMainApplication, withMainActivity } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
/**
* Copy MPV native files to android project
*/
function copyMpvFiles(projectRoot) {
const sourceDir = path.join(projectRoot, 'plugins', 'mpv-bridge', 'android', 'mpv');
const destDir = path.join(projectRoot, 'android', 'app', 'src', 'main', 'java', 'com', 'nuvio', 'app', 'mpv');
// Create destination directory if it doesn't exist
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
// Copy all files from source to destination
if (fs.existsSync(sourceDir)) {
const files = fs.readdirSync(sourceDir);
files.forEach(file => {
const srcFile = path.join(sourceDir, file);
const destFile = path.join(destDir, file);
if (fs.statSync(srcFile).isFile()) {
fs.copyFileSync(srcFile, destFile);
console.log(`[mpv-bridge] Copied ${file} to android project`);
}
});
}
}
/**
* Modify MainApplication.kt to include MpvPackage
*/
function withMpvMainApplication(config) {
return withMainApplication(config, async (config) => {
let contents = config.modResults.contents;
// Add import for MpvPackage
const mpvImport = 'import com.nuvio.app.mpv.MpvPackage';
if (!contents.includes(mpvImport)) {
// Add import after the last import statement
const lastImportIndex = contents.lastIndexOf('import ');
const endOfLastImport = contents.indexOf('\n', lastImportIndex);
contents = contents.slice(0, endOfLastImport + 1) + mpvImport + '\n' + contents.slice(endOfLastImport + 1);
}
// Add MpvPackage to the packages list
const packagesPattern = /override fun getPackages\(\): List<ReactPackage> \{[\s\S]*?return PackageList\(this\)\.packages\.apply \{/;
if (contents.match(packagesPattern) && !contents.includes('MpvPackage()')) {
contents = contents.replace(
packagesPattern,
(match) => match + '\n add(MpvPackage())'
);
}
config.modResults.contents = contents;
return config;
});
}
/**
* Modify MainActivity.kt to handle MPV lifecycle if needed
*/
function withMpvMainActivity(config) {
return withMainActivity(config, async (config) => {
// Currently no modifications needed for MainActivity
// But this hook is available for future enhancements
return config;
});
}
/**
* Main plugin function
*/
function withMpvBridge(config) {
// Copy native files during prebuild
config = withDangerousMod(config, [
'android',
async (config) => {
copyMpvFiles(config.modRequest.projectRoot);
return config;
},
]);
// Modify MainApplication to register the package
config = withMpvMainApplication(config);
// Modify MainActivity if needed
config = withMpvMainActivity(config);
return config;
}
module.exports = withMpvBridge;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View file

@ -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({

View file

@ -12,9 +12,9 @@ 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
@ -44,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;
@ -122,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(() => {
@ -148,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');
@ -157,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
@ -188,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,
@ -198,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)
@ -217,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)
@ -228,7 +257,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
}));
}
}, [backdropSource?.uri, backdropLoaded, backdropError]);
// Reset animation when episode changes
useEffect(() => {
backdropOpacity.value = 0;
@ -240,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);
@ -294,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>
);
@ -311,7 +340,7 @@ 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)
@ -327,7 +356,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
if (item.type === 'header') {
return renderSectionHeader({ section: { title: item.title, addonId: item.addonId } });
}
const stream = item.stream;
return (
<StreamCard
@ -414,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 ? (

View file

@ -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);

View file

@ -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';
@ -144,6 +145,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
onRetry,
scrollY: externalScrollY,
}) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isFocused = useIsFocused();
const { currentTheme } = useTheme();
@ -158,7 +160,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const [inLibrary, setInLibrary] = useState(false);
const [isInWatchlist, setIsInWatchlist] = useState(false);
const [isWatched, setIsWatched] = useState(false);
const [playButtonText, setPlayButtonText] = useState('Play');
const [shouldResume, setShouldResume] = useState(false);
const [type, setType] = useState<'movie' | 'series'>('movie');
// Create internal scrollY if not provided externally
@ -530,7 +532,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
useEffect(() => {
if (currentItem) {
const buttonText = getProgressPlayButtonText();
setPlayButtonText(buttonText);
// Use internal state for resume logic instead of string comparison
setShouldResume(buttonText === 'Resume');
// Update watched state based on progress
if (watchProgress) {
@ -987,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>
@ -1242,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 && (
<>
@ -1262,11 +1265,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
activeOpacity={0.85}
>
<MaterialIcons
name={playButtonText === 'Resume' ? "replay" : "play-arrow"}
name={shouldResume ? "replay" : "play-arrow"}
size={24}
color="#000"
/>
<Text style={styles.playButtonText}>{playButtonText}</Text>
<Text style={styles.playButtonText}>{shouldResume ? t('home.resume') : t('home.play')}</Text>
</TouchableOpacity>
{/* Save Button */}

View file

@ -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;
@ -73,9 +75,44 @@ 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]);
@ -117,7 +154,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
]}
numberOfLines={1}
>
{catalog.name}
{displayName}
</Text>
<View
style={[
@ -154,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}

View file

@ -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';
@ -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);
@ -182,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': {
@ -194,7 +196,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
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');
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);
@ -240,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;
@ -251,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;

File diff suppressed because it is too large Load diff

View file

@ -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';
@ -102,12 +104,12 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
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'
}
);

View file

@ -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>

View file

@ -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();
@ -610,6 +612,7 @@ 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);
@ -847,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>
@ -956,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>

View file

@ -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';
@ -58,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();
@ -176,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,
@ -239,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
@ -294,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>
@ -357,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,
{
@ -380,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}

View 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>
)}

View file

@ -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

View file

@ -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);
}
};

View file

@ -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;
@ -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', {
@ -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);
@ -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>
@ -930,7 +933,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
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>
@ -993,7 +996,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
) : (
<>
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
Load More
{t('common.load_more')}
</Text>
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} />
</>

View file

@ -51,6 +51,7 @@ import { useToast } from '../../contexts/ToastContext';
import { useTraktContext } from '../../contexts/TraktContext';
import { useSettings } from '../../hooks/useSettings';
import { useTrailer } from '../../contexts/TrailerContext';
import { useTranslation } from 'react-i18next';
import { logger } from '../../utils/logger';
import { TMDBService } from '../../services/tmdbService';
import TrailerService from '../../services/trailerService';
@ -149,6 +150,7 @@ const ActionButtons = memo(({
onToggleCollection?: () => void;
}) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast();
// Performance optimization: Cache theme colors
@ -235,9 +237,9 @@ const ActionButtons = memo(({
// Show appropriate toast
if (wasInCollection) {
showInfo('Removed from Collection', 'Removed from your Trakt collection');
showInfo(t('metadata.removed_from_collection_hero'), t('metadata.removed_from_collection_desc_hero'));
} else {
showSuccess('Added to Collection', 'Added to your Trakt collection');
showSuccess(t('metadata.added_to_collection_hero'), t('metadata.added_to_collection_desc_hero'));
}
}, [onToggleCollection, isInCollection, showSuccess, showInfo]);
@ -263,7 +265,7 @@ const ActionButtons = memo(({
const finalPlayButtonText = useMemo(() => {
// For movies, handle watched state
if (type === 'movie') {
return isWatched ? 'Watch Again' : playButtonText;
return isWatched ? t('metadata.watch_again') : playButtonText;
}
// For series, validate next episode existence for both watched and resume cases
@ -306,7 +308,7 @@ const ActionButtons = memo(({
return `Play S${seasonStr}E${episodeStr}`;
} else {
// If next episode doesn't exist, show generic text
return 'Completed';
return t('metadata.completed');
}
} else {
// For non-watched episodes, check if current episode exists
@ -320,17 +322,17 @@ const ActionButtons = memo(({
return playButtonText;
} else {
// Current episode doesn't exist, fallback to generic play
return 'Play';
return t('metadata.play');
}
}
}
// Fallback label if parsing fails
return isWatched ? 'Play Next Episode' : playButtonText;
return isWatched ? t('metadata.play_next_episode') : playButtonText;
}
// Default fallback for non-series or missing data
return isWatched ? 'Play' : playButtonText;
return isWatched ? t('metadata.play') : playButtonText;
}, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]);
// Count additional buttons (excluding Play and Save) - AI Chat no longer counted
@ -394,7 +396,7 @@ const ActionButtons = memo(({
color={inLibrary ? (isAuthenticated && isInWatchlist ? "#E74C3C" : currentTheme.colors.white) : currentTheme.colors.white}
/>
<Text style={[styles.infoButtonText, isTablet && styles.tabletInfoButtonText]}>
{inLibrary ? 'Saved' : 'Save'}
{inLibrary ? t('metadata.saved') : t('metadata.save')}
</Text>
</TouchableOpacity>
@ -484,6 +486,7 @@ const WatchProgressDisplay = memo(({
trailerReady: boolean;
}) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext();
// State to trigger refresh after manual sync
@ -567,7 +570,7 @@ const WatchProgressDisplay = memo(({
progressPercent: 100,
formattedTime: watchedDate,
episodeInfo,
displayText: watchedViaTrakt ? 'Watched on Trakt' : 'Watched',
displayText: watchedViaTrakt ? t('metadata.watched_on_trakt') : t('metadata.watched'),
syncStatus: isTraktAuthenticated && watchProgress?.traktSynced ? '' : '', // Clean look for watched
isTraktSynced: watchProgress?.traktSynced && isTraktAuthenticated,
isWatched: true
@ -597,22 +600,22 @@ const WatchProgressDisplay = memo(({
}
// Enhanced display text with Trakt integration
let displayText = progressPercent >= 85 ? 'Watched' : `${Math.round(progressPercent)}% watched`;
let displayText = progressPercent >= 85 ? t('metadata.watched') : t('metadata.percent_watched', { percent: Math.round(progressPercent) });
let syncStatus = '';
// Show Trakt sync status if user is authenticated
if (isTraktAuthenticated) {
if (isUsingTraktProgress) {
syncStatus = ' • Using Trakt progress';
syncStatus = ' • ' + t('metadata.using_trakt_progress');
if (watchProgress.traktSynced) {
syncStatus = ' • Synced with Trakt';
syncStatus = ' • ' + t('metadata.synced_with_trakt_progress');
}
} else if (watchProgress.traktSynced) {
syncStatus = ' • Synced with Trakt';
syncStatus = ' • ' + t('metadata.synced_with_trakt_progress');
// If we have specific Trakt progress that differs from local, mention it
if (watchProgress.traktProgress !== undefined &&
Math.abs(progressPercent - watchProgress.traktProgress) > 5) {
displayText = `${Math.round(progressPercent)}% watched (${Math.round(watchProgress.traktProgress)}% on Trakt)`;
displayText = t('metadata.percent_watched_trakt', { percent: Math.round(progressPercent), traktPercent: Math.round(watchProgress.traktProgress) });
}
} else {
// Do not show "Sync pending" label anymore; leave status empty.

View file

@ -233,21 +233,10 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
)}
{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,
color: '#F5C518',
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
}
]}>{metadata.imdbRating}</Text>

View file

@ -9,6 +9,7 @@ import {
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';
@ -39,6 +40,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
}) => {
const { t } = useTranslation();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -112,9 +114,9 @@ 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);
}
};
@ -149,7 +151,7 @@ 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>
<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}

View file

@ -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:

View file

@ -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',
@ -240,13 +248,23 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
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}

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable } from 'react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
@ -40,7 +41,6 @@ interface SeriesContentProps {
const DEFAULT_PLACEHOLDER = 'https://via.placeholder.com/300x450/1a1a1a/666666?text=No+Image';
const EPISODE_PLACEHOLDER = 'https://via.placeholder.com/500x280/1a1a1a/666666?text=No+Preview';
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png';
const SeriesContentComponent: React.FC<SeriesContentProps> = ({
episodes,
@ -54,6 +54,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
}) => {
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { t } = useTranslation();
const { width } = useWindowDimensions();
const isDarkMode = useColorScheme() === 'dark';
@ -489,9 +490,18 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
};
}, []);
// Add effect to scroll to selected season
// Track previous season to only scroll when it actually changes
const previousSeasonRef = React.useRef<number | null>(null);
// Add effect to scroll to selected season (only when season changes, not on every groupedEpisodes update)
useEffect(() => {
if (selectedSeason && seasonScrollViewRef.current && Object.keys(groupedEpisodes).length > 0) {
// Only scroll if the season actually changed (not just groupedEpisodes update)
if (previousSeasonRef.current === selectedSeason) {
return; // Season didn't change, don't scroll
}
previousSeasonRef.current = selectedSeason;
// Find the index of the selected season
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
const selectedIndex = seasons.findIndex(season => season === selectedSeason);
@ -731,7 +741,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
return (
<View style={styles.centeredContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>Loading episodes...</Text>
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>{t('metadata.loading_episodes')}</Text>
</View>
);
}
@ -740,7 +750,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
return (
<View style={styles.centeredContainer}>
<MaterialIcons name="error-outline" size={48} color={currentTheme.colors.textMuted} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>No episodes available</Text>
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>{t('metadata.no_episodes_available')}</Text>
</View>
);
}
@ -776,7 +786,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 18
}
]}>Seasons</Text>
]}>{t('metadata.seasons')}</Text>
{/* Dropdown Toggle Button */}
<TouchableOpacity
@ -855,7 +865,6 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
styles.seasonTextButton,
{
marginRight: seasonButtonSpacing,
width: isTV ? 150 : isLargeTablet ? 140 : isTablet ? 130 : 110,
paddingVertical: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
paddingHorizontal: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
@ -874,7 +883,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
{ color: currentTheme.colors.highEmphasis }
]
]} numberOfLines={1}>
{season === 0 ? 'Specials' : `Season ${season}`}
{season === 0 ? t('metadata.specials') : t('metadata.season_number', { number: season })}
</Text>
</TouchableOpacity>
</View>
@ -937,7 +946,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
]
]}
>
{season === 0 ? 'Specials' : `Season ${season}`}
{season === 0 ? t('metadata.specials') : t('metadata.season_number', { number: season })}
</Text>
</TouchableOpacity>
</View>
@ -1157,17 +1166,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
<View style={styles.ratingContainer}>
{isImdbRating ? (
<>
<FastImage
source={{ uri: IMDb_LOGO }}
style={[
styles.imdbLogo,
{
width: isTV ? 32 : isLargeTablet ? 30 : isTablet ? 28 : 28,
height: isTV ? 17 : isLargeTablet ? 16 : isTablet ? 15 : 15
}
]}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[
styles.ratingText,
{
@ -1423,17 +1422,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
<View style={styles.ratingContainerHorizontal}>
{isImdbRating ? (
<>
<FastImage
source={{ uri: IMDb_LOGO }}
style={[
styles.imdbLogoHorizontal,
{
width: isTV ? 32 : isLargeTablet ? 30 : isTablet ? 28 : 28,
height: isTV ? 17 : isLargeTablet ? 16 : isTablet ? 15 : 15
}
]}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[
styles.ratingTextHorizontal,
{
@ -1447,7 +1436,17 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
</>
) : (
<>
<MaterialIcons name="star" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color="#FFD700" />
<FastImage
source={{ uri: TMDB_LOGO }}
style={[
styles.tmdbLogo,
{
width: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 20,
height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
}
]}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[
styles.ratingTextHorizontal,
{
@ -1548,7 +1547,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
paddingHorizontal: horizontalPadding
}
]}>
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
{currentSeasonEpisodes.length === 1 ? t('metadata.episode_count', { count: currentSeasonEpisodes.length }) : t('metadata.episode_count_plural', { count: currentSeasonEpisodes.length })}
</Text>
{/* Show message when no episodes are available for selected season */}
@ -1556,10 +1555,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
<View style={styles.centeredContainer}>
<MaterialIcons name="schedule" size={48} color={currentTheme.colors.textMuted} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>
No episodes available for Season {selectedSeason}
{t('metadata.no_episodes_for_season', { season: selectedSeason })}
</Text>
<Text style={[styles.centeredSubText, { color: currentTheme.colors.textMuted }]}>
Episodes may not be released yet
{t('metadata.episodes_not_released')}
</Text>
</View>
)}
@ -1739,7 +1738,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
fontSize: isTV ? 16 : 15,
fontWeight: '500',
}}>
{markingAsWatched ? 'Removing...' : 'Mark as Unwatched'}
{markingAsWatched ? t('metadata.removing') : t('metadata.mark_as_unwatched')}
</Text>
</TouchableOpacity>
) : (
@ -1766,7 +1765,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
fontSize: isTV ? 16 : 15,
fontWeight: '600',
}}>
{markingAsWatched ? 'Marking...' : 'Mark as Watched'}
{markingAsWatched ? t('metadata.marking') : t('metadata.mark_as_watched')}
</Text>
</TouchableOpacity>
)
@ -1798,7 +1797,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
fontWeight: '500',
flex: 1, // Allow text to take up space
}} numberOfLines={1}>
{markingAsWatched ? 'Removing...' : `Unmark Season ${selectedSeason}`}
{markingAsWatched ? t('metadata.removing') : t('metadata.unmark_season', { season: selectedSeason })}
</Text>
</TouchableOpacity>
) : (
@ -1826,7 +1825,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
fontWeight: '500',
flex: 1,
}} numberOfLines={1}>
{markingAsWatched ? 'Marking...' : `Mark Season ${selectedSeason}`}
{markingAsWatched ? t('metadata.marking') : t('metadata.mark_season', { season: selectedSeason })}
</Text>
</TouchableOpacity>
)}
@ -1845,7 +1844,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
fontSize: isTV ? 15 : 14,
fontWeight: '500',
}}>
Cancel
{t('common.cancel')}
</Text>
</TouchableOpacity>
</View>
@ -1995,10 +1994,6 @@ const styles = StyleSheet.create({
width: 20,
height: 14,
},
imdbLogo: {
width: 35,
height: 18,
},
ratingText: {
color: '#01b4e4',
fontSize: 13,
@ -2186,10 +2181,7 @@ const styles = StyleSheet.create({
// chip background removed
gap: 2,
},
imdbLogoHorizontal: {
width: 35,
height: 18,
},
ratingTextHorizontal: {
color: '#FFD700',
fontSize: 11,

View file

@ -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>
)}

View file

@ -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();
@ -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>
@ -512,7 +514,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
}
]}>
Trailers & Videos
{t('trailers.title')}
</Text>
{/* Category Selector - Right Aligned */}

View 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;

View 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;

View 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,
);

View 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));

View 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),
);

View 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,
);

View 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,
);

View 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));

View 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,
);

View 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,
);

View file

@ -0,0 +1 @@
export type Point3D = { x: number; y: number; z: number };

View 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,
}));
};

View file

@ -339,7 +339,8 @@ const AndroidVideoPlayer: React.FC = () => {
if (data.audioTracks) {
const formatted = data.audioTracks.map((t: any, i: number) => ({
id: t.index !== undefined ? t.index : i,
// react-native-video selectedAudioTrack {type:'index'} uses 0-based list index.
id: i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
@ -347,7 +348,9 @@ const AndroidVideoPlayer: React.FC = () => {
}
if (data.textTracks) {
const formatted = data.textTracks.map((t: any, i: number) => ({
id: t.index !== undefined ? t.index : i,
// react-native-video selectedTextTrack {type:'index'} uses 0-based list index.
// Using `t.index` can be non-unique/misaligned and breaks selection/rendering.
id: i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
@ -360,7 +363,7 @@ const AndroidVideoPlayer: React.FC = () => {
// Auto-select audio track based on preferences
if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) {
const formatted = data.audioTracks.map((t: any, i: number) => ({
id: t.index !== undefined ? t.index : i,
id: i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
@ -380,7 +383,7 @@ const AndroidVideoPlayer: React.FC = () => {
// Only pre-select internal if preference is internal or any
if (sourcePreference === 'internal' || sourcePreference === 'any') {
const formatted = data.textTracks.map((t: any, i: number) => ({
id: t.index !== undefined ? t.index : i,
id: i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
@ -479,11 +482,11 @@ const AndroidVideoPlayer: React.FC = () => {
useEffect(() => {
if (!useCustomSubtitles || customSubtitles.length === 0) return;
const cueNow = customSubtitles.find(
cue => playerState.currentTime >= cue.start && playerState.currentTime <= cue.end
);
// Apply timing offset for custom/addon subtitles (ExoPlayer internal subtitles do not support offset)
const adjustedTime = playerState.currentTime + (subtitleOffsetSec || 0);
const cueNow = customSubtitles.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end);
setCurrentSubtitle(cueNow ? cueNow.text : '');
}, [playerState.currentTime, useCustomSubtitles, customSubtitles]);
}, [playerState.currentTime, subtitleOffsetSec, useCustomSubtitles, customSubtitles]);
const toggleControls = useCallback(() => {
playerState.setShowControls(prev => {
@ -678,8 +681,8 @@ const AndroidVideoPlayer: React.FC = () => {
mpvPlayerRef.current.setSubtitleTrack(-1);
}
// Set initial subtitle based on current time
const adjustedTime = playerState.currentTime;
// Set initial subtitle based on current time (+ any timing offset)
const adjustedTime = playerState.currentTime + (subtitleOffsetSec || 0);
const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end);
setCurrentSubtitle(cueNow ? cueNow.text : '');
@ -691,7 +694,7 @@ const AndroidVideoPlayer: React.FC = () => {
} finally {
setIsLoadingSubtitles(false);
}
}, [modals, playerState.currentTime, tracksHook]);
}, [modals, playerState.currentTime, subtitleOffsetSec, tracksHook]);
const disableCustomSubtitles = useCallback(() => {
setUseCustomSubtitles(false);
@ -817,6 +820,7 @@ const AndroidVideoPlayer: React.FC = () => {
subtitleBorderColor={subtitleOutlineColor}
subtitleShadowEnabled={subtitleTextShadow}
subtitlePosition={Math.max(50, 100 - Math.floor(subtitleBottomOffset * 0.3))} // Scale offset to MPV range
subtitleBottomOffset={subtitleBottomOffset}
subtitleDelay={subtitleOffsetSec}
subtitleAlignment={subtitleAlign}
/>
@ -943,6 +947,8 @@ const AndroidVideoPlayer: React.FC = () => {
type={type || 'movie'}
season={season}
episode={episode}
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
currentTime={playerState.currentTime}
onSkip={(endTime) => controlsHook.seekToTime(endTime)}
controlsVisible={playerState.showControls}

View file

@ -19,6 +19,7 @@ interface KSPlayerViewProps {
subtitleFontSize?: number;
subtitleTextColor?: string;
subtitleBackgroundColor?: string;
subtitleOutlineEnabled?: boolean;
resizeMode?: 'contain' | 'cover' | 'stretch';
onLoad?: (data: any) => void;
onProgress?: (data: any) => void;
@ -60,6 +61,7 @@ export interface KSPlayerProps {
subtitleFontSize?: number;
subtitleTextColor?: string;
subtitleBackgroundColor?: string;
subtitleOutlineEnabled?: boolean;
resizeMode?: 'contain' | 'cover' | 'stretch';
onLoad?: (data: any) => void;
onProgress?: (data: any) => void;
@ -210,6 +212,7 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
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)}

View file

@ -145,18 +145,18 @@ const KSPlayerCore: React.FC = () => {
// Track previous video session to reset subtitle offset only when video actually changes
const previousVideoRef = useRef<{ uri?: string; episodeId?: string }>({});
// Reset subtitle offset when starting a new video session
useEffect(() => {
const currentVideo = { uri, episodeId };
const previousVideo = previousVideoRef.current;
// Only reset if this is actually a new video (uri or episodeId changed)
if (previousVideo.uri !== undefined &&
(previousVideo.uri !== currentVideo.uri || previousVideo.episodeId !== currentVideo.episodeId)) {
if (previousVideo.uri !== undefined &&
(previousVideo.uri !== currentVideo.uri || previousVideo.episodeId !== currentVideo.episodeId)) {
customSubs.setSubtitleOffsetSec(0);
}
// Update the ref for next comparison
previousVideoRef.current = currentVideo;
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -616,6 +616,10 @@ const KSPlayerCore: React.FC = () => {
/>
{/* Video Surface & Pinch Zoom */}
{/*
For KSPlayer built-in subtitles (internal text tracks), we intentionally force background OFF.
Background styling is only supported/used for custom (external/addon) subtitles overlay.
*/}
<KSPlayerSurface
ksPlayerRef={ksPlayerRef}
uri={uri}
@ -656,7 +660,20 @@ const KSPlayerCore: React.FC = () => {
screenHeight={screenDimensions.height}
customVideoStyles={{ width: '100%', height: '100%' }}
subtitleTextColor={customSubs.subtitleTextColor}
subtitleBackgroundColor={customSubs.subtitleBackground ? `rgba(0,0,0,${customSubs.subtitleBgOpacity})` : 'transparent'}
subtitleBackgroundColor={
tracks.selectedTextTrack !== null &&
tracks.selectedTextTrack >= 0 &&
!customSubs.useCustomSubtitles
? 'rgba(0,0,0,0)'
: (customSubs.subtitleBackground ? `rgba(0,0,0,${customSubs.subtitleBgOpacity})` : 'transparent')
}
subtitleOutlineEnabled={
tracks.selectedTextTrack !== null &&
tracks.selectedTextTrack >= 0 &&
!customSubs.useCustomSubtitles
? customSubs.subtitleOutline
: false
}
subtitleFontSize={customSubs.subtitleSize}
subtitleBottomOffset={customSubs.subtitleBottomOffset}
/>
@ -750,6 +767,7 @@ const KSPlayerCore: React.FC = () => {
visible={speedControl.showSpeedActivatedOverlay}
opacity={speedControl.speedActivatedOverlayOpacity}
speed={speedControl.holdToSpeedValue}
screenDimensions={screenDimensions}
/>
<ResumeOverlay
@ -799,6 +817,8 @@ const KSPlayerCore: React.FC = () => {
type={type}
season={season}
episode={episode}
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
currentTime={currentTime}
onSkip={(endTime) => controls.seekToTime(endTime)}
controlsVisible={showControls}

View file

@ -77,6 +77,7 @@ interface VideoSurfaceProps {
subtitleBorderColor?: string;
subtitleShadowEnabled?: boolean;
subtitlePosition?: number;
subtitleBottomOffset?: number;
subtitleDelay?: number;
subtitleAlignment?: 'left' | 'center' | 'right';
}
@ -128,6 +129,7 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
subtitleBorderColor,
subtitleShadowEnabled,
subtitlePosition,
subtitleBottomOffset,
subtitleDelay,
subtitleAlignment,
}) => {
@ -173,15 +175,19 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
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: t.index ?? i,
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: t.index ?? i,
id: i,
name: t.title || t.language || `Track ${i + 1}`,
language: t.language,
};
@ -281,6 +287,11 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
}
};
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,
@ -314,21 +325,39 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
automaticallyWaitsToMinimizeStalling={true}
useTextureView={true}
// Subtitle Styling for ExoPlayer
// ExoPlayer supports: fontSize, paddingTop/Bottom/Left/Right, opacity, subtitlesFollowVideo
// 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 ExoPlayer scale (~1.5x conversion was applied)
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 18,
// Convert MPV-scaled size back to UI size (AndroidVideoPlayer passes MPV-scaled values here)
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 28,
paddingTop: 0,
// Convert MPV position (0=top, 100=bottom) to paddingBottom
// Higher MPV position = less padding from bottom
paddingBottom: subtitlePosition ? Math.max(20, Math.round((100 - subtitlePosition) * 2)) : 60,
// 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 */

View file

@ -28,7 +28,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
}) => {
const { width } = Dimensions.get('window');
const isTablet = width >= 768;
// Get episode image
let episodeImage = EPISODE_PLACEHOLDER;
if (episode.still_path) {
@ -42,11 +42,11 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
} else if (metadata?.poster) {
episodeImage = metadata.poster;
}
const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
const episodeString = seasonNumber && episodeNumber ? `S${seasonNumber.padStart(2, '0')}E${episodeNumber.padStart(2, '0')}` : '';
// Get episode progress
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
const tmdbOverride = tmdbEpisodeOverrides?.[`${metadata?.id}:${episode.season_number}:${episode.episode_number}`];
@ -60,7 +60,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
const progress = episodeProgress?.[episodeId];
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
const showProgress = progress && progressPercent < 85;
const formatRuntime = (runtime: number) => {
if (!runtime) return null;
const hours = Math.floor(runtime / 60);
@ -70,7 +70,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
}
return `${minutes}m`;
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
@ -106,11 +106,11 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
</View>
{showProgress && (
<View style={styles.progressBarContainer}>
<View
<View
style={[
styles.progressBar,
{ width: `${progressPercent}%`, backgroundColor: currentTheme.colors.primary }
]}
]}
/>
</View>
)}

View file

@ -4,6 +4,7 @@ import { Ionicons } from '@expo/vector-icons';
import Feather from 'react-native-vector-icons/Feather';
import { LinearGradient } from 'expo-linear-gradient';
import Slider from '@react-native-community/slider';
import { useTranslation } from 'react-i18next';
import { styles } from '../utils/playerStyles'; // Updated styles
import { getTrackDisplayName } from '../utils/playerUtils';
import { useTheme } from '../../../contexts/ThemeContext';
@ -99,6 +100,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
useExoPlayer,
}) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
/* Responsive Spacing */
@ -115,6 +117,13 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
/* Animations - State & Refs */
const [showBackwardSign, setShowBackwardSign] = React.useState(false);
const [showForwardSign, setShowForwardSign] = React.useState(false);
const [previewTime, setPreviewTime] = React.useState(currentTime);
const isSlidingRef = React.useRef(false);
React.useEffect(() => {
if (!isSlidingRef.current) {
setPreviewTime(currentTime);
}
}, [currentTime]);
/* Separate Animations for Each Button */
const backwardPressAnim = React.useRef(new Animated.Value(0)).current;
@ -280,10 +289,22 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
}}
minimumValue={0}
maximumValue={duration || 1}
value={currentTime}
onValueChange={onSliderValueChange}
onSlidingStart={onSlidingStart}
onSlidingComplete={onSlidingComplete}
value={previewTime}
onValueChange={(v) => setPreviewTime(v)}
onSlidingStart={() => {
isSlidingRef.current = true;
onSlidingStart();
}}
onSlidingComplete={(v) => {
isSlidingRef.current = false;
setPreviewTime(v);
onSlidingComplete(v);
}}
minimumTrackTintColor={currentTheme.colors.primary}
maximumTrackTintColor={currentTheme.colors.mediumEmphasis}
thumbTintColor={Platform.OS === 'android' ? currentTheme.colors.white : undefined}
@ -291,7 +312,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
/>
<View style={[styles.timeDisplay, { paddingHorizontal: 14 }]}>
<View style={styles.timeContainer}>
<Text style={styles.duration}>{formatTime(currentTime)}</Text>
<Text style={styles.duration}>{formatTime(previewTime)}</Text>
</View>
<View style={styles.timeContainer}>
<Text style={styles.duration}>{formatTime(duration)}</Text>
@ -319,7 +340,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
{/* Show year and provider (quality chip removed) */}
<View style={styles.metadataRow}>
{year && <Text style={styles.metadataText}>{year}</Text>}
{streamName && <Text style={styles.providerText}>via {streamName}</Text>}
{streamName && <Text style={styles.providerText}>{t('player_ui.via', { name: streamName })}</Text>}
</View>
{playerBackend && (
<View style={styles.metadataRow}>
@ -380,12 +401,13 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
transform: [{ scale: backwardScaleAnim }]
}
]}>
<Ionicons
name="reload-outline"
size={seekIconSize}
color="white"
style={{ transform: [{ scaleX: -1 }] }}
/>
<View style={{ transform: [{ scaleX: -1 }] }}>
<Ionicons
name="reload-outline"
size={seekIconSize}
color="white"
/>
</View>
<Animated.View style={[
styles.buttonCircle,
{
@ -608,4 +630,4 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
);
};
export default PlayerControls;
export default PlayerControls;

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { storageService } from '../../../services/storageService';
import { logger } from '../../../utils/logger';
import { useSettings } from '../../../hooks/useSettings';
@ -35,6 +36,44 @@ export const useWatchProgress = (
durationRef.current = duration;
}, [duration]);
// Keep latest traktAutosync ref to avoid dependency cycles in listeners
const traktAutosyncRef = useRef(traktAutosync);
useEffect(() => {
traktAutosyncRef.current = traktAutosync;
}, [traktAutosync]);
// AppState Listener for background save
useEffect(() => {
const subscription = AppState.addEventListener('change', async (nextAppState) => {
if (nextAppState.match(/inactive|background/)) {
if (id && type && durationRef.current > 0) {
logger.log('[useWatchProgress] App backgrounded, saving progress');
// Local save
const progress = {
currentTime: currentTimeRef.current,
duration: durationRef.current,
lastUpdated: Date.now(),
addonId: addonId
};
try {
await storageService.setWatchProgress(id, type, progress, episodeId);
// Trakt sync (end session)
// Use 'user_close' to force immediate sync
await traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'user_close');
} catch (error) {
logger.error('[useWatchProgress] Error saving background progress:', error);
}
}
}
});
return () => {
subscription.remove();
};
}, [id, type, episodeId, addonId]);
// Load Watch Progress
useEffect(() => {
const loadWatchProgress = async () => {

View file

@ -42,6 +42,7 @@ interface KSPlayerSurfaceProps {
subtitleBackgroundColor?: string;
subtitleFontSize?: number;
subtitleBottomOffset?: number;
subtitleOutlineEnabled?: boolean;
}
export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
@ -74,7 +75,8 @@ export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
subtitleTextColor,
subtitleBackgroundColor,
subtitleFontSize,
subtitleBottomOffset
subtitleBottomOffset,
subtitleOutlineEnabled
}) => {
const pinchRef = useRef<PinchGestureHandler>(null);
@ -146,6 +148,7 @@ export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
subtitleBackgroundColor={subtitleBackgroundColor}
subtitleFontSize={subtitleFontSize}
subtitleBottomOffset={subtitleBottomOffset}
subtitleOutlineEnabled={subtitleOutlineEnabled}
onLoad={handleLoad}
onProgress={onProgress}
onBuffering={handleBuffering}

View file

@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, useWindowDimensions, StyleSheet, Platform } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Animated, {
FadeIn,
FadeOut,
@ -25,6 +26,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
selectedAudioTrack,
selectAudioTrack,
}) => {
const { t } = useTranslation();
const { width, height } = useWindowDimensions();
// Size constants matching SubtitleModal aesthetics
@ -67,7 +69,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
>
{/* Header with shared aesthetics */}
<View style={{ flexDirection: 'row', alignItems: 'center', padding: 20, position: 'relative' }}>
<Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>Audio Tracks</Text>
<Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>{t('player_ui.audio_tracks')}</Text>
</View>
<ScrollView
@ -111,7 +113,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
{ksAudioTracks.length === 0 && (
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<MaterialIcons name="volume-off" size={32} color="white" />
<Text style={{ color: 'white', marginTop: 10 }}>No audio tracks available</Text>
<Text style={{ color: 'white', marginTop: 10 }}>{t('player_ui.no_audio_tracks')}</Text>
</View>
)}
</View>

View file

@ -7,6 +7,7 @@ import Animated, {
SlideInRight,
SlideOutRight,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { Episode } from '../../../types/metadata';
import { Stream } from '../../../types/streams';
import { stremioService } from '../../../services/stremioService';
@ -58,6 +59,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
onSelectStream,
metadata,
}) => {
const { t } = useTranslation();
const { width } = useWindowDimensions();
const MENU_WIDTH = Math.min(width * 0.85, 400);
@ -177,7 +179,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<View style={{ flex: 1, marginRight: 10 }}>
<Text style={{ color: 'white', fontSize: 20, fontWeight: '700' }} numberOfLines={1}>
{episode?.name || 'Sources'}
{episode?.name || t('player_ui.sources')}
</Text>
{episode && (
<Text style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, marginTop: 4 }}>
@ -195,7 +197,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
{isLoading && sortedProviders.length === 0 && (
<View style={{ padding: 40, alignItems: 'center' }}>
<ActivityIndicator color="white" />
<Text style={{ color: 'white', marginTop: 15, opacity: 0.6 }}>Finding sources...</Text>
<Text style={{ color: 'white', marginTop: 15, opacity: 0.6 }}>{t('player_ui.finding_sources')}</Text>
</View>
)}
@ -237,7 +239,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
<Text style={{ color: 'white', fontWeight: '700', fontSize: 14, flex: 1 }} numberOfLines={1}>
{stream.name || 'Unknown Source'}
{stream.name || t('player_ui.unknown_source')}
</Text>
<QualityBadge quality={quality} />
</View>
@ -258,13 +260,13 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
{!isLoading && sortedProviders.length === 0 && (
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<MaterialIcons name="cloud-off" size={48} color="white" />
<Text style={{ color: 'white', marginTop: 16, textAlign: 'center', fontWeight: '600' }}>No sources found</Text>
<Text style={{ color: 'white', marginTop: 16, textAlign: 'center', fontWeight: '600' }}>{t('player_ui.no_sources_found')}</Text>
</View>
)}
{hasErrors.length > 0 && (
<View style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: 12, padding: 12, marginTop: 10 }}>
<Text style={{ color: '#EF4444', fontSize: 11 }}>Sources might be limited due to provider errors.</Text>
<Text style={{ color: '#EF4444', fontSize: 11 }}>{t('player_ui.sources_limited')}</Text>
</View>
)}
</ScrollView>

View file

@ -7,6 +7,7 @@ import Animated, {
SlideInRight,
SlideOutRight,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { Episode } from '../../../types/metadata';
import { EpisodeCard } from '../cards/EpisodeCard';
import { storageService } from '../../../services/storageService';
@ -32,6 +33,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
onSelectEpisode,
tmdbEpisodeOverrides
}) => {
const { t } = useTranslation();
const { width } = useWindowDimensions();
const [selectedSeason, setSelectedSeason] = useState<number>(currentEpisode?.season || 1);
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: any }>({});
@ -117,7 +119,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
>
<View style={{ paddingTop: Platform.OS === 'ios' ? 60 : 20, paddingHorizontal: 20 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }}>Episodes</Text>
<Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }}>{t('player_ui.episodes')}</Text>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 15, gap: 8 }}>
@ -143,7 +145,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
color: selectedSeason === season ? 'black' : 'white',
fontWeight: selectedSeason === season ? '700' : '500'
}}>
{season === 0 ? 'Specials' : `Season ${season}`}
{season === 0 ? t('player_ui.specials') : t('player_ui.season', { season })}
</Text>
</TouchableOpacity>
))}

View file

@ -2,6 +2,7 @@ import React from 'react';
import * as ExpoClipboard from 'expo-clipboard';
import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Animated, {
FadeIn,
FadeOut,
@ -22,6 +23,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
errorDetails,
onDismiss,
}) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const { width } = useWindowDimensions();
const MODAL_WIDTH = Math.min(width * 0.8, 400);
@ -79,7 +81,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
marginBottom: 8,
textAlign: 'center'
}}>
Playback Error
{t('player_ui.playback_error')}
</Text>
<Text
@ -93,7 +95,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
lineHeight: 22
}}
>
{errorDetails || 'An unknown error occurred during playback.'}
{errorDetails || t('player_ui.unknown_error')}
</Text>
<TouchableOpacity
@ -114,7 +116,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
style={{ marginRight: 6 }}
/>
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 13, fontWeight: '500' }}>
{copied ? 'Copied to clipboard' : 'Copy error details'}
{copied ? t('player_ui.copied_to_clipboard') : t('player_ui.copy_error')}
</Text>
</TouchableOpacity>
@ -135,7 +137,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
fontSize: 16,
fontWeight: '700'
}}>
Dismiss
{t('player_ui.dismiss')}
</Text>
</TouchableOpacity>
</Animated.View>

View file

@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useTranslation } from 'react-i18next';
import { styles } from '../utils/playerStyles';
import { formatTime } from '../utils/playerUtils';
import { logger } from '../../../utils/logger';
@ -27,6 +28,7 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
handleResume,
handleStartFromBeginning,
}) => {
const { t } = useTranslation();
useEffect(() => {
// Removed excessive logging for props changes
}, [showResumeOverlay, resumePosition, duration, title]);
@ -35,9 +37,9 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
// Removed excessive logging for overlay visibility
return null;
}
// Removed excessive logging for overlay rendering
return (
<View style={styles.resumeOverlay}>
<LinearGradient
@ -49,18 +51,18 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
<Ionicons name="play-circle" size={40} color="#E50914" />
</View>
<View style={styles.resumeTextContainer}>
<Text style={styles.resumeTitle}>Continue Watching</Text>
<Text style={styles.resumeTitle}>{t('player_ui.continue_watching')}</Text>
<Text style={styles.resumeInfo}>
{title}
{season && episode && ` • S${season}E${episode}`}
</Text>
<View style={styles.resumeProgressContainer}>
<View style={styles.resumeProgressBar}>
<View
<View
style={[
styles.resumeProgressFill,
styles.resumeProgressFill,
{ width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` }
]}
]}
/>
</View>
<Text style={styles.resumeTimeText}>
@ -71,19 +73,19 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
</View>
<View style={styles.resumeButtons}>
<TouchableOpacity
style={styles.resumeButton}
<TouchableOpacity
style={styles.resumeButton}
onPress={handleStartFromBeginning}
>
<Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} />
<Text style={styles.resumeButtonText}>Start Over</Text>
<Text style={styles.resumeButtonText}>{t('player_ui.start_over')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.resumeButton, styles.resumeFromButton]}
<TouchableOpacity
style={[styles.resumeButton, styles.resumeFromButton]}
onPress={handleResume}
>
<Ionicons name="play" size={16} color="white" style={styles.buttonIcon} />
<Text style={styles.resumeButtonText}>Resume</Text>
<Text style={styles.resumeButtonText}>{t('player_ui.resume')}</Text>
</TouchableOpacity>
</View>
</LinearGradient>

View file

@ -7,6 +7,7 @@ import Animated, {
SlideInRight,
SlideOutRight,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { Stream } from '../../../types/streams';
interface SourcesModalProps {
@ -57,6 +58,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
onSelectStream,
isChangingSource = false,
}) => {
const { t } = useTranslation();
const { width } = useWindowDimensions();
const MENU_WIDTH = Math.min(width * 0.85, 400);
@ -123,7 +125,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
alignItems: 'center'
}}>
<Text style={{ color: 'white', fontSize: 20, fontWeight: '700' }}>
Change Source
{t('player_ui.change_source')}
</Text>
</View>
@ -142,7 +144,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
}}>
<ActivityIndicator size="small" color="#22C55E" />
<Text style={{ color: '#22C55E', fontSize: 14, fontWeight: '600', marginLeft: 10 }}>
Switching source...
{t('player_ui.switching_source')}
</Text>
</View>
)}
@ -191,7 +193,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
fontSize: 14,
flex: 1,
}} numberOfLines={1}>
{stream.title || stream.name || `Stream ${index + 1}`}
{stream.title || stream.name || t('player_ui.stream', { number: index + 1 })}
</Text>
<QualityBadge quality={quality} />
</View>
@ -237,7 +239,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<MaterialIcons name="cloud-off" size={48} color="white" />
<Text style={{ color: 'white', marginTop: 16, textAlign: 'center', fontWeight: '600' }}>
No sources found
{t('player_ui.no_sources_found')}
</Text>
</View>
)}

View file

@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Animated, {
FadeIn,
FadeOut,
@ -55,6 +56,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
holdToSpeedValue,
setHoldToSpeedValue,
}) => {
const { t } = useTranslation();
const { width } = useWindowDimensions();
const speedPresets = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5];
const holdSpeedOptions = [1.0, 2.0, 3.0];
@ -85,7 +87,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
}}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20, alignItems: 'center' }}>
<Text style={{ color: 'white', fontSize: 16, fontWeight: '600' }}>Playback Speed</Text>
<Text style={{ color: 'white', fontSize: 16, fontWeight: '600' }}>{t('player_ui.playback_speed')}</Text>
</View>
{/* Speed Selection Row */}
@ -108,7 +110,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
onPress={() => setHoldToSpeedEnabled(!holdToSpeedEnabled)}
style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: holdToSpeedEnabled ? 15 : 0 }}
>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }}>On Hold</Text>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }}>{t('player_ui.on_hold')}</Text>
<View style={{
width: 34, height: 18, borderRadius: 10,
backgroundColor: holdToSpeedEnabled ? 'white' : 'rgba(255,255,255,0.2)',

View file

@ -9,6 +9,7 @@ import Animated, {
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes';
import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils';
@ -96,6 +97,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
selectedExternalSubtitleId,
onOpenSyncModal,
}) => {
const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isIos = Platform.OS === 'ios';
const isLandscape = width > height;
@ -108,7 +110,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
const isCompact = width < 360 || height < 640;
// Internal subtitle is active when a built-in track is selected AND not using custom/addon subtitles
const isUsingInternalSubtitle = selectedTextTrack >= 0 && !useCustomSubtitles;
// ExoPlayer has limited styling support - hide unsupported options when using ExoPlayer with internal subs
// ExoPlayer internal subtitles have limited styling support
const isExoPlayerInternal = useExoPlayer && isUsingInternalSubtitle;
const sectionPad = isCompact ? 12 : 16;
const chipPadH = isCompact ? 8 : 12;
@ -120,7 +122,9 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
const menuMaxHeight = height * 0.95;
React.useEffect(() => {
if (showSubtitleModal && !isLoadingSubtitleList && availableSubtitles.length === 0) fetchAvailableSubtitles();
if (showSubtitleModal && !isLoadingSubtitleList && availableSubtitles.length === 0) {
fetchAvailableSubtitles();
}
}, [showSubtitleModal]);
const handleClose = () => setShowSubtitleModal(false);
@ -151,14 +155,14 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', padding: 20, position: 'relative' }}>
<Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>Subtitles</Text>
<Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>{t('player_ui.subtitles')}</Text>
</View>
{/* Tab Bar */}
<View style={{ flexDirection: 'row', gap: 15, paddingHorizontal: 70, marginBottom: 20 }}>
<MorphingTab label="Built-in" isSelected={activeTab === 'built-in'} onPress={() => setActiveTab('built-in')} />
<MorphingTab label="Addons" isSelected={activeTab === 'addon'} onPress={() => setActiveTab('addon')} />
<MorphingTab label="Style" isSelected={activeTab === 'appearance'} onPress={() => setActiveTab('appearance')} />
<MorphingTab label={t('player_ui.built_in')} isSelected={activeTab === 'built-in'} onPress={() => setActiveTab('built-in')} />
<MorphingTab label={t('player_ui.addons')} isSelected={activeTab === 'addon'} onPress={() => setActiveTab('addon')} />
<MorphingTab label={t('player_ui.style')} isSelected={activeTab === 'appearance'} onPress={() => setActiveTab('appearance')} />
</View>
<ScrollView showsVerticalScrollIndicator={false}>
@ -174,7 +178,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}}
style={{ padding: 10, borderRadius: 12, backgroundColor: selectedTextTrack === -1 ? 'white' : 'rgba(242, 184, 181)' }}
>
<Text style={{ color: selectedTextTrack === -1 ? 'black' : 'rgba(96, 20, 16)', fontWeight: '600' }}>None</Text>
<Text style={{ color: selectedTextTrack === -1 ? 'black' : 'rgba(96, 20, 16)', fontWeight: '600' }}>{t('player_ui.none')}</Text>
</TouchableOpacity>
{ksTextTracks.map((track) => (
<TouchableOpacity
@ -199,7 +203,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{availableSubtitles.length === 0 ? (
<TouchableOpacity onPress={fetchAvailableSubtitles} style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<MaterialIcons name="cloud-download" size={32} color="white" />
<Text style={{ color: 'white', marginTop: 10 }}>Search Online Subtitles</Text>
<Text style={{ color: 'white', marginTop: 10 }}>{t('player_ui.search_online_subtitles')}</Text>
</TouchableOpacity>
) : (
availableSubtitles.map((sub) => (
@ -230,12 +234,13 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}>
<MaterialIcons name="visibility" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Preview</Text>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{t('player_ui.preview')}</Text>
</View>
<View style={{ height: previewHeight, justifyContent: 'flex-end' }}>
<View style={{ alignItems: subtitleAlign === 'center' ? 'center' : subtitleAlign === 'left' ? 'flex-start' : 'flex-end', marginBottom: Math.min(80, subtitleBottomOffset) }}>
<View style={{
backgroundColor: subtitleBackground ? `rgba(0,0,0,${subtitleBgOpacity})` : 'transparent',
// Built-in (KSPlayer internal) subtitles: force background off in UI preview.
backgroundColor: isUsingInternalSubtitle ? 'transparent' : (subtitleBackground ? `rgba(0,0,0,${subtitleBgOpacity})` : 'transparent'),
borderRadius: 8,
paddingHorizontal: isCompact ? 10 : 12,
paddingVertical: isCompact ? 6 : 8,
@ -257,12 +262,12 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
</View>
{/* Quick Presets - Hidden for ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
{/* Quick Presets - only for CustomSubtitles overlay */}
{!isUsingInternalSubtitle && (
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}>
<MaterialIcons name="star" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Quick Presets</Text>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{t('player_ui.quick_presets')}</Text>
</View>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<TouchableOpacity
@ -274,7 +279,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}
>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 11 : 12 }}>Default</Text>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.default')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
@ -282,7 +287,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,215,0,0.12)', borderWidth: 1, borderColor: 'rgba(255,215,0,0.35)' }}
>
<Text style={{ color: '#FFD700', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>Yellow</Text>
<Text style={{ color: '#FFD700', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.yellow')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
@ -290,7 +295,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(34,197,94,0.12)', borderWidth: 1, borderColor: 'rgba(34,197,94,0.35)' }}
>
<Text style={{ color: '#22C55E', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>High Contrast</Text>
<Text style={{ color: '#22C55E', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.high_contrast')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
@ -298,7 +303,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(59,130,246,0.12)', borderWidth: 1, borderColor: 'rgba(59,130,246,0.35)' }}
>
<Text style={{ color: '#3B82F6', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>Large</Text>
<Text style={{ color: '#3B82F6', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.large')}</Text>
</TouchableOpacity>
</View>
</View>
@ -308,12 +313,12 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}>
<MaterialIcons name="tune" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Core</Text>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{t('player_ui.core')}</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="format-size" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>Font Size</Text>
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>{t('player_ui.font_size')}</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<TouchableOpacity onPress={decreaseSubtitleSize} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', justifyContent: 'center', alignItems: 'center' }}>
@ -327,12 +332,12 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity>
</View>
</View>
{/* Show Background - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
{/* Background is only for CustomSubtitles (external/addon). Built-in subtitles force background OFF. */}
{!isUsingInternalSubtitle && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="layers" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>Show Background</Text>
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>{t('player_ui.show_background')}</Text>
</View>
<TouchableOpacity
style={{ width: isCompact ? 48 : 54, height: isCompact ? 28 : 30, backgroundColor: subtitleBackground ? 'white' : 'rgba(255,255,255,0.25)', borderRadius: 15, justifyContent: 'center', alignItems: subtitleBackground ? 'flex-end' : 'flex-start', paddingHorizontal: 3 }}
@ -344,30 +349,29 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
)}
</View>
{/* Advanced controls - Limited for ExoPlayer */}
{/* Advanced controls */}
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="build" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{isExoPlayerInternal ? 'Position' : 'Advanced'}</Text>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{isUsingInternalSubtitle ? t('player_ui.position') : t('player_ui.advanced')}</Text>
</View>
{/* Text Color - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'white', marginLeft: 8, fontWeight: '600' }}>Text Color</Text>
</View>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' }}>
{['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88', '#9b59b6', '#f97316'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleTextColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleTextColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
))}
</View>
{/* Text Color - supported for MPV built-in, and for CustomSubtitles */}
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'white', marginLeft: 8, fontWeight: '600' }}>{t('player_ui.text_color')}</Text>
</View>
)}
{/* Align - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' }}>
{['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88', '#9b59b6', '#f97316'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleTextColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleTextColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
))}
</View>
</View>
{/* Align - only supported for CustomSubtitles overlay */}
{!isUsingInternalSubtitle && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Align</Text>
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.align')}</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{([{ key: 'left', icon: 'format-align-left' }, { key: 'center', icon: 'format-align-center' }, { key: 'right', icon: 'format-align-right' }] as const).map(a => (
<TouchableOpacity key={a.key} onPress={() => setSubtitleAlign(a.key)} style={{ paddingHorizontal: isCompact ? 8 : 10, paddingVertical: isCompact ? 4 : 6, borderRadius: 8, backgroundColor: subtitleAlign === a.key ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}>
@ -378,7 +382,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
)}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Bottom Offset</Text>
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.bottom_offset')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleBottomOffset(Math.max(0, subtitleBottomOffset - 5))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="keyboard-arrow-down" color="#fff" size={20} />
@ -391,10 +395,10 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity>
</View>
</View>
{/* Background Opacity - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
{/* Background Opacity (CustomSubtitles only) */}
{!isUsingInternalSubtitle && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Background Opacity</Text>
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.background_opacity')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleBgOpacity(Math.max(0, +(subtitleBgOpacity - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
@ -410,16 +414,28 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
)}
{!isUsingInternalSubtitle && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text>
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.text_shadow')}</Text>
<TouchableOpacity onPress={() => setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}>
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleTextShadow ? 'On' : 'Off'}</Text>
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleTextShadow ? t('player_ui.on') : t('player_ui.off')}</Text>
</TouchableOpacity>
</View>
)}
{!isUsingInternalSubtitle && (
{/* Outline controls (now supported for ExoPlayer internal via native patch) */}
{isUsingInternalSubtitle ? (
// KSPlayer built-in subtitles: only expose an Outline on/off toggle (no width control).
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.outline')}</Text>
<TouchableOpacity
onPress={() => setSubtitleOutline(!subtitleOutline)}
style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleOutline ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}
>
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleOutline ? t('player_ui.on') : t('player_ui.off')}</Text>
</TouchableOpacity>
</View>
) : (
<>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Color</Text>
<Text style={{ color: 'white' }}>{t('player_ui.outline_color')}</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
@ -427,7 +443,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Width</Text>
<Text style={{ color: 'white' }}>{t('player_ui.outline_width')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
@ -445,7 +461,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{!isUsingInternalSubtitle && (
<View style={{ flexDirection: isCompact ? 'column' : 'row', justifyContent: 'space-between', gap: 12 }}>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Letter Spacing</Text>
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.letter_spacing')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
@ -459,7 +475,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Line Height</Text>
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.line_height')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
@ -478,7 +494,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{!isExoPlayerInternal && (
<View style={{ marginTop: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Timing Offset (s)</Text>
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.timing_offset')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleOffsetSec(+(subtitleOffsetSec - 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
@ -511,10 +527,10 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}}
>
<MaterialIcons name="sync" color="#fff" size={18} style={{ marginRight: 8 }} />
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 14 }}>Visual Sync</Text>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 14 }}>{t('player_ui.visual_sync')}</Text>
</TouchableOpacity>
)}
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>Nudge subtitles earlier (-) or later (+) to sync if needed.</Text>
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>{t('player_ui.timing_hint')}</Text>
</View>
)}
<View style={{ alignItems: 'flex-end', marginTop: 8 }}>
@ -527,7 +543,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.1)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}
>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 12 : 14 }}>Reset to defaults</Text>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 12 : 14 }}>{t('player_ui.reset_defaults')}</Text>
</TouchableOpacity>
</View>
</View>

View file

@ -74,6 +74,7 @@ export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
const hasShownRef = useRef(false);
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const fadeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const prevShouldShowRef = useRef<boolean>(false);
// Animation values
const lineHeight = useSharedValue(0);
@ -130,9 +131,51 @@ export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
fetchData();
}, [imdbId, type, season, episode]);
// Trigger animation when shouldShow becomes true
// Handle show/hide based on shouldShow (controls visibility)
useEffect(() => {
if (shouldShow && warnings.length > 0 && !hasShownRef.current) {
// When controls are shown (shouldShow becomes false), immediately hide overlay
if (!shouldShow && isVisible) {
// Clear any pending timeouts
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
if (fadeTimeoutRef.current) {
clearTimeout(fadeTimeoutRef.current);
fadeTimeoutRef.current = null;
}
// Immediately hide overlay with quick fade out
const count = warnings.length;
// FADE OUT: Items fade out in reverse order (bottom to top)
for (let i = count - 1; i >= 0; i--) {
const reverseDelay = (count - 1 - i) * 40;
itemOpacities[i].value = withDelay(
reverseDelay,
withTiming(0, { duration: 100 })
);
}
// Line shrinks after items are gone
const lineDelay = count * 40 + 50;
lineHeight.value = withDelay(lineDelay, withTiming(0, {
duration: 200,
easing: Easing.in(Easing.cubic),
}));
// Container fades out last
containerOpacity.value = withDelay(lineDelay + 100, withTiming(0, { duration: 150 }));
// Set invisible after all animations complete
fadeTimeoutRef.current = setTimeout(() => {
setIsVisible(false);
// Don't reset hasShownRef here - only reset on content change
}, lineDelay + 300);
}
// When controls are hidden (shouldShow becomes true), show overlay if not already shown for this content
// Only show if transitioning from false to true (controls just hidden)
if (shouldShow && !prevShouldShowRef.current && warnings.length > 0 && !hasShownRef.current) {
hasShownRef.current = true;
setIsVisible(true);
@ -182,10 +225,14 @@ export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
// Set invisible after all animations complete
fadeTimeoutRef.current = setTimeout(() => {
setIsVisible(false);
// Don't reset hasShownRef - only reset on content change
}, lineDelay + 500);
}, 5000);
}
}, [shouldShow, warnings.length]);
// Update previous shouldShow value
prevShouldShowRef.current = shouldShow;
}, [shouldShow, isVisible, warnings.length]);
// Cleanup on unmount
useEffect(() => {
@ -198,6 +245,7 @@ export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
// Reset when content changes
useEffect(() => {
hasShownRef.current = false;
prevShouldShowRef.current = false;
setWarnings([]);
setIsVisible(false);
lineHeight.value = 0;

View file

@ -10,7 +10,7 @@ import Animated, {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { introService, IntroTimestamps } from '../../../services/introService';
import { introService, SkipInterval, SkipType } from '../../../services/introService';
import { useTheme } from '../../../contexts/ThemeContext';
import { logger } from '../../../utils/logger';
@ -19,6 +19,8 @@ interface SkipIntroButtonProps {
type: 'movie' | 'series' | string;
season?: number;
episode?: number;
malId?: string;
kitsuId?: string;
currentTime: number;
onSkip: (endTime: number) => void;
controlsVisible?: boolean;
@ -30,6 +32,8 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
type,
season,
episode,
malId,
kitsuId,
currentTime,
onSkip,
controlsVisible = false,
@ -37,10 +41,15 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
}) => {
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const [introData, setIntroData] = useState<IntroTimestamps | null>(null);
// State
const [skipIntervals, setSkipIntervals] = useState<SkipInterval[]>([]);
const [currentInterval, setCurrentInterval] = useState<SkipInterval | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [hasSkipped, setHasSkipped] = useState(false);
const [hasSkippedCurrent, setHasSkippedCurrent] = useState(false);
const [autoHidden, setAutoHidden] = useState(false);
// Refs
const fetchedRef = useRef(false);
const lastEpisodeRef = useRef<string>('');
const autoHideTimerRef = useRef<NodeJS.Timeout | null>(null);
@ -50,14 +59,13 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
const scale = useSharedValue(0.8);
const translateY = useSharedValue(0);
// Fetch intro data when episode changes
// Fetch skip data when episode changes
useEffect(() => {
const episodeKey = `${imdbId}-${season}-${episode}`;
const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`;
// Skip if not a series or missing required data
if (type !== 'series' || !imdbId || !season || !episode) {
logger.log(`[SkipIntroButton] Skipping fetch - type: ${type}, imdbId: ${imdbId}, season: ${season}, episode: ${episode}`);
setIntroData(null);
// Skip if not a series or missing required data (though MAL/Kitsu ID might be enough for some cases, usually need season/ep)
if (type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) {
setSkipIntervals([]);
fetchedRef.current = false;
return;
}
@ -69,45 +77,76 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
lastEpisodeRef.current = episodeKey;
fetchedRef.current = true;
setHasSkipped(false);
setHasSkippedCurrent(false);
setAutoHidden(false);
setSkipIntervals([]);
const fetchIntroData = async () => {
logger.log(`[SkipIntroButton] Fetching intro data for ${imdbId} S${season}E${episode}...`);
const fetchSkipData = async () => {
logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`);
try {
const data = await introService.getIntroTimestamps(imdbId, season, episode);
setIntroData(data);
const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId);
setSkipIntervals(intervals);
if (data) {
logger.log(`[SkipIntroButton] ✓ Found intro: ${data.start_sec}s - ${data.end_sec}s (confidence: ${data.confidence})`);
if (intervals.length > 0) {
logger.log(`[SkipIntroButton] ✓ Found ${intervals.length} skip intervals:`, intervals);
} else {
logger.log(`[SkipIntroButton] ✗ No intro data available for this episode`);
logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`);
}
} catch (error) {
logger.error('[SkipIntroButton] Error fetching intro data:', error);
setIntroData(null);
logger.error('[SkipIntroButton] Error fetching skip data:', error);
setSkipIntervals([]);
}
};
fetchIntroData();
}, [imdbId, type, season, episode]);
fetchSkipData();
}, [imdbId, type, season, episode, malId, kitsuId]);
// Determine if button should show based on current playback position
// Determine active interval based on current playback position
useEffect(() => {
if (skipIntervals.length === 0) {
setCurrentInterval(null);
return;
}
// Find an interval that contains the current time
const active = skipIntervals.find(
interval => currentTime >= interval.startTime && currentTime < (interval.endTime - 0.5)
);
if (active) {
// If we found a new active interval that is different from the previous one
if (!currentInterval ||
active.startTime !== currentInterval.startTime ||
active.type !== currentInterval.type) {
logger.log(`[SkipIntroButton] Entering interval: ${active.type} (${active.startTime}-${active.endTime})`);
setCurrentInterval(active);
setHasSkippedCurrent(false); // Reset skipped state for new interval
setAutoHidden(false); // Reset auto-hide for new interval
}
} else {
// No active interval
if (currentInterval) {
logger.log('[SkipIntroButton] Exiting interval');
setCurrentInterval(null);
}
}
}, [currentTime, skipIntervals]);
// Determine if button should show
const shouldShowButton = useCallback(() => {
if (!introData || hasSkipped) return false;
// Show when within intro range, with a small buffer at the end
const inIntroRange = currentTime >= introData.start_sec && currentTime < (introData.end_sec - 0.5);
if (!currentInterval || hasSkippedCurrent) return false;
// If auto-hidden, only show when controls are visible
if (autoHidden && !controlsVisible) return false;
return inIntroRange;
}, [introData, currentTime, hasSkipped, autoHidden, controlsVisible]);
return true;
}, [currentInterval, hasSkippedCurrent, autoHidden, controlsVisible]);
// Handle visibility animations
useEffect(() => {
const shouldShow = shouldShowButton();
if (shouldShow && !isVisible) {
logger.log(`[SkipIntroButton] Showing button - currentTime: ${currentTime.toFixed(1)}s, intro: ${introData?.start_sec}s - ${introData?.end_sec}s`);
setIsVisible(true);
opacity.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.cubic) });
scale.value = withSpring(1, { damping: 15, stiffness: 150 });
@ -115,8 +154,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
// Start 15-second auto-hide timer
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
autoHideTimerRef.current = setTimeout(() => {
if (!hasSkipped) {
logger.log('[SkipIntroButton] Auto-hiding after 15 seconds');
if (!hasSkippedCurrent) {
setAutoHidden(true);
opacity.value = withTiming(0, { duration: 200 });
scale.value = withTiming(0.8, { duration: 200 });
@ -124,25 +162,20 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
}
}, 15000);
} else if (!shouldShow && isVisible) {
logger.log(`[SkipIntroButton] Hiding button - currentTime: ${currentTime.toFixed(1)}s, hasSkipped: ${hasSkipped}`);
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
opacity.value = withTiming(0, { duration: 200 });
scale.value = withTiming(0.8, { duration: 200 });
// Delay hiding to allow animation to complete
setTimeout(() => setIsVisible(false), 250);
}
}, [shouldShowButton, isVisible]);
}, [shouldShowButton, isVisible, hasSkippedCurrent]);
// Re-show when controls become visible (if still in intro range and was auto-hidden)
// Re-show when controls become visible (if still in interval and was auto-hidden)
useEffect(() => {
if (controlsVisible && autoHidden && introData && !hasSkipped) {
const inIntroRange = currentTime >= introData.start_sec && currentTime < (introData.end_sec - 0.5);
if (inIntroRange) {
logger.log('[SkipIntroButton] Re-showing button because controls became visible');
setAutoHidden(false);
}
if (controlsVisible && autoHidden && currentInterval && !hasSkippedCurrent) {
setAutoHidden(false);
}
}, [controlsVisible, autoHidden, introData, hasSkipped, currentTime]);
}, [controlsVisible, autoHidden, currentInterval, hasSkippedCurrent]);
// Cleanup timer on unmount
useEffect(() => {
@ -162,12 +195,32 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
// Handle skip action
const handleSkip = useCallback(() => {
if (!introData) return;
if (!currentInterval) return;
logger.log(`[SkipIntroButton] User pressed Skip Intro - seeking to ${introData.end_sec}s (from ${currentTime.toFixed(1)}s)`);
setHasSkipped(true);
onSkip(introData.end_sec);
}, [introData, onSkip, currentTime]);
logger.log(`[SkipIntroButton] User pressed Skip - seeking to ${currentInterval.endTime}s`);
setHasSkippedCurrent(true);
onSkip(currentInterval.endTime);
}, [currentInterval, onSkip]);
// Get display text based on skip type
const getButtonText = () => {
if (!currentInterval) return 'Skip';
switch (currentInterval.type) {
case 'op':
case 'mixed-op':
case 'intro':
return 'Skip Intro';
case 'ed':
case 'mixed-ed':
case 'outro':
return 'Skip Ending';
case 'recap':
return 'Skip Recap';
default:
return 'Skip';
}
};
// Animated styles
const containerStyle = useAnimatedStyle(() => ({
@ -175,8 +228,8 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
transform: [{ scale: scale.value }, { translateY: translateY.value }],
}));
// Don't render if not visible or no intro data
if (!isVisible || !introData) {
// Don't render if not visible
if (!isVisible) {
return null;
}
@ -208,7 +261,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
color="#FFFFFF"
style={styles.icon}
/>
<Text style={styles.text}>Skip Intro</Text>
<Text style={styles.text}>{getButtonText()}</Text>
<Animated.View
style={[
styles.accentBar,

View file

@ -0,0 +1,155 @@
import React, { useMemo } from 'react';
import { View, Text, FlatList } from 'react-native';
import { useTranslation } from 'react-i18next';
import { AddonSearchResults, StreamingContent } from '../../services/catalogService';
import { SearchResultItem } from './SearchResultItem';
import { isTablet, isLargeTablet, isTV } from './searchUtils';
import { searchStyles as styles } from './searchStyles';
interface AddonSectionProps {
addonGroup: AddonSearchResults;
addonIndex: number;
onItemPress: (item: StreamingContent) => void;
onItemLongPress: (item: StreamingContent) => void;
currentTheme: any;
}
export const AddonSection = React.memo(({
addonGroup,
addonIndex,
onItemPress,
onItemLongPress,
currentTheme,
}: AddonSectionProps) => {
const { t } = useTranslation();
const movieResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'movie'),
[addonGroup.results]
);
const seriesResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'series'),
[addonGroup.results]
);
const otherResults = useMemo(() =>
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
[addonGroup.results]
);
return (
<View>
{/* Addon Header */}
<View style={styles.addonHeaderContainer}>
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
{addonGroup.addonName}
</Text>
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>
{addonGroup.results.length}
</Text>
</View>
</View>
{/* Movies */}
{movieResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{t('search.movies')} ({movieResults.length})
</Text>
<FlatList
data={movieResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
onPress={onItemPress}
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
{/* TV Shows */}
{seriesResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{t('search.tv_shows')} ({seriesResults.length})
</Text>
<FlatList
data={seriesResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
onPress={onItemPress}
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
{/* Other types */}
{otherResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
</Text>
<FlatList
data={otherResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
onPress={onItemPress}
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
</View>
);
}, (prev, next) => {
// Only re-render if this section's reference changed
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
});
AddonSection.displayName = 'AddonSection';

View file

@ -0,0 +1,275 @@
import React, { useMemo, useCallback, forwardRef, RefObject, useState } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons';
import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
import { DiscoverCatalog } from './searchUtils';
import { searchStyles as styles } from './searchStyles';
import { useBottomSheetBackHandler } from '../../hooks/useBottomSheetBackHandler';
interface DiscoverBottomSheetsProps {
typeSheetRef: RefObject<BottomSheetModal>;
catalogSheetRef: RefObject<BottomSheetModal>;
genreSheetRef: RefObject<BottomSheetModal>;
selectedDiscoverType: 'movie' | 'series';
selectedCatalog: DiscoverCatalog | null;
selectedDiscoverGenre: string | null;
filteredCatalogs: DiscoverCatalog[];
availableGenres: string[];
onTypeSelect: (type: 'movie' | 'series') => void;
onCatalogSelect: (catalog: DiscoverCatalog) => void;
onGenreSelect: (genre: string | null) => void;
currentTheme: any;
}
export const DiscoverBottomSheets = ({
typeSheetRef,
catalogSheetRef,
genreSheetRef,
selectedDiscoverType,
selectedCatalog,
selectedDiscoverGenre,
filteredCatalogs,
availableGenres,
onTypeSelect,
onCatalogSelect,
onGenreSelect,
currentTheme,
}: DiscoverBottomSheetsProps) => {
const { t } = useTranslation();
const typeSnapPoints = useMemo(() => ['25%'], []);
const catalogSnapPoints = useMemo(() => ['50%'], []);
const genreSnapPoints = useMemo(() => ['50%'], []);
const [activeBottomSheetRef, setActiveBottomSheetRef] = useState(null);
const {onDismiss, onChange} = useBottomSheetBackHandler();
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
return (
<>
{/* Catalog Selection Bottom Sheet */}
<BottomSheetModal
ref={catalogSheetRef}
index={0}
snapPoints={catalogSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
}}
onDismiss={onDismiss(catalogSheetRef)}
onChange={onChange(catalogSheetRef)}
>
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
{t('search.select_catalog')}
</Text>
<TouchableOpacity onPress={() => catalogSheetRef.current?.dismiss()}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
<BottomSheetScrollView
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
{filteredCatalogs.map((catalog, index) => (
<TouchableOpacity
key={`${catalog.addonId}-${catalog.catalogId}-${index}`}
style={[
styles.bottomSheetItem,
selectedCatalog?.catalogId === catalog.catalogId &&
selectedCatalog?.addonId === catalog.addonId &&
styles.bottomSheetItemSelected
]}
onPress={() => onCatalogSelect(catalog)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{catalog.catalogName}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{catalog.addonName}
</Text>
</View>
{selectedCatalog?.catalogId === catalog.catalogId &&
selectedCatalog?.addonId === catalog.addonId && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
))}
</BottomSheetScrollView>
</BottomSheetModal>
{/* Genre Selection Bottom Sheet */}
<BottomSheetModal
ref={genreSheetRef}
index={0}
snapPoints={genreSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
android_keyboardInputMode="adjustResize"
animateOnMount={true}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
}}
onDismiss={onDismiss(genreSheetRef)}
onChange={onChange(genreSheetRef)}
>
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
{t('search.select_genre')}
</Text>
<TouchableOpacity onPress={() => genreSheetRef.current?.dismiss()}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
<BottomSheetScrollView
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
{/* All Genres option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
!selectedDiscoverGenre && styles.bottomSheetItemSelected
]}
onPress={() => onGenreSelect(null)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.all_genres')}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.show_all_content')}
</Text>
</View>
{!selectedDiscoverGenre && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
{/* Genre options */}
{availableGenres.map((genre, index) => (
<TouchableOpacity
key={`${genre}-${index}`}
style={[
styles.bottomSheetItem,
selectedDiscoverGenre === genre && styles.bottomSheetItemSelected
]}
onPress={() => onGenreSelect(genre)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{genre}
</Text>
</View>
{selectedDiscoverGenre === genre && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
))}
</BottomSheetScrollView>
</BottomSheetModal>
{/* Type Selection Bottom Sheet */}
<BottomSheetModal
ref={typeSheetRef}
index={0}
snapPoints={typeSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
}}
onDismiss={onDismiss(typeSheetRef)}
onChange={onChange(typeSheetRef)}
>
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
{t('search.select_type')}
</Text>
<TouchableOpacity onPress={() => typeSheetRef.current?.dismiss()}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
<BottomSheetScrollView
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
{/* Movies option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
selectedDiscoverType === 'movie' && styles.bottomSheetItemSelected
]}
onPress={() => onTypeSelect('movie')}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.movies')}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.browse_movies')}
</Text>
</View>
{selectedDiscoverType === 'movie' && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
{/* TV Shows option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
selectedDiscoverType === 'series' && styles.bottomSheetItemSelected
]}
onPress={() => onTypeSelect('series')}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.tv_shows')}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.browse_tv')}
</Text>
</View>
{selectedDiscoverType === 'series' && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</BottomSheetScrollView>
</BottomSheetModal>
</>
);
};
DiscoverBottomSheets.displayName = 'DiscoverBottomSheets';

View file

@ -0,0 +1,159 @@
import React, { useMemo, useEffect, useState } from 'react';
import { View, Text, TouchableOpacity, Dimensions, DeviceEventEmitter } from 'react-native';
import { MaterialIcons, Feather } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image';
import { StreamingContent, catalogService } from '../../services/catalogService';
import { mmkvStorage } from '../../services/mmkvStorage';
import { useSettings } from '../../hooks/useSettings';
import {
isTablet,
isLargeTablet,
isTV,
HORIZONTAL_ITEM_WIDTH,
HORIZONTAL_POSTER_HEIGHT,
PLACEHOLDER_POSTER,
} from './searchUtils';
import { searchStyles as styles } from './searchStyles';
const { width } = Dimensions.get('window');
interface DiscoverResultItemProps {
item: StreamingContent;
index: number;
navigation: any;
setSelectedItem: (item: StreamingContent) => void;
setMenuVisible: (visible: boolean) => void;
currentTheme: any;
isGrid?: boolean;
}
export const DiscoverResultItem = React.memo(({
item,
index,
navigation,
setSelectedItem,
setMenuVisible,
currentTheme,
isGrid = false
}: DiscoverResultItemProps) => {
const { settings } = useSettings();
const [inLibrary, setInLibrary] = useState(!!item.inLibrary);
const [watched, setWatched] = useState(false);
// Calculate dimensions based on poster shape
const { itemWidth, aspectRatio } = useMemo(() => {
const shape = item.posterShape || 'poster';
const baseHeight = HORIZONTAL_POSTER_HEIGHT;
let w = HORIZONTAL_ITEM_WIDTH;
let r = 2 / 3;
if (isGrid) {
// Grid Calculation: (Window Width - Padding) / Columns
const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3;
const totalPadding = 32;
const totalGap = 12 * (Math.max(3, columns) - 1);
const availableWidth = width - totalPadding - totalGap;
w = availableWidth / Math.max(3, columns);
} else {
if (shape === 'landscape') {
r = 16 / 9;
w = baseHeight * r;
} else if (shape === 'square') {
r = 1;
w = baseHeight;
}
}
return { itemWidth: w, aspectRatio: r };
}, [item.posterShape, isGrid]);
useEffect(() => {
const updateWatched = () => {
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
};
updateWatched();
const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched);
return () => sub.remove();
}, [item.id, item.type]);
useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type);
setInLibrary(!!found);
});
return () => unsubscribe();
}, [item.id, item.type]);
return (
<TouchableOpacity
style={[
styles.horizontalItem,
{ width: itemWidth },
isGrid && styles.discoverGridItem
]}
onPress={() => {
navigation.navigate('Metadata', {
id: item.id,
type: item.type,
addonId: item.addonId
});
}}
onLongPress={() => {
setSelectedItem(item);
setMenuVisible(true);
}}
delayLongPress={300}
activeOpacity={0.7}
>
<View style={[styles.horizontalItemPosterContainer, {
width: itemWidth,
height: undefined,
aspectRatio: aspectRatio,
backgroundColor: currentTheme.colors.darkBackground,
borderRadius: settings.posterBorderRadius ?? 12,
}]}>
<FastImage
source={{
uri: item.poster || PLACEHOLDER_POSTER,
priority: FastImage.priority.low,
cache: FastImage.cacheControl.immutable,
}}
style={[styles.horizontalItemPoster, { borderRadius: settings.posterBorderRadius ?? 12 }]}
resizeMode={FastImage.resizeMode.cover}
/>
{/* Bookmark icon */}
{inLibrary && (
<View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }]}>
<Feather name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
{/* Watched icon */}
{watched && (
<View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }]}>
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
</View>
<Text
style={[
styles.horizontalItemTitle,
{
color: currentTheme.colors.white,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 14,
lineHeight: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 18,
}
]}
numberOfLines={2}
>
{item.name}
</Text>
{item.year && (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray, fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 12 }]}>
{item.year}
</Text>
)}
</TouchableOpacity>
);
});
DiscoverResultItem.displayName = 'DiscoverResultItem';

View file

@ -0,0 +1,198 @@
import React from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
ActivityIndicator,
FlatList,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons';
import { StreamingContent } from '../../services/catalogService';
import { DiscoverCatalog, isTablet, isLargeTablet, isTV } from './searchUtils';
import { DiscoverResultItem } from './DiscoverResultItem';
import { searchStyles as styles } from './searchStyles';
import { BottomSheetModal } from '@gorhom/bottom-sheet';
interface DiscoverSectionProps {
discoverLoading: boolean;
discoverInitialized: boolean;
discoverResults: StreamingContent[];
pendingDiscoverResults: StreamingContent[];
loadingMore: boolean;
selectedCatalog: DiscoverCatalog | null;
selectedDiscoverType: 'movie' | 'series';
selectedDiscoverGenre: string | null;
availableGenres: string[];
typeSheetRef: React.RefObject<BottomSheetModal>;
catalogSheetRef: React.RefObject<BottomSheetModal>;
genreSheetRef: React.RefObject<BottomSheetModal>;
handleShowMore: () => void;
navigation: any;
setSelectedItem: (item: StreamingContent) => void;
setMenuVisible: (visible: boolean) => void;
currentTheme: any;
}
export const DiscoverSection = ({
discoverLoading,
discoverInitialized,
discoverResults,
pendingDiscoverResults,
loadingMore,
selectedCatalog,
selectedDiscoverType,
selectedDiscoverGenre,
availableGenres,
typeSheetRef,
catalogSheetRef,
genreSheetRef,
handleShowMore,
navigation,
setSelectedItem,
setMenuVisible,
currentTheme,
}: DiscoverSectionProps) => {
const { t } = useTranslation();
return (
<View style={styles.discoverContainer}>
{/* Section Header */}
<View style={styles.discoverHeader}>
<Text style={[styles.discoverTitle, { color: currentTheme.colors.white }]}>
{t('search.discover')}
</Text>
</View>
{/* Filter Chips Row */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.discoverChipsScroll}
contentContainerStyle={styles.discoverChipsContent}
>
{/* Type Selector Chip (Movie/TV Show) */}
<TouchableOpacity
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => typeSheetRef.current?.present()}
>
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedDiscoverType === 'movie' ? t('search.movies') : t('search.tv_shows')}
</Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
{/* Catalog Selector Chip */}
<TouchableOpacity
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => catalogSheetRef.current?.present()}
>
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedCatalog ? selectedCatalog.catalogName : t('search.select_catalog')}
</Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
{/* Genre Selector Chip - only show if catalog has genres */}
{availableGenres.length > 0 && (
<TouchableOpacity
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => genreSheetRef.current?.present()}
>
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedDiscoverGenre || t('search.all_genres')}
</Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
)}
</ScrollView>
{/* Selected filters summary */}
{selectedCatalog && (
<View style={styles.discoverFilterSummary}>
<Text style={[styles.discoverFilterSummaryText, { color: currentTheme.colors.lightGray }]}>
{selectedCatalog.addonName} {selectedCatalog.type === 'movie' ? t('search.movies') : t('search.tv_shows')}
{selectedDiscoverGenre ? `${selectedDiscoverGenre}` : ''}
</Text>
</View>
)}
{/* Discover Results */}
{discoverLoading ? (
<View style={styles.discoverLoadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.discoverLoadingText, { color: currentTheme.colors.lightGray }]}>
{t('search.discovering')}
</Text>
</View>
) : discoverResults.length > 0 ? (
<FlatList
data={discoverResults}
keyExtractor={(item, index) => `discover-${item.id}-${index}`}
numColumns={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
key={isTV ? 'tv-6' : isLargeTablet ? 'ltab-5' : isTablet ? 'tab-4' : 'phone-3'}
columnWrapperStyle={styles.discoverGridRow}
contentContainerStyle={styles.discoverGridContent}
renderItem={({ item, index }) => (
<DiscoverResultItem
key={`discover-${item.id}-${index}`}
item={item}
index={index}
navigation={navigation}
setSelectedItem={setSelectedItem}
setMenuVisible={setMenuVisible}
currentTheme={currentTheme}
isGrid={true}
/>
)}
initialNumToRender={9}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={true}
scrollEnabled={false}
ListFooterComponent={
pendingDiscoverResults.length > 0 ? (
<TouchableOpacity
style={styles.showMoreButton}
onPress={handleShowMore}
activeOpacity={0.7}
>
<Text style={[styles.showMoreButtonText, { color: currentTheme.colors.white }]}>
{t('search.show_more', { count: pendingDiscoverResults.length })}
</Text>
<MaterialIcons name="expand-more" size={20} color={currentTheme.colors.white} />
</TouchableOpacity>
) : loadingMore ? (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
) : null
}
/>
) : discoverInitialized && !discoverLoading && selectedCatalog ? (
<View style={styles.discoverEmptyContainer}>
<MaterialIcons name="movie-filter" size={48} color={currentTheme.colors.lightGray} />
<Text style={[styles.discoverEmptyText, { color: currentTheme.colors.lightGray }]}>
{t('search.no_content_found')}
</Text>
<Text style={[styles.discoverEmptySubtext, { color: currentTheme.colors.mediumGray }]}>
{t('search.try_different')}
</Text>
</View>
) : !selectedCatalog && discoverInitialized ? (
<View style={styles.discoverEmptyContainer}>
<MaterialIcons name="touch-app" size={48} color={currentTheme.colors.lightGray} />
<Text style={[styles.discoverEmptyText, { color: currentTheme.colors.lightGray }]}>
{t('search.select_catalog_desc')}
</Text>
<Text style={[styles.discoverEmptySubtext, { color: currentTheme.colors.mediumGray }]}>
{t('search.tap_catalog_desc')}
</Text>
</View>
) : null}
</View>
);
};
DiscoverSection.displayName = 'DiscoverSection';

View file

@ -190,7 +190,15 @@ const styles = StyleSheet.create({
fontSize: 12,
marginTop: 2,
},
libraryBadge: {},
libraryBadge: {
position: 'absolute',
top: 8,
left: 8,
borderRadius: 8,
padding: 4,
zIndex: 2,
backgroundColor: 'transparent',
},
watchedIndicator: {},
});

View file

@ -1,6 +1,11 @@
// Search components barrel export
export * from './searchUtils';
export { searchStyles } from './searchStyles';
export { SearchSkeletonLoader } from './SearchSkeletonLoader';
export { SearchAnimation } from './SearchAnimation';
export { SearchResultItem } from './SearchResultItem';
export { RecentSearches } from './RecentSearches';
export { DiscoverResultItem } from './DiscoverResultItem';
export { AddonSection } from './AddonSection';
export { DiscoverSection } from './DiscoverSection';
export { DiscoverBottomSheets } from './DiscoverBottomSheets';

View file

@ -0,0 +1,531 @@
import { StyleSheet, Platform, Dimensions } from 'react-native';
import { isTablet, isTV, isLargeTablet, HORIZONTAL_ITEM_WIDTH, HORIZONTAL_POSTER_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT } from './searchUtils';
const { width } = Dimensions.get('window');
export const searchStyles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
flex: 1,
paddingTop: 0,
},
searchBarContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
height: 48,
},
searchBarWrapper: {
flex: 1,
height: 48,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
paddingHorizontal: 16,
height: '100%',
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
searchIcon: {
marginRight: 12,
},
searchInput: {
flex: 1,
fontSize: 16,
height: '100%',
},
clearButton: {
padding: 4,
},
scrollView: {
flex: 1,
},
scrollViewContent: {
paddingBottom: isTablet ? 120 : 100,
paddingHorizontal: 0,
},
carouselContainer: {
marginBottom: isTablet ? 32 : 24,
},
carouselTitle: {
fontSize: isTablet ? 20 : 18,
fontWeight: '700',
marginBottom: isTablet ? 16 : 12,
paddingHorizontal: 16,
},
carouselSubtitle: {
fontSize: isTablet ? 16 : 14,
fontWeight: '600',
marginBottom: isTablet ? 12 : 8,
paddingHorizontal: 16,
},
addonHeaderContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: isTablet ? 16 : 12,
marginTop: isTablet ? 24 : 16,
marginBottom: isTablet ? 8 : 4,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
addonHeaderIcon: {
// removed icon
},
addonHeaderText: {
fontSize: isTablet ? 18 : 16,
fontWeight: '700',
flex: 1,
},
addonHeaderBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
addonHeaderBadgeText: {
fontSize: isTablet ? 12 : 11,
fontWeight: '600',
},
horizontalListContent: {
paddingHorizontal: 16,
},
horizontalItem: {
width: HORIZONTAL_ITEM_WIDTH,
marginRight: 16,
},
horizontalItemPosterContainer: {
width: HORIZONTAL_ITEM_WIDTH,
height: HORIZONTAL_POSTER_HEIGHT,
borderRadius: 12,
overflow: 'hidden',
marginBottom: 8,
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
elevation: Platform.OS === 'android' ? 1 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
},
horizontalItemPoster: {
width: '100%',
height: '100%',
},
horizontalItemTitle: {
fontSize: isTablet ? 12 : 14,
fontWeight: '600',
lineHeight: isTablet ? 16 : 18,
textAlign: 'left',
},
yearText: {
fontSize: isTablet ? 10 : 12,
marginTop: 2,
},
recentSearchesContainer: {
paddingHorizontal: 16,
paddingBottom: isTablet ? 24 : 16,
paddingTop: isTablet ? 12 : 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.05)',
marginBottom: isTablet ? 16 : 8,
},
recentSearchItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: isTablet ? 12 : 10,
paddingHorizontal: 16,
marginVertical: 1,
},
recentSearchIcon: {
marginRight: 12,
},
recentSearchText: {
fontSize: 16,
flex: 1,
},
recentSearchDeleteButton: {
padding: 4,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: isTablet ? 64 : 32,
paddingBottom: isTablet ? 120 : 100,
},
emptyText: {
fontSize: 18,
fontWeight: 'bold',
marginTop: 16,
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
skeletonContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
paddingTop: 16,
justifyContent: 'space-between',
},
skeletonVerticalItem: {
flexDirection: 'row',
marginBottom: 16,
},
skeletonPoster: {
width: POSTER_WIDTH,
height: POSTER_HEIGHT,
borderRadius: 12,
},
skeletonItemDetails: {
flex: 1,
marginLeft: 16,
justifyContent: 'center',
},
skeletonMetaRow: {
flexDirection: 'row',
gap: 8,
marginTop: 8,
},
skeletonTitle: {
height: 20,
width: '80%',
marginBottom: 8,
borderRadius: 4,
},
skeletonMeta: {
height: 14,
width: '30%',
borderRadius: 4,
},
skeletonSectionHeader: {
height: 24,
width: '40%',
marginBottom: 16,
borderRadius: 4,
},
ratingContainer: {
position: 'absolute',
bottom: 8,
right: 8,
backgroundColor: 'rgba(0,0,0,0.7)',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
},
ratingText: {
fontSize: isTablet ? 9 : 10,
fontWeight: '700',
marginLeft: 2,
},
simpleAnimationContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
simpleAnimationContent: {
alignItems: 'center',
},
spinnerContainer: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
simpleAnimationText: {
fontSize: 16,
fontWeight: '600',
},
watchedIndicator: {
position: 'absolute',
top: 8,
right: 8,
borderRadius: 12,
padding: 2,
zIndex: 2,
backgroundColor: 'transparent',
},
libraryBadge: {
position: 'absolute',
top: 8,
left: 8,
borderRadius: 8,
padding: 4,
zIndex: 2,
backgroundColor: 'transparent',
},
// Discover section styles
discoverContainer: {
paddingTop: isTablet ? 16 : 12,
paddingBottom: isTablet ? 24 : 16,
},
discoverHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: isTablet ? 16 : 12,
gap: 8,
},
discoverTitle: {
fontSize: isTablet ? 22 : 20,
fontWeight: '700',
},
discoverTypeContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
marginBottom: isTablet ? 16 : 12,
gap: 12,
},
discoverTypeButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.1)',
gap: 6,
},
discoverTypeText: {
fontSize: isTablet ? 15 : 14,
fontWeight: '600',
},
discoverGenreScroll: {
marginBottom: isTablet ? 20 : 16,
},
discoverGenreContent: {
paddingHorizontal: 16,
gap: 8,
},
discoverGenreChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.08)',
marginRight: 8,
},
discoverGenreChipActive: {
backgroundColor: 'rgba(255,255,255,0.2)',
},
discoverGenreText: {
fontSize: isTablet ? 14 : 13,
fontWeight: '500',
color: 'rgba(255,255,255,0.7)',
},
discoverGenreTextActive: {
color: '#FFFFFF',
fontWeight: '600',
},
discoverLoadingContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
discoverLoadingText: {
marginTop: 12,
fontSize: 14,
},
discoverAddonSection: {
marginBottom: isTablet ? 28 : 20,
},
discoverAddonHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: isTablet ? 12 : 8,
},
discoverAddonName: {
fontSize: isTablet ? 16 : 15,
fontWeight: '600',
flex: 1,
},
discoverAddonBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
},
discoverAddonBadgeText: {
fontSize: 11,
fontWeight: '600',
},
discoverEmptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
paddingHorizontal: 32,
},
discoverEmptyText: {
fontSize: 16,
fontWeight: '600',
marginTop: 12,
textAlign: 'center',
},
discoverEmptySubtext: {
fontSize: 14,
marginTop: 4,
textAlign: 'center',
},
discoverGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 16,
gap: 12,
},
discoverGridRow: {
justifyContent: 'flex-start',
gap: 12,
},
discoverGridContent: {
paddingHorizontal: 16,
paddingBottom: 16,
},
discoverGridItem: {
marginRight: 0,
marginBottom: 12,
},
loadingMoreContainer: {
width: '100%',
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
},
// New chip-based discover styles
discoverChipsScroll: {
marginBottom: isTablet ? 12 : 10,
flexGrow: 0,
},
discoverChipsContent: {
paddingHorizontal: 16,
flexDirection: 'row',
gap: 8,
},
discoverSelectorChip: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
gap: 6,
},
discoverSelectorText: {
fontSize: isTablet ? 14 : 13,
fontWeight: '600',
},
discoverFilterSummary: {
paddingHorizontal: 16,
marginBottom: isTablet ? 16 : 12,
},
discoverFilterSummaryText: {
fontSize: 12,
fontWeight: '500',
},
// Bottom sheet styles
bottomSheetHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
bottomSheetTitle: {
fontSize: 18,
fontWeight: '700',
},
bottomSheetContent: {
paddingHorizontal: 12,
paddingBottom: 40,
},
bottomSheetItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 12,
borderRadius: 12,
marginVertical: 2,
},
bottomSheetItemSelected: {
backgroundColor: 'rgba(255,255,255,0.08)',
},
bottomSheetItemIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.1)',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
bottomSheetItemContent: {
flex: 1,
},
bottomSheetItemTitle: {
fontSize: 16,
fontWeight: '600',
},
bottomSheetItemSubtitle: {
fontSize: 13,
marginTop: 2,
},
showMoreButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 24,
backgroundColor: 'rgba(255,255,255,0.1)',
borderRadius: 8,
marginVertical: 20,
alignSelf: 'center',
},
showMoreButtonText: {
fontSize: 14,
fontWeight: '600',
marginRight: 8,
},
});

9
src/constants/locales.ts Normal file
View file

@ -0,0 +1,9 @@
export const LOCALES = [
{ code: 'en', key: 'english' },
{ code: 'pt-BR', key: 'portuguese_br' },
{ code: 'pt-PT', key: 'portuguese_pt' },
{ code: 'de', key: 'german' },
{ code: 'ar', key: 'arabic' },
{ code: 'fr', key: 'french' },
{ code: 'it', key: 'italian' }
];

View file

@ -1,12 +1,13 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { useTraktIntegration } from '../hooks/useTraktIntegration';
import {
TraktUser,
TraktWatchedItem,
TraktWatchlistItem,
TraktCollectionItem,
import {
TraktUser,
TraktWatchedItem,
TraktWatchlistItem,
TraktCollectionItem,
TraktRatingItem,
TraktPlaybackItem
TraktPlaybackItem,
traktService
} from '../services/traktService';
interface TraktContextProps {
@ -37,15 +38,25 @@ interface TraktContextProps {
removeFromCollection: (imdbId: string, type: 'movie' | 'show') => Promise<boolean>;
isInWatchlist: (imdbId: string, type: 'movie' | 'show') => boolean;
isInCollection: (imdbId: string, type: 'movie' | 'show') => boolean;
// Maintenance mode
isMaintenanceMode: boolean;
maintenanceMessage: string;
}
const TraktContext = createContext<TraktContextProps | undefined>(undefined);
export function TraktProvider({ children }: { children: ReactNode }) {
const traktIntegration = useTraktIntegration();
// Add maintenance mode values to the context
const contextValue: TraktContextProps = {
...traktIntegration,
isMaintenanceMode: traktService.isMaintenanceMode(),
maintenanceMessage: traktService.getMaintenanceMessage(),
};
return (
<TraktContext.Provider value={traktIntegration}>
<TraktContext.Provider value={contextValue}>
{children}
</TraktContext.Provider>
);

View file

@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react';
import { BackHandler } from 'react-native';
import { BottomSheetModal } from '@gorhom/bottom-sheet';
export function useBottomSheetBackHandler() {
const activeSheetRef =
useRef<React.RefObject<BottomSheetModal> | null>(null);
useEffect(() => {
const sub = BackHandler.addEventListener(
'hardwareBackPress',
() => {
if (activeSheetRef.current?.current) {
activeSheetRef.current.current.dismiss();
return true;
}
return false;
}
);
return () => sub.remove();
}, []);
const onChange =
(ref: React.RefObject<BottomSheetModal>) =>
(index: number) => {
if (index >= 0) {
activeSheetRef.current = ref;
}
};
const onDismiss =
(ref: React.RefObject<BottomSheetModal>) => () => {
if (activeSheetRef.current === ref) {
activeSheetRef.current = null;
}
};
return { onChange, onDismiss };
}

View file

@ -220,7 +220,7 @@ export const useCalendarData = (): UseCalendarDataReturn => {
vote_average: tmdbEpisode.vote_average || 0,
still_path: tmdbEpisode.still_path || null,
season_poster_path: tmdbEpisode.season_poster_path || null,
addonId: episodeData.addonId || series.addonId,
addonId: (episodeData as any).addonId || series.addonId,
};
@ -247,7 +247,7 @@ export const useCalendarData = (): UseCalendarDataReturn => {
vote_average: 0,
still_path: null,
season_poster_path: null,
addonId: episodeData?.addonId || series.addonId,
addonId: (episodeData as any)?.addonId || series.addonId,
}
};
}
@ -268,7 +268,7 @@ export const useCalendarData = (): UseCalendarDataReturn => {
vote_average: 0,
still_path: null,
season_poster_path: null,
addonId: episodeData?.addonId || series.addonId,
addonId: series.addonId,
}
};
}

View file

@ -550,7 +550,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId);
const movieDetails = await tmdbService.getMovieDetails(
tmdbId,
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'
);
if (movieDetails) {
const imdbId = movieDetails.imdb_id || movieDetails.external_ids?.imdb_id;
@ -634,7 +634,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
try {
const showDetails = await tmdbService.getTVShowDetails(
parseInt(tmdbId),
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'
);
if (showDetails) {
// OPTIMIZATION: Fetch external IDs, credits, and logo in parallel
@ -824,9 +824,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Store addon logo before TMDB enrichment overwrites it
const addonLogo = (finalMetadata as any).logo;
// If localization is enabled, merge TMDB localized text (name/overview) before first render
try {
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichTitleDescription) {
const tmdbSvc = TMDBService.getInstance();
let finalTmdbId: number | null = tmdbId;
if (!finalTmdbId) {
@ -835,7 +835,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
if (finalTmdbId) {
const lang = settings.tmdbLanguagePreference || 'en';
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
if (type === 'movie') {
const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang);
if (localized) {
@ -857,8 +857,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
finalMetadata = {
...finalMetadata,
name: finalMetadata.name || localized.title,
description: finalMetadata.description || localized.overview,
name: localized.title || finalMetadata.name,
description: localized.overview || finalMetadata.description,
movieDetails: movieDetailsObj,
...(productionInfo.length > 0 && { networks: productionInfo }),
};
@ -894,8 +894,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
finalMetadata = {
...finalMetadata,
name: finalMetadata.name || localized.name,
description: finalMetadata.description || localized.overview,
name: localized.name || finalMetadata.name,
description: localized.overview || finalMetadata.description,
tvDetails,
...(productionInfo.length > 0 && { networks: productionInfo }),
};
@ -904,19 +904,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
}
} catch (e) {
if (__DEV__) console.log('[useMetadata] failed to merge localized TMDB text', e);
if (__DEV__) console.log('[useMetadata] failed to merge TMDB title/description', e);
}
// Centralized logo fetching logic
try {
if (addonLogo) {
finalMetadata.logo = addonLogo;
if (__DEV__) {
console.log('[useMetadata] Using addon-provided logo:', { hasLogo: true });
}
// Check both master switch AND granular logos setting
} else if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) {
// Only use TMDB logos when both enrichment AND logos option are ON
// When TMDB enrichment AND logos are enabled, prioritize TMDB logo over addon logo
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) {
const tmdbService = TMDBService.getInstance();
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
const contentType = type === 'series' ? 'tv' : 'movie';
@ -932,23 +926,26 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (tmdbIdForLogo) {
const logoUrl = await tmdbService.getContentLogo(contentType, tmdbIdForLogo, preferredLanguage);
finalMetadata.logo = logoUrl || undefined; // TMDB logo or undefined (no addon fallback)
// Use TMDB logo if found, otherwise fall back to addon logo
finalMetadata.logo = logoUrl || addonLogo || undefined;
if (__DEV__) {
console.log('[useMetadata] Logo fetch result:', {
contentType,
tmdbIdForLogo,
preferredLanguage,
logoUrl: !!logoUrl,
tmdbLogoFound: !!logoUrl,
usingAddonFallback: !logoUrl && !!addonLogo,
enrichmentEnabled: true
});
}
} else {
finalMetadata.logo = undefined; // No TMDB ID means no logo
if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, will show text title');
// No TMDB ID, fall back to addon logo
finalMetadata.logo = addonLogo || undefined;
if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, using addon logo');
}
} else {
// When enrichment or logos is OFF, keep addon logo or undefined
finalMetadata.logo = finalMetadata.logo || undefined;
// When enrichment or logos is OFF, use addon logo
finalMetadata.logo = addonLogo || finalMetadata.logo || undefined;
if (__DEV__) {
console.log('[useMetadata] TMDB logo enrichment disabled, using addon logo:', {
hasAddonLogo: !!finalMetadata.logo,
@ -1125,10 +1122,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Fetch season posters from TMDB only if enrichment AND season posters are enabled
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichSeasonPosters) {
try {
const lang = settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}` : 'en';
const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null);
if (tmdbIdToUse) {
if (!tmdbId) setTmdbId(tmdbIdToUse);
const showDetails = await tmdbService.getTVShowDetails(tmdbIdToUse);
const showDetails = await tmdbService.getTVShowDetails(tmdbIdToUse, lang);
if (showDetails?.seasons) {
Object.keys(groupedAddonEpisodes).forEach(seasonStr => {
const seasonNum = parseInt(seasonStr, 10);
@ -1151,37 +1149,46 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (__DEV__) logger.log('[loadSeriesData] TMDB season poster enrichment disabled; skipping season poster fetch');
}
// If localized TMDB text is enabled AND episode enrichment is enabled, merge episode names/overviews per language
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichEpisodes && settings.useTmdbLocalizedMetadata) {
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichEpisodes) {
try {
const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null);
if (tmdbIdToUse) {
const lang = `${settings.tmdbLanguagePreference || 'en'}-US`;
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
const seasons = Object.keys(groupedAddonEpisodes).map(Number);
for (const seasonNum of seasons) {
const seasonEps = groupedAddonEpisodes[seasonNum];
// Parallel fetch a reasonable batch (limit concurrency implicitly by season)
const localized = await Promise.all(
seasonEps.map(async ep => {
try {
const data = await tmdbService.getEpisodeDetails(Number(tmdbIdToUse), seasonNum, ep.episode_number, lang);
if (data) {
// Fetch all seasons in parallel (much faster than fetching each episode individually)
const seasonPromises = seasons.map(async seasonNum => {
try {
// getSeasonDetails returns all episodes for a season in one call
const seasonData = await tmdbService.getSeasonDetails(Number(tmdbIdToUse), seasonNum, undefined, lang);
if (seasonData && seasonData.episodes) {
// Create a map of episode number -> localized data for fast lookup
const localizedMap = new Map<number, { name: string; overview: string }>();
for (const ep of seasonData.episodes) {
localizedMap.set(ep.episode_number, { name: ep.name, overview: ep.overview });
}
// Merge localized data into addon episodes
groupedAddonEpisodes[seasonNum] = groupedAddonEpisodes[seasonNum].map(ep => {
const localized = localizedMap.get(ep.episode_number);
if (localized) {
return {
...ep,
name: data.name || ep.name,
overview: data.overview || ep.overview,
name: localized.name || ep.name,
overview: localized.overview || ep.overview,
};
}
} catch { }
return ep;
})
);
groupedAddonEpisodes[seasonNum] = localized;
}
if (__DEV__) logger.log('[useMetadata] merged localized episode names/overviews from TMDB');
return ep;
});
}
} catch { }
});
await Promise.all(seasonPromises);
if (__DEV__) logger.log('[useMetadata] merged episode names/overviews from TMDB (batch)');
}
} catch (e) {
if (__DEV__) console.log('[useMetadata] failed to merge localized episode text', e);
if (__DEV__) console.log('[useMetadata] failed to merge episode text from TMDB', e);
}
}
@ -1264,13 +1271,14 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Fallback to TMDB if no addon episodes
logger.log('📺 No addon episodes found, falling back to TMDB');
const lang = settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}` : 'en';
const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
if (tmdbIdResult) {
setTmdbId(tmdbIdResult);
const [allEpisodes, showDetails] = await Promise.all([
tmdbService.getAllEpisodes(tmdbIdResult),
tmdbService.getTVShowDetails(tmdbIdResult)
tmdbService.getAllEpisodes(tmdbIdResult, lang),
tmdbService.getTVShowDetails(tmdbIdResult, lang)
]);
const transformedEpisodes: GroupedEpisodes = {};
@ -1596,22 +1604,26 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
});
// Add local scrapers if enabled
localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => {
initialStatuses.push({
id: scraper.id,
name: scraper.name,
isLoading: true,
hasCompleted: false,
error: null,
startTime: Date.now(),
endTime: null
const currentSettings = await mmkvStorage.getItem('app_settings');
const enableLocalScrapersNow = currentSettings ? JSON.parse(currentSettings).enableLocalScrapers !== false : true;
if (enableLocalScrapersNow) {
localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => {
initialStatuses.push({
id: scraper.id,
name: scraper.name,
isLoading: true,
hasCompleted: false,
error: null,
startTime: Date.now(),
endTime: null
});
initialActiveFetching.push(scraper.name);
});
initialActiveFetching.push(scraper.name);
});
}
setScraperStatuses(initialStatuses);
setActiveFetchingScrapers(initialActiveFetching);
console.log('🔍 [loadStreams] Initialized activeFetchingScrapers:', initialActiveFetching);
// If no scrapers are available, stop loading immediately
if (initialStatuses.length === 0) {
@ -1732,23 +1744,27 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
initialActiveFetching.push(addon.name);
});
// Add local scrapers if enabled
localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => {
initialStatuses.push({
id: scraper.id,
name: scraper.name,
isLoading: true,
hasCompleted: false,
error: null,
startTime: Date.now(),
endTime: null
// Add local scrapers if enabled (read from storage to avoid stale closure)
const currentSettings = await mmkvStorage.getItem('app_settings');
const enableLocalScrapersNow = currentSettings ? JSON.parse(currentSettings).enableLocalScrapers !== false : true;
if (enableLocalScrapersNow) {
localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => {
initialStatuses.push({
id: scraper.id,
name: scraper.name,
isLoading: true,
hasCompleted: false,
error: null,
startTime: Date.now(),
endTime: null
});
initialActiveFetching.push(scraper.name);
});
initialActiveFetching.push(scraper.name);
});
}
setScraperStatuses(initialStatuses);
setActiveFetchingScrapers(initialActiveFetching);
console.log('🔍 [loadEpisodeStreams] Initialized activeFetchingScrapers:', initialActiveFetching);
// If no scrapers are available, stop loading immediately
if (initialStatuses.length === 0) {
@ -2038,7 +2054,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setLoadingRecommendations(true);
try {
const tmdbService = TMDBService.getInstance();
const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId));
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId), lang);
// Convert TMDB results to StreamingContent format (simplified)
const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({
@ -2056,7 +2073,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} finally {
setLoadingRecommendations(false);
}
}, [tmdbId, type]);
}, [tmdbId, type, settings.useTmdbLocalizedMetadata, settings.tmdbLanguagePreference]);
// Fetch TMDB ID if needed and then recommendations
useEffect(() => {

View file

@ -37,6 +37,7 @@ export interface AppSettings {
useExternalPlayer: boolean;
preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'infuse_livecontainer' | 'external';
showHeroSection: boolean;
showThisWeekSection: boolean; // Toggle "This Week" section
featuredContentSource: 'tmdb' | 'catalogs';
heroStyle: 'legacy' | 'carousel' | 'appletv';
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
@ -92,12 +93,14 @@ export interface AppSettings {
tmdbEnrichMovieDetails: boolean; // Show movie details (budget, revenue, tagline, etc.)
tmdbEnrichTvDetails: boolean; // Show TV details (status, seasons count, networks, etc.)
tmdbEnrichCollections: boolean; // Show movie collections/franchises
tmdbEnrichTitleDescription: boolean; // Use TMDB title/description (overrides addon when localization enabled)
// Trakt integration
showTraktComments: boolean; // Show Trakt comments in metadata screens
// Continue Watching behavior
useCachedStreams: boolean; // Enable/disable direct player navigation from Continue Watching cache
openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen
streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour)
continueWatchingCardStyle: 'wide' | 'poster'; // Card style: 'wide' (horizontal) or 'poster' (vertical)
enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile
useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content
// Android MPV player settings
@ -122,6 +125,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
useExternalPlayer: false,
preferredPlayer: 'internal',
showHeroSection: true,
showThisWeekSection: true, // Enabled by default
featuredContentSource: 'catalogs',
heroStyle: 'appletv',
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
@ -176,12 +180,14 @@ export const DEFAULT_SETTINGS: AppSettings = {
tmdbEnrichMovieDetails: true,
tmdbEnrichTvDetails: true,
tmdbEnrichCollections: true,
tmdbEnrichTitleDescription: true, // Enabled by default for backward compatibility
// Trakt integration
showTraktComments: true, // Show Trakt comments by default when authenticated
// Continue Watching behavior
useCachedStreams: false, // Enable by default
openMetadataScreenWhenCacheDisabled: true, // Default to StreamsScreen when cache disabled
streamCacheTTL: 60 * 60 * 1000, // Default: 1 hour in milliseconds
continueWatchingCardStyle: 'wide', // Default to wide (horizontal) card style
enableStreamsBackdrop: true, // Enable by default (new behavior)
// Android MPV player settings
videoPlayerEngine: 'auto', // Default to auto (ExoPlayer primary, MPV fallback)

View file

@ -523,7 +523,7 @@ export function useTraktIntegration() {
// Fetch both playback progress and recently watched movies
const [traktProgress, watchedMovies, watchedShows] = await Promise.all([
getTraktPlaybackProgress(),
traktService.getWatchedMovies()
traktService.getWatchedMovies(),
traktService.getWatchedShows()
]);
@ -559,15 +559,23 @@ export function useTraktIntegration() {
return undefined;
})();
updatePromises.push(
storageService.mergeWithTraktProgress(
id,
type,
item.progress,
item.paused_at,
episodeId,
exactTime
)
// Merge with local progress
await storageService.mergeWithTraktProgress(
id,
type,
item.progress,
item.paused_at,
episodeId,
exactTime
);
// FIX: Mark as already synced so it won't be re-uploaded to Trakt
await storageService.updateTraktSyncStatus(
id,
type,
true, // synced = true
item.progress,
episodeId
);
} catch (error) {
logger.error('[useTraktIntegration] Error preparing Trakt progress update:', error);
@ -581,19 +589,27 @@ export function useTraktIntegration() {
const id = movie.movie.ids.imdb;
const watchedAt = movie.last_watched_at;
updatePromises.push(
storageService.mergeWithTraktProgress(
id,
'movie',
100, // 100% progress for watched items
watchedAt
)
await storageService.mergeWithTraktProgress(
id,
'movie',
100,
watchedAt
);
// FIX: Mark as already synced
await storageService.updateTraktSyncStatus(
id,
'movie',
true,
100
);
}
} catch (error) {
logger.error('[useTraktIntegration] Error preparing watched movie update:', error);
logger.error('[useTraktIntegration] Error preparing watched movie update:', error);
}
}
// Process watched shows (100% completed episodes)
for (const show of watchedShows) {
try {
if (show.show?.ids?.imdb && show.seasons) {
@ -602,21 +618,31 @@ export function useTraktIntegration() {
for (const season of show.seasons) {
for (const episode of season.episodes) {
const episodeId = `${showImdbId}:${season.number}:${episode.number}`;
updatePromises.push(
storageService.mergeWithTraktProgress(
showImdbId,
'series',
100,
episode.last_watched_at,
episodeId
)
await storageService.mergeWithTraktProgress(
showImdbId,
'series',
100,
episode.last_watched_at,
episodeId
);
// FIX: Mark as already synced
await storageService.updateTraktSyncStatus(
showImdbId,
'series',
true,
100,
episodeId
);
}
}
}
} catch (error) {
logger.error('[useTraktIntegration] Error preparing watched show update:', error);
}
}
// Execute all updates in parallel
await Promise.all(updatePromises);

View file

@ -108,30 +108,60 @@ export const useWatchProgress = (
setWatchProgress(null);
}
} else {
// FIXED: Find the most recently watched episode instead of first unfinished
// Sort by lastUpdated timestamp (most recent first)
const sortedProgresses = seriesProgresses.sort((a, b) =>
b.progress.lastUpdated - a.progress.lastUpdated
);
if (sortedProgresses.length > 0) {
// Use the most recently watched episode
const mostRecentProgress = sortedProgresses[0];
const progress = mostRecentProgress.progress;
// Removed excessive logging for most recent progress
const COMPLETION_THRESHOLD = 85;
const incompleteProgresses = seriesProgresses.filter(({ progress }) => {
const progressPercent = (progress.currentTime / progress.duration) * 100;
return progressPercent < COMPLETION_THRESHOLD;
});
if (incompleteProgresses.length > 0) {
const sortedIncomplete = incompleteProgresses.sort((a, b) =>
b.progress.lastUpdated - a.progress.lastUpdated
);
const mostRecentIncomplete = sortedIncomplete[0];
setWatchProgress({
...progress,
episodeId: mostRecentProgress.episodeId,
traktSynced: progress.traktSynced,
traktProgress: progress.traktProgress
...mostRecentIncomplete.progress,
episodeId: mostRecentIncomplete.episodeId,
traktSynced: mostRecentIncomplete.progress.traktSynced,
traktProgress: mostRecentIncomplete.progress.traktProgress
});
} else if (seriesProgresses.length > 0) {
const watchedEpisodeNumbers = seriesProgresses
.map(({ episodeId }) => getEpisodeNumber(episodeId))
.filter(Boolean)
.sort((a, b) => {
if (a!.season !== b!.season) return a!.season - b!.season;
return a!.episode - b!.episode;
});
if (watchedEpisodeNumbers.length > 0) {
const lastWatched = watchedEpisodeNumbers[watchedEpisodeNumbers.length - 1]!;
const currentEpisodes = episodesRef.current;
const nextEpisode = currentEpisodes.find(ep => {
if (ep.season_number > lastWatched.season) return true;
if (ep.season_number === lastWatched.season && ep.episode_number > lastWatched.episode) return true;
return false;
});
if (nextEpisode) {
setWatchProgress({
currentTime: 0,
duration: nextEpisode.runtime * 60 || 0,
lastUpdated: Date.now(),
episodeId: `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`,
traktSynced: false,
traktProgress: 0
});
} else {
setWatchProgress(null);
}
} else {
// No watched episodes found
setWatchProgress(null);
}
} else {
setWatchProgress(null);
}
}
} else {
// For movies
const progress = await storageService.getWatchProgress(id, type, episodeId);
@ -224,4 +254,4 @@ export const useWatchProgress = (
getPlayButtonText,
loadWatchProgress
};
};
};

Some files were not shown because too many files have changed in this diff Show more