Compare commits

...

258 commits
1.3.0 ... 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
Nayif
2b802079a0
Merge pull request #331 from chrisk325/main
fixed tmdb enrichment logic , fixed trakt not syncing watch progress for old seasons
2026-01-01 03:31:40 +05:30
chrisk325
0d416f724c
small fix to backdrop fallback logic 2026-01-01 02:02:53 +05:30
tapframe
a6a0a8b1b1 removed sandboxed environment 2026-01-01 02:00:54 +05:30
chrisk325
dd1a3ed496
fix tmdb enrichment override for the logo 2026-01-01 01:58:30 +05:30
chrisk325
9f3831e733
fix for trakt only syncing the latest season disregarding the previous seasons 2026-01-01 01:16:18 +05:30
chrisk325
fd1107a5a3
fix backdrop override by tmdb enrichment 2026-01-01 00:09:33 +05:30
chrisk325
a9a78d5565
fix tmdb enrichment overriding metadata even when turned off 2025-12-31 23:48:56 +05:30
Nayif
a794e27235
Merge pull request #328 from AdityasahuX07/patch-15
Minor UI changes and Bug fix.
2025-12-31 20:03:59 +05:30
Nayif
9bb9d6548a
Merge pull request #327 from chrisk325/main
this should fix addon provided metadata for "continue watching" and "trakt this week"
2025-12-31 20:02:54 +05:30
Nayif
3625ca9edc
Merge pull request #330 from tapframe/plugin
multi plugin support
2025-12-31 20:02:28 +05:30
tapframe
3293b57537 multi plugin support 2025-12-31 19:50:08 +05:30
AdityasahuX07
867458b52f
Change controls hide delay from 3s to 2s
Reduce the delay for hiding controls from 3 seconds to 2 seconds.
2025-12-31 18:59:16 +05:30
AdityasahuX07
8daca53be3
Implement auto-hide for video player controls
Added auto-hide functionality for video player controls after 3 seconds of inactivity, with cleanup on unmount.
2025-12-31 18:56:32 +05:30
AdityasahuX07
4174fd2add
Adjust padding and gap in playerStyles 2025-12-31 18:55:44 +05:30
AdityasahuX07
d3041f99cc
Refactor SpeedActivatedOverlay component props and styles 2025-12-31 18:48:10 +05:30
chrisk325
6acfa2971b
fallback to metadata banner if tmdb enrichment banner is disabled by the user 2025-12-31 17:29:23 +05:30
chrisk325
7271ed39a0
fix for trakt this week metadata 2025-12-31 17:14:02 +05:30
chrisk325
639e84bb88
push addonId for trakt up next 2025-12-31 16:58:24 +05:30
chrisk325
36ad45cfbc
push addonId 2025-12-31 16:52:55 +05:30
chrisk325
c0540db282
preserving stream providers's id 2025-12-31 16:47:15 +05:30
chrisk325
7d6008b0a9
adding addonId to player's saved progress 2025-12-31 16:44:07 +05:30
tapframe
af96d30122 ui changes 2025-12-31 03:26:25 +05:30
tapframe
bf75cca438 refactored settingscreen 2025-12-31 03:15:37 +05:30
tapframe
3285ecbe04 Updated Subtitle Sync Modal 2025-12-31 02:41:13 +05:30
tapframe
6906ad99b7 updated to gorhom bottom sheet 2025-12-31 01:15:04 +05:30
tapframe
f3c5289013 icons update 2025-12-31 01:06:40 +05:30
tapframe
be9473adf7 added subtitle/audio track selection menu 2025-12-31 01:05:09 +05:30
Nayif
ec28f73df9
Merge pull request #324 from chrisk325/patch-1
small fix
2025-12-31 00:37:25 +05:30
chrisk325
d19f4713a2
syntax 2025-12-30 20:38:55 +05:30
chrisk325
2e79c34068
small fix 2025-12-30 19:49:19 +05:30
tapframe
c7e5696974 included limpv aar 2025-12-30 19:45:36 +05:30
tapframe
154d034e8f fixed type errors and syntax errors from merged PR. 2025-12-30 18:55:03 +05:30
tapframe
916eeaef4c Merge branch 'main' of https://github.com/tapframe/NuvioStreaming 2025-12-30 17:56:19 +05:30
Nayif
ad18e30de7
Merge pull request #321 from chrisk325/main 2025-12-30 17:46:50 +05:30
chrisk325
d4917fefc9
fetch addon provided logo first if it errors then fallback to tmdb 2025-12-30 17:20:33 +05:30
chrisk325
67b16c27f3
preserve addonId when creating obj thisweekepisode 2025-12-30 16:05:18 +05:30
chrisk325
f15fe80d3a
addon provided metadata for trakt this week 2025-12-30 15:52:30 +05:30
chrisk325
fbb44b14dd
addon provided metadata for appletv style carousel 2025-12-30 15:49:21 +05:30
chrisk325
103bcdd4cc
save addonId in watch progress 2025-12-30 15:34:11 +05:30
chrisk325
5e04ebca18
preserving addonId 2025-12-30 15:22:31 +05:30
chrisk325
b00812333a
added addonId to continue watching 2025-12-30 15:18:16 +05:30
chrisk325
9012bfdea9
addonId for hero section type carousel 2025-12-30 15:07:55 +05:30
chrisk325
9e5877173e
adding addonId for hero and continue watching 2025-12-30 15:02:01 +05:30
tapframe
b165c3223d fixed the implicit any type errors AppNavigator.tsx 2025-12-30 13:18:13 +05:30
tapframe
79213ad573 Merge branch 'main' of https://github.com/tapframe/NuvioStreaming 2025-12-30 13:11:50 +05:30
Nayif
7df42903c6
Merge pull request #319 from chrisk325/main 2025-12-30 11:17:48 +05:30
chrisk325
42d4290acd
modify onpress handler to pass addonId 2025-12-30 04:19:19 +05:30
chrisk325
8c449215a6
attached addonId to search and discover 2025-12-30 04:12:32 +05:30
tapframe
183d30c720 removed config cache 2025-12-30 03:59:24 +05:30
tapframe
53a572ecac update env example 2025-12-30 03:10:17 +05:30
tapframe
4173786b12 more sdui control over settinsgcreen. 2025-12-30 02:49:10 +05:30
tapframe
44abb9f635 added video support to sdui server 2025-12-30 02:15:43 +05:30
tapframe
fd6e29a8ec Added granular control for TMDB Enrichment 2025-12-29 19:48:26 +05:30
tapframe
832e5368be settingscreen refactor 2025-12-29 15:05:50 +05:30
tapframe
e543d72879 ota server change 2025-12-29 13:44:44 +05:30
tapframe
b4b8648e25 embed stream fetch critical bug fix 2025-12-29 01:03:06 +05:30
tapframe
ff2bca18a5 discover screen optimization 2025-12-28 23:57:31 +05:30
tapframe
cf5cc2d8f9 discover screen init 2025-12-28 22:22:18 +05:30
tapframe
a30fa604d7 discover screen init 2025-12-28 22:10:40 +05:30
tapframe
18e90397d9 black spot fix during playback ksplayer 2025-12-28 19:35:01 +05:30
tapframe
97f558faf4 update discord url 2025-12-28 14:54:45 +05:30
tapframe
69dacb0ede update readme 2025-12-28 14:50:14 +05:30
tapframe
95e7d44035 addes scrolltotop by clicking tab navigation buttons 2025-12-28 13:29:33 +05:30
tapframe
d39a485d24 fixed watchprogress not resuming in exoplayer 2025-12-28 13:10:25 +05:30
tapframe
4f0a673f87 moved submodules to gitignore 2025-12-28 12:54:11 +05:30
tapframe
8618dcda74 rm cached submodules 2025-12-28 12:53:52 +05:30
tapframe
cc8be32cac updated index.html 2025-12-28 12:50:10 +05:30
tapframe
f65eb8fe7e removed submodules 2025-12-28 12:05:46 +05:30
tapframe
7c1a69d136 added ipa, updated nuvio-source.json 2025-12-28 12:02:24 +05:30
tapframe
7fdd4c4383 converted from sectionlist to flatflist (streamscreen) 2025-12-28 03:41:33 +05:30
tapframe
43cd14a025 chore(release): 1.3.2 2025-12-28 03:29:36 +05:30
tapframe
5662ee908d moved introdb base url to env 2025-12-28 03:13:17 +05:30
tapframe
de7fcb4d4d streamsceen scrollview changed to sectionlist 2025-12-28 02:59:26 +05:30
tapframe
f6dea03c05 users can now toggle between - auto/mpv exclusively 2025-12-28 02:21:01 +05:30
tapframe
6e2ddd2dda Added ExoPlayer as primary for better hardwre decoder support and MPV as fallback 2025-12-28 02:14:39 +05:30
tapframe
2d97cad1dc skip intro ui changes 2025-12-27 23:04:10 +05:30
tapframe
1d9a3b645b Added SkipIntro button 2025-12-27 22:53:05 +05:30
tapframe
a89c7f5c5c sub changes 2025-12-27 22:29:27 +05:30
tapframe
91af3a4021 improved stream fetching logic 2025-12-27 21:13:11 +05:30
tapframe
7a5ecd3009 addon first result in streamscreen 2025-12-27 19:15:10 +05:30
tapframe
aed4fed56f refactor streamscreen 2025-12-27 19:12:25 +05:30
tapframe
7885df341e changes 2025-12-27 18:42:41 +05:30
tapframe
2921b3eb1f added internal sub customization - mpv 2025-12-27 18:37:03 +05:30
tapframe
579b0a77b3 added hwdec options to choose from 2025-12-27 17:50:09 +05:30
tapframe
063f8a8c1b macos fullscreen fix 2025-12-26 14:22:13 +05:30
tapframe
985d01d5a9 ksplayer sub rendering fix 2025-12-26 12:32:33 +05:30
tapframe
9f461f7091 ksplayer sub rendering fix 2025-12-26 12:32:19 +05:30
tapframe
0b4db84f30 changed mpv hw to try "mediacode" first 2025-12-25 20:31:14 +05:30
tapframe
7e7804b6d4 improved parental guide UI 2025-12-25 14:22:12 +05:30
tapframe
eee6f81fca parental guide overlay init 2025-12-25 14:09:09 +05:30
tapframe
9375fab06c Changed to TraktService.getInstance().isAuthenticated() 2025-12-25 13:03:04 +05:30
Nayif
d2987ce0cc
Merge pull request #292 from AdityasahuX07/patch-14
Video player Ui changes
2025-12-25 12:46:25 +05:30
tapframe
a61c1e6456 Fix: Player orientation now correctly resets when exiting video player 2025-12-25 12:35:07 +05:30
tapframe
0a1e008d5f update readme 2025-12-24 23:22:37 +05:30
tapframe
7f9e9ff5db orientation fix after exiting player ios 2025-12-24 21:46:46 +05:30
tapframe
39498f78b7 removed dead code 2025-12-24 21:38:02 +05:30
tapframe
8588aca948 removed orphaned modules 2025-12-24 20:05:17 +05:30
Nayif
3f63461d45
Merge pull request #297 from tapframe/mpv
update nuvio-source.json
2025-12-24 20:03:10 +05:30
tapframe
f5e9a3977b update nuvio-source.json 2025-12-24 20:01:50 +05:30
Nayif
aa62cc78f0
Merge pull request #296 from tapframe/mpv
Mpv
2025-12-24 19:55:52 +05:30
tapframe
d6bb2869c5 removed orphaned submodules 2025-12-24 19:54:18 +05:30
AdityasahuX07
74764bbbe0
Merge branch 'main' into patch-14 2025-12-24 19:40:07 +05:30
tapframe
52dd075b6a bumped app version 2025-12-24 19:23:53 +05:30
tapframe
1821bf1230 changes 2025-12-24 19:22:00 +05:30
AdityasahuX07
68c5b09e3a
Change primary color to a lighter shade 2025-12-23 16:26:12 +05:30
AdityasahuX07
d2f9b7586a
Refactor SourcesModal component styling and layout
Refactor QualityBadge component for improved styling and readability. Update source selection UI for better user experience.
2025-12-23 15:24:02 +05:30
AdityasahuX07
4753b2a57a
Update EpisodeStreamsModal.tsx 2025-12-23 15:22:01 +05:30
AdityasahuX07
5119822c31
Refactor episode progress fetching and UI adjustments 2025-12-23 15:21:27 +05:30
AdityasahuX07
09d0483ee3
Update print statement from 'Hello' to 'Goodbye' 2025-12-23 15:20:23 +05:30
AdityasahuX07
034fd8a9aa
Refactor AudioTrackModal for better layout and animations
Updated the AudioTrackModal component to improve layout and animations.
2025-12-23 15:18:45 +05:30
AdityasahuX07
b3ec4e0c01
Video player Ui changes 2025-12-23 15:17:18 +05:30
234 changed files with 37321 additions and 17223 deletions

View file

@ -17,3 +17,8 @@ EXPO_PUBLIC_MOVIEBOX_TMDB_API_KEY=your_tmdb_api_key_for_moviebox
EXPO_PUBLIC_TRAKT_CLIENT_ID=your_trakt_client_id
EXPO_PUBLIC_TRAKT_CLIENT_SECRET=your_trakt_client_secret
EXPO_PUBLIC_TRAKT_REDIRECT_URI=stremioexpo://auth/trakt
# Skip Intro API (IntroDB)
# Fetches intro timestamps for TV shows to enable skip intro functionality
EXPO_PUBLIC_INTRODB_API_URL=https://api.introdb.app
EXPO_PUBLIC_DISCORD_USER_API=

11
.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
@ -51,6 +54,7 @@ android/build/
android/.gradle/
android/app/libs/*.aar
!android/app/libs/lib-decoder-ffmpeg-release.aar
!android/app/libs/libmpv-release.aar
HEATING_OPTIMIZATIONS.md
# sliderreadme.md
.cursor/mcp.json
@ -88,4 +92,9 @@ ios/sentry.properties
android/sentry.properties
Stremio addons refer
trakt-docs
trakt-docss
trakt-docss
# Removed submodules (kept locally)
libmpv-android/
mpv-android/
mpvKt/

83
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';
@ -22,6 +23,7 @@ import AppNavigator, {
CustomNavigationDarkTheme,
CustomDarkTheme
} from './src/navigation/AppNavigator';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import 'react-native-reanimated';
import { CatalogProvider } from './src/contexts/CatalogContext';
import { GenreProvider } from './src/contexts/GenreContext';
@ -41,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({
@ -89,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 {
@ -104,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 () => {
@ -133,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
@ -179,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';
@ -203,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 }]}>
@ -226,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>
@ -245,19 +214,21 @@ const ThemedApp = () => {
function App(): React.JSX.Element {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<GenreProvider>
<CatalogProvider>
<TraktProvider>
<ThemeProvider>
<TrailerProvider>
<ToastProvider>
<ThemedApp />
</ToastProvider>
</TrailerProvider>
</ThemeProvider>
</TraktProvider>
</CatalogProvider>
</GenreProvider>
<BottomSheetModalProvider>
<GenreProvider>
<CatalogProvider>
<TraktProvider>
<ThemeProvider>
<TrailerProvider>
<ToastProvider>
<ThemedApp />
</ToastProvider>
</TrailerProvider>
</ThemeProvider>
</TraktProvider>
</CatalogProvider>
</GenreProvider>
</BottomSheetModalProvider>
</GestureHandlerRootView>
);
}

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">
@ -22,11 +21,10 @@
<a href="#getting-started"><strong>Get Started »</strong></a>
<br />
<br />
<a href="#demo">View Screenshots</a>
·
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=bug&template=bug_report.md">Report Bug</a>
·
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=enhancement&template=feature_request.md">Request Feature</a>
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=bug&template=bug_report.md">Report Bug</a>
·
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=enhancement&template=feature_request.md">Request Feature</a>
</p>
</div>
@ -38,11 +36,13 @@
<a href="#about-the-project">About The Project</a>
</li>
<li><a href="#installation">Installation</a></li>
<li><a href="#demo">Screenshots</a></li>
<li><a href="#getting-started">Getting Started</a></li>
<li><a href="#contributing">Contributing</a></li>
<li><a href="#support">Support</a></li>
<li><a href="#support">Support</a></li>
<li><a href="#license">License</a></li>
<li><a href="#legal">Legal</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#acknowledgments">Acknowledgments</a></li>
<li><a href="#built-with">Built With</a></li>
@ -79,20 +79,12 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- DEMO / SCREENSHOTS -->
## Demo
<a id="demo"></a>
| Home | Details |
|:----:|:-------:|
| ![Home](screenshots/Simulator%20Screenshot%20-%20iPhone%2016%20Pro%20-%202025-08-27%20at%2021.08.32-portrait.png) | ![Details](screenshots/WhatsApp%20Image%202025-09-02%20at%2000.24.31-portrait.png) |
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- GETTING STARTED -->
## Getting Started
Follow the steps below to run the app locally for development.
Follow the steps below to run the app locally for development. For detailed setup and troubleshooting, see [Project Documentation](docs/DOCUMENTATION.md).
### Development Build
@ -148,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:**
@ -177,6 +177,16 @@ Distributed under the GNU GPLv3 License. See `LICENSE` for more information.
React Native • Expo • TypeScript
</p>
## Star History
<a href="https://www.star-history.com/#tapframe/NuvioStreaming&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=tapframe/NuvioStreaming&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=tapframe/NuvioStreaming&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=tapframe/NuvioStreaming&type=date&legend=top-left" />
</picture>
</a>
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- MARKDOWN LINKS & IMAGES -->

View file

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

Binary file not shown.

View file

@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
@ -22,7 +21,7 @@
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ERROR_RECOVERY_ONLY"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="30000"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://ota.nuvioapp.space/api/manifest"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|locale|layoutDirection" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>

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,
@ -24,8 +27,11 @@ class MPVView @JvmOverloads constructor(
private var surface: Surface? = null
private var httpHeaders: Map<String, String>? = null
// Hardware decoding setting (default: false = software decoding)
var useHardwareDecoding: Boolean = false
// Decoder mode setting: 'auto', 'sw', 'hw', 'hw+' (default: auto)
var decoderMode: String = "auto"
// GPU mode setting: 'gpu', 'gpu-next' (default: gpu)
var gpuMode: String = "gpu"
// Flag to track if onLoad has been fired (prevents multiple fires for HLS streams)
private var hasLoadEventFired: Boolean = false
@ -37,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) {
@ -77,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()
@ -93,95 +118,92 @@ class MPVView @JvmOverloads constructor(
}
private fun initOptions() {
// Mobile-optimized profile
MPVLib.setOptionString("profile", "fast")
MPVLib.setOptionString("vo", "gpu")
// GPU rendering mode (gpu or gpu-next)
MPVLib.setOptionString("vo", gpuMode)
MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
// Hardware decoding configuration
// 'mediacodec-copy' for hardware acceleration (GPU decoding, copies frames to CPU)
// 'no' for software decoding (more compatible, especially on emulators)
val hwdecValue = if (useHardwareDecoding) "mediacodec-copy" else "no"
Log.d(TAG, "Hardware decoding: $useHardwareDecoding, hwdec value: $hwdecValue")
// Decoder mode mapping (same as mpvKt)
val hwdecValue = when (decoderMode) {
"auto" -> "auto-copy" // Best balance: HW decode, copy to CPU for filters
"sw" -> "no" // Software decoding only
"hw" -> "mediacodec-copy" // HW decode with copy (safer)
"hw+" -> "mediacodec" // Full HW decode (fastest, may have issues)
else -> "auto-copy"
}
Log.d(TAG, "Decoder mode: $decoderMode, hwdec value: $hwdecValue, GPU mode: $gpuMode")
MPVLib.setOptionString("hwdec", hwdecValue)
MPVLib.setOptionString("hwdec-codecs", "all")
// Note: Not setting hwdec-codecs explicitly - let mpv use defaults
MPVLib.setOptionString("target-colorspace-hint", "yes")
// HDR and Dolby Vision support
// target-prim: Signal target display primaries (auto = passthrough when display supports)
MPVLib.setOptionString("target-prim", "auto")
// target-trc: Signal target transfer characteristics (auto = passthrough when display supports)
MPVLib.setOptionString("target-trc", "auto")
// tone-mapping: How to handle HDR/DV content on SDR displays (auto = best automatic choice)
MPVLib.setOptionString("tone-mapping", "auto")
// hdr-compute-peak: Compute peak brightness for better tone mapping
MPVLib.setOptionString("hdr-compute-peak", "auto")
// Allow DV Profile 5 (HEVC with RPU) to be decoded by hardware decoder
MPVLib.setOptionString("vd-lavc-o", "strict=-2")
// Workaround for https://github.com/mpv-player/mpv/issues/14651
MPVLib.setOptionString("vd-lavc-film-grain", "cpu")
// Audio output
MPVLib.setOptionString("ao", "audiotrack,opensles")
// Network caching for streaming
MPVLib.setOptionString("demuxer-max-bytes", "67108864") // 64MB
MPVLib.setOptionString("demuxer-max-back-bytes", "33554432") // 32MB
// Limit demuxer cache based on Android version (like mpvKt)
val cacheMegs = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) 64 else 32
MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}")
MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}")
MPVLib.setOptionString("cache", "yes")
MPVLib.setOptionString("cache-secs", "30")
// Network options
MPVLib.setOptionString("network-timeout", "60") // 60 second timeout
// CRITICAL: Disable youtube-dl/yt-dlp hook
// The ytdl_hook incorrectly tries to parse HLS/direct URLs through youtube-dl
// which fails on Android since yt-dlp is not available, causing playback failure
MPVLib.setOptionString("network-timeout", "60")
MPVLib.setOptionString("ytdl", "no")
// CRITICAL: HTTP headers MUST be set as options before init()
// Apply headers if they were set before surface initialization
applyHttpHeadersAsOptions()
// FFmpeg HTTP protocol options for better compatibility
MPVLib.setOptionString("tls-verify", "no") // Disable TLS cert verification
MPVLib.setOptionString("http-reconnect", "yes") // Auto-reconnect on network issues
MPVLib.setOptionString("stream-reconnect", "yes") // Reconnect if stream drops
MPVLib.setOptionString("tls-verify", "no")
MPVLib.setOptionString("http-reconnect", "yes")
MPVLib.setOptionString("stream-reconnect", "yes")
// CRITICAL: HLS demuxer options for proper VOD stream handling
// Without these, HLS streams may be treated as live and start from the end
// Note: Multiple lavf options separated by comma
MPVLib.setOptionString("demuxer-lavf-o", "live_start_index=0,prefer_x_start=1,http_persistent=0")
MPVLib.setOptionString("demuxer-seekable-cache", "yes") // Allow seeking in cached content
MPVLib.setOptionString("force-seekable", "yes") // Force stream to be seekable
MPVLib.setOptionString("demuxer-seekable-cache", "yes")
MPVLib.setOptionString("force-seekable", "yes")
// Increase probe/analyze duration to help detect full HLS duration
MPVLib.setOptionString("demuxer-lavf-probesize", "10000000") // 10MB probe size
MPVLib.setOptionString("demuxer-lavf-analyzeduration", "10") // 10 seconds analyze
MPVLib.setOptionString("sub-auto", "fuzzy")
MPVLib.setOptionString("sub-visibility", "yes")
MPVLib.setOptionString("sub-font-size", "48")
MPVLib.setOptionString("sub-pos", "100")
MPVLib.setOptionString("sub-color", "#FFFFFFFF")
MPVLib.setOptionString("sub-border-size", "3")
MPVLib.setOptionString("sub-border-color", "#FF000000")
MPVLib.setOptionString("sub-shadow-offset", "2")
MPVLib.setOptionString("sub-shadow-color", "#80000000")
// Subtitle configuration - CRITICAL for Android
MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles
MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default
MPVLib.setOptionString("sub-font-size", "48") // Larger font size for mobile readability
MPVLib.setOptionString("sub-pos", "95") // Position at bottom (0-100, 100 = very bottom)
MPVLib.setOptionString("sub-color", "#FFFFFFFF") // White color
MPVLib.setOptionString("sub-border-size", "3") // Thicker border for readability
MPVLib.setOptionString("sub-border-color", "#FF000000") // Black border
MPVLib.setOptionString("sub-shadow-offset", "2") // Add shadow for better visibility
MPVLib.setOptionString("sub-shadow-color", "#80000000") // Semi-transparent black shadow
// Font configuration - point to Android system fonts for all language support
MPVLib.setOptionString("osd-fonts-dir", "/system/fonts")
MPVLib.setOptionString("sub-fonts-dir", "/system/fonts")
MPVLib.setOptionString("sub-font", "Roboto") // Default fallback font
// Allow embedded fonts in ASS/SSA but fallback to system fonts
MPVLib.setOptionString("sub-font", "Roboto")
MPVLib.setOptionString("embeddedfonts", "yes")
// Language/encoding support for various subtitle formats
MPVLib.setOptionString("sub-codepage", "auto") // Auto-detect encoding (supports UTF-8, Latin, CJK, etc.)
MPVLib.setOptionString("sub-codepage", "auto")
MPVLib.setOptionString("osc", "no") // Disable on screen controller
MPVLib.setOptionString("osd-level", "1")
// Critical for subtitle rendering on Android GPU
// blend-subtitles=no lets the GPU renderer handle subtitle overlay properly
MPVLib.setOptionString("blend-subtitles", "no")
MPVLib.setOptionString("sub-use-margins", "no")
// Use 'scale' to allow ASS styling but with our scale and font overrides
// This preserves styled subtitles while having font fallbacks
MPVLib.setOptionString("sub-ass-override", "scale")
MPVLib.setOptionString("sub-use-margins", "yes")
MPVLib.setOptionString("sub-ass-override", "force")
MPVLib.setOptionString("sub-scale", "1.0")
MPVLib.setOptionString("sub-fix-timing", "yes") // Fix timing for SRT subtitles
MPVLib.setOptionString("sub-fix-timing", "yes")
// Force subtitle rendering
MPVLib.setOptionString("sid", "auto") // Auto-select subtitle track
MPVLib.setOptionString("osc", "no")
MPVLib.setOptionString("osd-level", "1")
MPVLib.setOptionString("sid", "auto")
// Disable terminal/input
MPVLib.setOptionString("terminal", "no")
MPVLib.setOptionString("input-default-bindings", "no")
}
@ -342,6 +364,119 @@ class MPVView @JvmOverloads constructor(
}
}
// Subtitle Styling Methods
fun setSubtitleSize(size: Int) {
if (isMpvInitialized) {
Log.d(TAG, "Setting subtitle size: $size")
MPVLib.setPropertyInt("sub-font-size", size)
}
}
fun setSubtitleColor(color: String) {
if (isMpvInitialized) {
// MPV expects color in #AARRGGBB format, but we receive #RRGGBB
// Convert to MPV format with full opacity
val mpvColor = if (color.length == 7) "#FF${color.substring(1)}" else color
Log.d(TAG, "Setting subtitle color: $mpvColor")
MPVLib.setPropertyString("sub-color", mpvColor)
}
}
fun setSubtitleBackgroundColor(color: String, opacity: Float) {
if (isMpvInitialized) {
// Convert opacity (0-1) to hex (00-FF)
val alphaHex = (opacity * 255).toInt().coerceIn(0, 255).let {
String.format("%02X", it)
}
// MPV format: #AARRGGBB
val baseColor = if (color.startsWith("#")) color.substring(1) else color
val mpvColor = "#${alphaHex}${baseColor.takeLast(6)}"
Log.d(TAG, "Setting subtitle background: $mpvColor (opacity: $opacity)")
MPVLib.setPropertyString("sub-back-color", mpvColor)
}
}
fun setSubtitleBorderSize(size: Int) {
if (isMpvInitialized) {
Log.d(TAG, "Setting subtitle border size: $size")
MPVLib.setPropertyInt("sub-border-size", size)
}
}
fun setSubtitleBorderColor(color: String) {
if (isMpvInitialized) {
val mpvColor = if (color.length == 7) "#FF${color.substring(1)}" else color
Log.d(TAG, "Setting subtitle border color: $mpvColor")
MPVLib.setPropertyString("sub-border-color", mpvColor)
}
}
fun setSubtitleShadow(enabled: Boolean, offset: Int) {
if (isMpvInitialized) {
Log.d(TAG, "Setting subtitle shadow: enabled=$enabled, offset=$offset")
if (enabled) {
MPVLib.setPropertyInt("sub-shadow-offset", offset)
MPVLib.setPropertyString("sub-shadow-color", "#80000000")
} else {
MPVLib.setPropertyInt("sub-shadow-offset", 0)
}
}
}
fun setSubtitlePosition(pos: Int) {
if (isMpvInitialized) {
// sub-pos: 0=top, 100=bottom, can go beyond 100 for more offset
// UI sends bottomOffset (0=at bottom, higher=more up from bottom)
// Convert: MPV pos = 100 - (bottomOffset / screenHeightFactor)
// Simplified: just pass pos directly, UI should convert
Log.d(TAG, "Setting subtitle position: $pos")
MPVLib.setPropertyInt("sub-pos", pos)
}
}
fun setSubtitleDelay(delaySec: Double) {
if (isMpvInitialized) {
Log.d(TAG, "Setting subtitle delay: $delaySec seconds")
MPVLib.setPropertyDouble("sub-delay", delaySec)
}
}
fun setSubtitleScale(scale: Double) {
if (isMpvInitialized) {
Log.d(TAG, "Setting subtitle scale: $scale")
MPVLib.setPropertyDouble("sub-scale", scale)
}
}
fun setSubtitleAlignment(align: String) {
if (isMpvInitialized) {
// MPV sub-justify values: left, center, right, auto
val mpvAlign = when (align) {
"left" -> "left"
"right" -> "right"
"center" -> "center"
else -> "center"
}
Log.d(TAG, "Setting subtitle alignment: $mpvAlign")
MPVLib.setPropertyString("sub-justify", mpvAlign)
}
}
fun setSubtitleBold(bold: Boolean) {
if (isMpvInitialized) {
Log.d(TAG, "Setting subtitle bold: $bold")
MPVLib.setPropertyString("sub-bold", if (bold) "yes" else "no")
}
}
fun setSubtitleItalic(italic: Boolean) {
if (isMpvInitialized) {
Log.d(TAG, "Setting subtitle italic: $italic")
MPVLib.setPropertyString("sub-italic", if (italic) "yes" else "no")
}
}
// MPVLib.EventObserver implementation
override fun eventProperty(property: String) {

View file

@ -181,8 +181,61 @@ class MpvPlayerViewManager(
}
}
@ReactProp(name = "useHardwareDecoding")
fun setUseHardwareDecoding(view: MPVView, useHardwareDecoding: Boolean) {
view.useHardwareDecoding = useHardwareDecoding
@ReactProp(name = "decoderMode")
fun setDecoderMode(view: MPVView, decoderMode: String?) {
view.decoderMode = decoderMode ?: "auto"
}
@ReactProp(name = "gpuMode")
fun setGpuMode(view: MPVView, gpuMode: String?) {
view.gpuMode = gpuMode ?: "gpu"
}
// Subtitle Styling Props
@ReactProp(name = "subtitleSize", defaultInt = 48)
fun setSubtitleSize(view: MPVView, size: Int) {
view.setSubtitleSize(size)
}
@ReactProp(name = "subtitleColor")
fun setSubtitleColor(view: MPVView, color: String?) {
view.setSubtitleColor(color ?: "#FFFFFF")
}
@ReactProp(name = "subtitleBackgroundOpacity", defaultFloat = 0.0f)
fun setSubtitleBackgroundOpacity(view: MPVView, opacity: Float) {
// Black background with user-specified opacity
view.setSubtitleBackgroundColor("#000000", opacity)
}
@ReactProp(name = "subtitleBorderSize", defaultInt = 3)
fun setSubtitleBorderSize(view: MPVView, size: Int) {
view.setSubtitleBorderSize(size)
}
@ReactProp(name = "subtitleBorderColor")
fun setSubtitleBorderColor(view: MPVView, color: String?) {
view.setSubtitleBorderColor(color ?: "#000000")
}
@ReactProp(name = "subtitleShadowEnabled", defaultBoolean = true)
fun setSubtitleShadowEnabled(view: MPVView, enabled: Boolean) {
view.setSubtitleShadow(enabled, if (enabled) 2 else 0)
}
@ReactProp(name = "subtitlePosition", defaultInt = 100)
fun setSubtitlePosition(view: MPVView, pos: Int) {
view.setSubtitlePosition(pos)
}
@ReactProp(name = "subtitleDelay", defaultFloat = 0.0f)
fun setSubtitleDelay(view: MPVView, delay: Float) {
view.setSubtitleDelay(delay.toDouble())
}
@ReactProp(name = "subtitleAlignment")
fun setSubtitleAlignment(view: MPVView, align: String?) {
view.setSubtitleAlignment(align ?: "center")
}
}

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

View file

@ -7,6 +7,7 @@ buildscript {
compileSdkVersion = 35
targetSdkVersion = 35
castFrameworkVersion = "22.1.0"
ndkVersion = "29.0.14206865" // Required for libmpv AAR built with NDK r29
}
repositories {
google()

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Nuvio",
"slug": "nuvio",
"version": "1.2.11",
"version": "1.3.5",
"orientation": "default",
"backgroundColor": "#020404",
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
@ -16,9 +16,8 @@
},
"ios": {
"supportsTablet": true,
"requireFullScreen": true,
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
"buildNumber": "26",
"buildNumber": "33",
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
@ -52,7 +51,7 @@
"android.permission.WRITE_SETTINGS"
],
"package": "com.nuvio.app",
"versionCode": 26,
"versionCode": 33,
"architectures": [
"arm64-v8a",
"armeabi-v7a",
@ -90,15 +89,14 @@
"receiverAppId": "CC1AD845",
"iosStartDiscoveryAfterFirstTapOnCastButton": true
}
],
"./plugins/mpv-bridge/withMpvBridge"
]
],
"updates": {
"enabled": true,
"checkAutomatically": "ON_ERROR_RECOVERY",
"fallbackToCacheTimeout": 30000,
"url": "https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest"
"url": "https://ota.nuvioapp.space/api/manifest"
},
"runtimeVersion": "1.2.11"
"runtimeVersion": "1.3.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

BIN
assets/nuviotext.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -1,8 +1,41 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Platform, Animated, TouchableWithoutFeedback, View } from 'react-native';
import Video, { VideoRef, SelectedTrack, BufferingStrategyType, ResizeMode } from 'react-native-video';
import RNImmersiveMode from 'react-native-immersive-mode';
// Subtitle style configuration interface - matches ExoPlayer's SubtitleStyle
export interface SubtitleStyleConfig {
// Font size in SP (scale-independent pixels) for subtitle text
// Default: -1 (uses system default)
fontSize?: number;
// Padding values in pixels
paddingTop?: number;
paddingBottom?: number;
paddingLeft?: number;
paddingRight?: number;
// Opacity of subtitles (0.0 to 1.0)
// 0 = hidden, 1 = fully visible
opacity?: number;
// Whether subtitles should follow video position when video is resized
// true = subtitles stay within video bounds
// false = subtitles can extend beyond video bounds
subtitlesFollowVideo?: boolean;
}
// Default subtitle style configuration
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
fontSize: 18,
paddingTop: 0,
paddingBottom: 60,
paddingLeft: 16,
paddingRight: 16,
opacity: 1,
subtitlesFollowVideo: true,
};
interface VideoPlayerProps {
src: string;
headers?: { [key: string]: string };
@ -12,6 +45,8 @@ interface VideoPlayerProps {
selectedAudioTrack?: SelectedTrack;
selectedTextTrack?: SelectedTrack;
resizeMode?: ResizeMode;
// Subtitle customization - pass custom subtitle styling
subtitleStyle?: SubtitleStyleConfig;
onProgress?: (data: { currentTime: number; playableDuration: number }) => void;
onLoad?: (data: { duration: number }) => void;
onError?: (error: any) => void;
@ -29,6 +64,7 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
selectedAudioTrack,
selectedTextTrack,
resizeMode = 'contain' as ResizeMode,
subtitleStyle: customSubtitleStyle,
onProgress,
onLoad,
onError,
@ -41,6 +77,12 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
const [isSeeking, setIsSeeking] = useState(false);
const [lastSeekTime, setLastSeekTime] = useState<number>(0);
// Merge custom subtitle style with defaults
const subtitleStyle = useMemo(() => ({
...DEFAULT_SUBTITLE_STYLE,
...customSubtitleStyle,
}), [customSubtitleStyle]);
// Enable immersive mode when video player mounts, disable when it unmounts
useEffect(() => {
if (Platform.OS === 'android') {
@ -132,13 +174,21 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
rate={1.0}
repeat={false}
reportBandwidth={true}
textTracks={[]}
useTextureView={false}
useTextureView={true}
disableFocus={false}
minLoadRetryCount={3}
automaticallyWaitsToMinimizeStalling={true}
hideShutterView={false}
shutterColor="#000000"
subtitleStyle={{
fontSize: subtitleStyle.fontSize,
paddingTop: subtitleStyle.paddingTop,
paddingBottom: subtitleStyle.paddingBottom,
paddingLeft: subtitleStyle.paddingLeft,
paddingRight: subtitleStyle.paddingRight,
opacity: subtitleStyle.opacity,
subtitlesFollowVideo: subtitleStyle.subtitlesFollowVideo,
}}
/>
);
};

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

2114
index.html

File diff suppressed because it is too large Load diff

View file

@ -1,55 +0,0 @@
//
// KSPlayerManager.m
// Nuvio
//
// Created by KSPlayer integration
//
#import <React/RCTViewManager.h>
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface RCT_EXTERN_MODULE(KSPlayerViewManager, RCTViewManager)
RCT_EXPORT_VIEW_PROPERTY(source, NSDictionary)
RCT_EXPORT_VIEW_PROPERTY(paused, BOOL)
RCT_EXPORT_VIEW_PROPERTY(volume, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(rate, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(audioTrack, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(textTrack, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(allowsExternalPlayback, BOOL)
RCT_EXPORT_VIEW_PROPERTY(usesExternalPlaybackWhileExternalScreenIsActive, BOOL)
RCT_EXPORT_VIEW_PROPERTY(subtitleBottomOffset, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(subtitleFontSize, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString)
// Event properties
RCT_EXPORT_VIEW_PROPERTY(onLoad, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onProgress, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onBuffering, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onEnd, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onError, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onBufferingProgress, RCTDirectEventBlock)
RCT_EXTERN_METHOD(seek:(nonnull NSNumber *)node toTime:(nonnull NSNumber *)time)
RCT_EXTERN_METHOD(setSource:(nonnull NSNumber *)node source:(nonnull NSDictionary *)source)
RCT_EXTERN_METHOD(setPaused:(nonnull NSNumber *)node paused:(BOOL)paused)
RCT_EXTERN_METHOD(setVolume:(nonnull NSNumber *)node volume:(nonnull NSNumber *)volume)
RCT_EXTERN_METHOD(setPlaybackRate:(nonnull NSNumber *)node rate:(nonnull NSNumber *)rate)
RCT_EXTERN_METHOD(setAudioTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId)
RCT_EXTERN_METHOD(setTextTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId)
RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(setAllowsExternalPlayback:(nonnull NSNumber *)node allows:(BOOL)allows)
RCT_EXTERN_METHOD(setUsesExternalPlaybackWhileExternalScreenIsActive:(nonnull NSNumber *)node uses:(BOOL)uses)
RCT_EXTERN_METHOD(getAirPlayState:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)node)
@end
@interface RCT_EXTERN_MODULE(KSPlayerModule, RCTEventEmitter)
RCT_EXTERN_METHOD(getTracks:(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")
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,147 +0,0 @@
//
// KSPlayerViewManager.swift
// Nuvio
//
// Created by KSPlayer integration
//
import Foundation
import KSPlayer
import React
@objc(KSPlayerViewManager)
class KSPlayerViewManager: RCTViewManager {
// Not needed for RCTViewManager-based views; events are exported via Objective-C externs in KSPlayerManager.m
override func view() -> UIView! {
let view = KSPlayerView()
view.viewManager = self
return view
}
override static func requiresMainQueueSetup() -> Bool {
return true
}
override func constantsToExport() -> [AnyHashable : Any]! {
return [
"EventTypes": [
"onLoad": "onLoad",
"onProgress": "onProgress",
"onBuffering": "onBuffering",
"onEnd": "onEnd",
"onError": "onError",
"onBufferingProgress": "onBufferingProgress"
]
]
}
// No-op: events are sent via direct event blocks on the view
@objc func seek(_ node: NSNumber, toTime time: NSNumber) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.seek(to: TimeInterval(truncating: time))
}
}
}
@objc func setSource(_ node: NSNumber, source: NSDictionary) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.setSource(source)
}
}
}
@objc func setPaused(_ node: NSNumber, paused: Bool) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.setPaused(paused)
}
}
}
@objc func setVolume(_ node: NSNumber, volume: NSNumber) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.setVolume(Float(truncating: volume))
}
}
}
@objc func setPlaybackRate(_ node: NSNumber, rate: NSNumber) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.setPlaybackRate(Float(truncating: rate))
}
}
}
@objc func setAudioTrack(_ node: NSNumber, trackId: NSNumber) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.setAudioTrack(Int(truncating: trackId))
}
}
}
@objc func setTextTrack(_ node: NSNumber, trackId: NSNumber) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.setTextTrack(Int(truncating: trackId))
}
}
}
@objc func getTracks(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
let tracks = view.getAvailableTracks()
resolve(tracks)
} else {
reject("NO_VIEW", "KSPlayerView not found", nil)
}
}
}
// AirPlay methods
@objc func setAllowsExternalPlayback(_ node: NSNumber, allows: Bool) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.setAllowsExternalPlayback(allows)
}
}
}
@objc func setUsesExternalPlaybackWhileExternalScreenIsActive(_ node: NSNumber, uses: Bool) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.setUsesExternalPlaybackWhileExternalScreenIsActive(uses)
}
}
}
@objc func getAirPlayState(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
let airPlayState = view.getAirPlayState()
resolve(airPlayState)
} else {
reject("NO_VIEW", "KSPlayerView not found", nil)
}
}
}
@objc func showAirPlayPicker(_ node: NSNumber) {
print("[KSPlayerViewManager] showAirPlayPicker called for node: \(node)")
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
print("[KSPlayerViewManager] Found KSPlayerView, calling showAirPlayPicker")
view.showAirPlayPicker()
} else {
print("[KSPlayerViewManager] Could not find KSPlayerView for node: \(node)")
}
}
}
}

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 */; };
564F8559E25775FFA08707DA /* libPods-Nuvio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 18260C6F06D8D6DAF4C1D17E /* libPods-Nuvio.a */; };
9FBA88F42E86ECD700892850 /* KSPlayerViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */; };
9FBA88F52E86ECD700892850 /* KSPlayerModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */; };
9FBA88F62E86ECD700892850 /* KSPlayerManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F02E86ECD700892850 /* KSPlayerManager.m */; };
9FBA88F72E86ECD700892850 /* KSPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F22E86ECD700892850 /* KSPlayerView.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
D66ACCC72CB69F1FF14A2585 /* libPods-Nuvio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */
@ -24,16 +24,16 @@
13B07F961A680F5B00A75B9A /* Nuvio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nuvio.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Nuvio/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Nuvio/Info.plist; sourceTree = "<group>"; };
18260C6F06D8D6DAF4C1D17E /* libPods-Nuvio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Nuvio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
2118C3C63E4B7D66EAC534DE /* Pods-Nuvio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.debug.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.debug.xcconfig"; sourceTree = "<group>"; };
436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Nuvio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
49055D6E250FAFA21141FE49 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Nuvio/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.release.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.release.xcconfig"; sourceTree = "<group>"; };
6E007C0BAC8C453623E81663 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Nuvio/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
73BB213C2E9EEAC700EC03F8 /* NuvioRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = NuvioRelease.entitlements; path = Nuvio/NuvioRelease.entitlements; sourceTree = "<group>"; };
7F2FA62198C389C99926AA47 /* Pods-Nuvio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.release.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.release.xcconfig"; sourceTree = "<group>"; };
9FBA88F02E86ECD700892850 /* KSPlayerManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KSPlayerManager.m; sourceTree = "<group>"; };
9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSPlayerModule.swift; sourceTree = "<group>"; };
9FBA88F22E86ECD700892850 /* KSPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSPlayerView.swift; sourceTree = "<group>"; };
9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSPlayerViewManager.swift; sourceTree = "<group>"; };
904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.debug.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.debug.xcconfig"; sourceTree = "<group>"; };
9FBA88F02E86ECD700892850 /* KSPlayerManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ../KSPlayer/RNBridge/KSPlayerManager.m; sourceTree = "<group>"; };
9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerModule.swift; sourceTree = "<group>"; };
9FBA88F22E86ECD700892850 /* KSPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerView.swift; sourceTree = "<group>"; };
9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerViewManager.swift; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Nuvio/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
@ -46,7 +46,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
564F8559E25775FFA08707DA /* libPods-Nuvio.a in Frameworks */,
D66ACCC72CB69F1FF14A2585 /* libPods-Nuvio.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -76,7 +76,7 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
18260C6F06D8D6DAF4C1D17E /* libPods-Nuvio.a */,
436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */,
);
name = Frameworks;
sourceTree = "<group>";
@ -131,8 +131,8 @@
D90A3959C97EE9926C513293 /* Pods */ = {
isa = PBXGroup;
children = (
2118C3C63E4B7D66EAC534DE /* Pods-Nuvio.debug.xcconfig */,
7F2FA62198C389C99926AA47 /* Pods-Nuvio.release.xcconfig */,
904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */,
5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@ -152,15 +152,15 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Nuvio" */;
buildPhases = (
4A10611824FCBAA4C1793637 /* [CP] Check Pods Manifest.lock */,
3B2D9C1D63379C2F30AC0F2B /* [CP] Check Pods Manifest.lock */,
99A79B70155E84EE1FB7F466 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
9B977D89FE30470F8C59964C /* Upload Debug Symbols to Sentry */,
EE80421364369BBCA82253B9 /* [CP] Embed Pods Frameworks */,
778B42B39FEE5454E4D24252 /* [CP] Copy Pods Resources */,
9F740EE07B5F97C85979C145 /* [CP] Embed Pods Frameworks */,
550CD54859274FE505BA4957 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@ -234,7 +234,7 @@
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n/bin/sh `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'\"` `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
4A10611824FCBAA4C1793637 /* [CP] Check Pods Manifest.lock */ = {
3B2D9C1D63379C2F30AC0F2B /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -256,7 +256,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
778B42B39FEE5454E4D24252 /* [CP] Copy Pods Resources */ = {
550CD54859274FE505BA4957 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -406,7 +406,7 @@
shellPath = /bin/sh;
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
};
EE80421364369BBCA82253B9 /* [CP] Embed Pods Frameworks */ = {
9F740EE07B5F97C85979C145 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -449,13 +449,13 @@
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 2118C3C63E4B7D66EAC534DE /* Pods-Nuvio.debug.xcconfig */;
baseConfigurationReference = 904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Nuvio/Nuvio.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = NLXTHANK2N;
DEVELOPMENT_TEAM = 8QBDZ766S3;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
@ -476,6 +476,7 @@
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = Nuvio;
SUPPORTS_MACCATALYST = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@ -486,7 +487,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7F2FA62198C389C99926AA47 /* Pods-Nuvio.release.xcconfig */;
baseConfigurationReference = 5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
@ -506,8 +507,9 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
PRODUCT_NAME = Nuvio;
SUPPORTS_MACCATALYST = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";

View file

@ -1,103 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.11</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>com.nuvio.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+nuvio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>26</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require microphone access.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTRootViewBackgroundColor</key>
<integer>4278322180</integer>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.10</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>com.nuvio.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+nuvio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>29</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require microphone access.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTRootViewBackgroundColor</key>
<integer>4278322180</integer>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>SplashScreenBackground</string>
<key>UIImageName</key>
<string>SplashScreenLegacy</string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View file

@ -15,7 +15,7 @@
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
<view key="view" userInteractionEnabled="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EXPO-ContainerView" userLabel="ContainerView">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLegacy" image="SplashScreenLegacy" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
<rect key="frame" x="0" y="0" width="414" height="736"/>

View file

@ -11,6 +11,6 @@
<key>EXUpdatesRuntimeVersion</key>
<string>1.2.11</string>
<key>EXUpdatesURL</key>
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
<string>https://ota.nuvioapp.space/api/manifest</string>
</dict>
</plist>

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

@ -1 +0,0 @@
Subproject commit 8c4778b5aad441bb0449a7f9b3d6d827fd3d6a2a

@ -1 +0,0 @@
Subproject commit 118cd1ed3d498265e44230e5dbb015bdd59f9dad

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,46 @@
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
],
"versions": [
{
"version": "1.3.5",
"buildVersion": "33",
"date": "2026-01-09",
"localizedDescription": "## Update Notes\n\n### ExoPlayer Subtitle Fixes\nThis update mainly focuses on fixing multiple subtitle-related issues in ExoPlayer:\n- Fixed issue where **only the first subtitle index** was being rendered \n- Fixed subtitles **always showing background** due to Android native caption settings \n- Fixed subtitles being **rendered inside the player view** \n- Fixed **subtitle bottom offset** issues \n- Merged PR **#391** by **@saifshaikh1805** \n - Fixes issue **#301**\n\n### KSPlayer Improvements\n- Improved **subtitle behavior** in KSPlayer \n- Merged PR **#394** by **@AdityasahuX07**\n\n### Settings & Persistence\n- Implemented **save and load** functionality for **Discover settings**\n\nThis release improves subtitle rendering accuracy across players and adds better persistence for user preferences.",
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.5/app-release.apk",
"size": 25700000
},
{
"version": "1.3.4",
"buildVersion": "32",
"date": "2026-01-06",
"localizedDescription": "## Update Notes\n\n### Player & Playback\n- Fixed **Android player crashes with large files** when using ExoPlayer \n - Merged PR **#361** by **@chrisk325**\n\n### Trakt Improvements\n- Improved **Trakt Continue Watching** section for better accuracy and reliability\n\n### Internationalization\n- Added **multi-language support** across the app using **i18n** \n - More languages will be added through **community contributions** \n - ⚠️ **Arabic UI does not use RTL yet**. RTL support will be added in a future update\n\n### Stability & Fixes\n- Crash optimizations and internal stability improvements\n\nThis update focuses on improving playback stability, Trakt experience, and expanding language support.",
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.4/app-release.apk",
"size": 25700000
},
{
"version": "1.3.3",
"buildVersion": "31",
"date": "2026-01-01",
"localizedDescription": "# Nuvio Media Hub v1.3.3\n\n## Update Notes\n\n### Playback & Preferences\n- Added **default audio and subtitle track selection**\n\n### Plugins & Repositories\n- Added support for **multiple active repositories**\n- Improved **plugin fetch logic** for better reliability and performance\n- Changed OTA server.\n\n### Trakt & Metadata Fixes\n- Fixed **TMDB enrichment logic**\n- Fixed **Trakt watch progress not syncing** for older seasons \n - Contributed by **@chrisk325** \n - Fixes #331 and closes #233 \n - ⚠️ It is recommended to **log out and log back into Trakt** inside the app to correctly reflect watched status for older seasons\n\n### UI Improvements & Bug Fixes\n- Minor UI refinements and bug fixes \n- Added **YouTube-style press-and-hold playback speed indicator** \n- Refined **gesture indicator pill** \n- Fixed **OSC not auto-hiding on Android** \n - Contributed by **@AdityasahuX07** \n - Fixes #326 and #298 \n\nThis release includes valuable contributions from the community and focuses on improving playback preferences, plugin handling, Trakt syncing, and overall UI polish.",
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.3.3/Stable_1-3-3.ipa",
"size": 25700000
},
{
"version": "1.3.2",
"buildVersion": "30",
"date": "2025-12-28",
"localizedDescription": "# Nuvio Media Hub v1.3.2\n\n### New Features\n- Added **Skip Intro** feature powered by **IntroDB** \n- Added support for **Internal Subtitle customization** for both **MPV** and **ExoPlayer** \n - ExoPlayer customization is currently limited \n\n### Stability & Fixes\n- Improved **StreamScreen error handling** to prevent crashes \n- Minor bug fixes and internal improvements",
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.3.2/Stable_1-3-2.ipa",
"size": 25700000
},
{
"version": "1.3.0",
"buildVersion": "28",
"date": "2025-12-24",
"localizedDescription": "# ⚠️ Important Notice Before Updating to v1.3.0\n\nBefore updating, please read this carefully.\n\nEspecially for **Android users**, this update is **not mandatory**.\nWe have **completely migrated the internal player** from **ExoPlayer + VLC** to **MPV Player**.\nBecause this is a major internal change, **unexpected bugs may occur**.\n\nThis update is **recommended only for users who are willing to test and provide feedback**.\nYour feedback is important to help complete this migration and make it bug-free.\n\n---\n\n# Nuvio Media Hub v1.3.0\n\n## Android\n- Replaced internal player with **MPV Player** for better codec support\n- Added toggles for **Software (SW) / Hardware (HW) decoding**\n\n## Global\n- Dependency updates\n- Minor bug fixes",
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.3.0/Stable_1-3-0.ipa",
"size": 25700000
},
{
"version": "1.2.11",
"buildVersion": "27",

181
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",
@ -44,14 +45,14 @@
"expo-brightness": "~14.0.7",
"expo-clipboard": "~8.0.8",
"expo-crypto": "~15.0.7",
"expo-dev-client": "~6.0.15",
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.9",
"expo-document-picker": "~14.0.7",
"expo-file-system": "~19.0.17",
"expo-glass-effect": "~0.1.4",
"expo-haptics": "~15.0.7",
"expo-intent-launcher": "~13.0.7",
"expo-libvlc-player": "^2.2.3",
"expo-keep-awake": "~15.0.8",
"expo-linear-gradient": "~15.0.7",
"expo-localization": "~17.0.7",
"expo-navigation-bar": "~5.0.10",
@ -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",
@ -6512,17 +6559,6 @@
"react": "*"
}
},
"node_modules/expo-libvlc-player": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/expo-libvlc-player/-/expo-libvlc-player-2.2.5.tgz",
"integrity": "sha512-Hl0XiRNK5iwPMDRWYouA7+Xzf804GZ/AMVTU87ktUlQMU5bgTUFgmi8QjlOLGEF5LpVp7LDFfQwsDpXP1ggpag==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-linear-gradient": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz",
@ -7473,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",
@ -7584,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",
@ -7711,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",
@ -10493,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",
@ -10505,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",
@ -11318,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",
@ -13197,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",
@ -44,13 +45,14 @@
"expo-brightness": "~14.0.7",
"expo-clipboard": "~8.0.8",
"expo-crypto": "~15.0.7",
"expo-dev-client": "~6.0.15",
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.9",
"expo-document-picker": "~14.0.7",
"expo-file-system": "~19.0.17",
"expo-glass-effect": "~0.1.4",
"expo-haptics": "~15.0.7",
"expo-intent-launcher": "~13.0.7",
"expo-keep-awake": "~15.0.8",
"expo-linear-gradient": "~15.0.7",
"expo-localization": "~17.0.7",
"expo-navigation-bar": "~5.0.10",
@ -62,10 +64,14 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"i18next": "^25.7.3",
"intl-pluralrules": "^2.0.1",
"lodash": "^4.17.21",
"lottie-react-native": "~7.3.1",
"posthog-react-native": "^4.4.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.5.1",
"react-native": "0.81.4",
"react-native-boost": "^0.6.2",
"react-native-bottom-tabs": "^1.0.2",
@ -85,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"
@ -99,8 +105,8 @@
"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
}
}

View file

@ -1,14 +1,58 @@
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/results.bin
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/results.bin
new file mode 100644
index 0000000..0d259dd
--- /dev/null
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/results.bin
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/results.bin
@@ -0,0 +1 @@
+o/classes
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/transformed/classes/classes_dex/classes.dex
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/transformed/classes/classes_dex/classes.dex
new file mode 100644
index 0000000..f27c56f
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/cde462de23ae0e930ed29ed683a0d845/transformed/classes/classes_dex/classes.dex differ
index 0000000..83d6ed6
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/028370e4575f206e9ebac60013572605/transformed/classes/classes_dex/classes.dex differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/results.bin
new file mode 100644
index 0000000..0d259dd
--- /dev/null
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/results.bin
@@ -0,0 +1 @@
+o/classes
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/transformed/classes/classes_dex/classes.dex
new file mode 100644
index 0000000..5c0a147
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/41904b52a493ec37d5f38c060e4ae4c7/transformed/classes/classes_dex/classes.dex differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/results.bin
new file mode 100644
index 0000000..0d259dd
--- /dev/null
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/results.bin
@@ -0,0 +1 @@
+o/classes
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/transformed/classes/classes_dex/classes.dex
new file mode 100644
index 0000000..e74c2bf
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4ab1c81df82d5078216fa3a2e3e201e5/transformed/classes/classes_dex/classes.dex differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/results.bin
new file mode 100644
index 0000000..0d259dd
--- /dev/null
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/results.bin
@@ -0,0 +1 @@
+o/classes
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/transformed/classes/classes_dex/classes.dex
new file mode 100644
index 0000000..d25d1d1
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/4e62ae45002eade3f68471ae8fe8d84f/transformed/classes/classes_dex/classes.dex differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/results.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/results.bin
new file mode 100644
index 0000000..0d259dd
--- /dev/null
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/results.bin
@@ -0,0 +1 @@
+o/classes
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/transformed/classes/classes_dex/classes.dex b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/transformed/classes/classes_dex/classes.dex
new file mode 100644
index 0000000..28daa05
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/.transforms/6db26500d91cbce8361f70f1540f2ea8/transformed/classes/classes_dex/classes.dex differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/generated/source/buildConfig/debug/com/brentvatne/react/BuildConfig.java b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/generated/source/buildConfig/debug/com/brentvatne/react/BuildConfig.java
new file mode 100644
index 0000000..b26a50e
@ -76,10 +120,6 @@ index 0000000..247891c
+ "elementType": "File"
+}
\ No newline at end of file
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/aar_main_jar/debug/syncDebugLibJars/classes.jar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/aar_main_jar/debug/syncDebugLibJars/classes.jar
new file mode 100644
index 0000000..1f4ce80
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/aar_main_jar/debug/syncDebugLibJars/classes.jar differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties
new file mode 100644
index 0000000..1211b1e
@ -100,12 +140,9 @@ index 0000000..9e26dfe
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/annotations_typedef_file/debug/extractDebugAnnotations/typedefs.txt b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/annotations_typedef_file/debug/extractDebugAnnotations/typedefs.txt
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar
new file mode 100644
index 0000000..9ec2b04
index 0000000..e5f72fd
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar
new file mode 100644
@ -169,17 +206,13 @@ diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda
new file mode 100644
index 0000000..bb5b5f9
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_exo_legacy_player_control_view.xml.flat differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug-mergeJavaRes/merge-state b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug-mergeJavaRes/merge-state
new file mode 100644
index 0000000..441a1d2
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug-mergeJavaRes/merge-state differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties
new file mode 100644
index 0000000..842471c
index 0000000..186e5bd
--- /dev/null
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties
@@ -0,0 +1,3 @@
+#Thu Oct 16 20:19:39 IST 2025
+#Sun Jan 11 19:30:05 IST 2026
+com.brentvatne.react.react-native-video-main-6\:/drawable/circle.xml=/Users/nayifnoushad/Documents/Projects/NuvioStreaming/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/packaged_res/debug/packageDebugResources/drawable/circle.xml
+com.brentvatne.react.react-native-video-main-6\:/layout/exo_legacy_player_control_view.xml=/Users/nayifnoushad/Documents/Projects/NuvioStreaming/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/packaged_res/debug/packageDebugResources/layout/exo_legacy_player_control_view.xml
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml
@ -283,31 +316,31 @@ index 0000000..2f92e20
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/androidx/media3/exoplayer/rtsp/RtspMediaSource.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$1.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$1.class
new file mode 100644
index 0000000..a1368f5
index 0000000..2a5b980
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$1.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$2.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$2.class
new file mode 100644
index 0000000..ec7b745
index 0000000..750c145
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$2.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$3.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$3.class
new file mode 100644
index 0000000..598654a
index 0000000..19b4277
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$3.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$4.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$4.class
new file mode 100644
index 0000000..803aefe
index 0000000..9f6af89
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$4.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$OnAudioFocusChangedListener.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$OnAudioFocusChangedListener.class
new file mode 100644
index 0000000..6ec4ad1
index 0000000..99b4560
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$OnAudioFocusChangedListener.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$RNVLoadControl.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$RNVLoadControl.class
new file mode 100644
index 0000000..22d8796
index 0000000..077afc1
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView$RNVLoadControl.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView.class
new file mode 100644
index 0000000..53eee7f
index 0000000..4d14dd4
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/exoplayer/ReactExoplayerView.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/react/BuildConfig.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/brentvatne/react/BuildConfig.class
new file mode 100644
@ -414,10 +447,6 @@ index 0000000..2300097
+5 <uses-sdk android:minSdkVersion="24" />
+6
+7</manifest>
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/merged_java_res/debug/mergeDebugJavaResource/feature-react-native-video.jar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/merged_java_res/debug/mergeDebugJavaResource/feature-react-native-video.jar
new file mode 100644
index 0000000..87d384d
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/merged_java_res/debug/mergeDebugJavaResource/feature-react-native-video.jar differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml
new file mode 100644
index 0000000..728c5a9
@ -628,7 +657,7 @@ index 0000000..e8dd9e4
\ No newline at end of file
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar
new file mode 100644
index 0000000..e65b062
index 0000000..1f68b89
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt
new file mode 100644
@ -683,7 +712,7 @@ index 0000000..1cb95ed
+style ExoMediaButton_FullScreen
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab
new file mode 100644
index 0000000..7df5bcf
index 0000000..6eb806a
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream
new file mode 100644
@ -699,7 +728,7 @@ index 0000000..41d6c24
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at
new file mode 100644
index 0000000..d0eda54
index 0000000..c7f2806
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i
new file mode 100644
@ -711,7 +740,7 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab
new file mode 100644
index 0000000..532458c
index 0000000..d038dce
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream
new file mode 100644
@ -727,7 +756,7 @@ index 0000000..bba171d
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at
new file mode 100644
index 0000000..5d6b64a
index 0000000..9a58d92
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i
new file mode 100644
@ -739,7 +768,7 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab
new file mode 100644
index 0000000..8178530
index 0000000..e70d24d
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream
new file mode 100644
@ -755,7 +784,7 @@ index 0000000..bba171d
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at
new file mode 100644
index 0000000..6a45a65
index 0000000..23ddf3d
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i
new file mode 100644
@ -795,7 +824,7 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab
new file mode 100644
index 0000000..c3975e3
index 0000000..daeef6b
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream
new file mode 100644
@ -811,7 +840,7 @@ index 0000000..b7d7395
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at
new file mode 100644
index 0000000..e3911a1
index 0000000..3412003
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i
new file mode 100644
@ -851,7 +880,7 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab
new file mode 100644
index 0000000..c626f49
index 0000000..409b10e
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream
new file mode 100644
@ -867,19 +896,19 @@ index 0000000..882f24f
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values
new file mode 100644
index 0000000..2ff3eb7
index 0000000..8cabf48
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at
new file mode 100644
index 0000000..af661f7
index 0000000..6e6f502
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.s b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.s
new file mode 100644
index 0000000..b6551b9
index 0000000..67a228d
--- /dev/null
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.s
@@ -0,0 +1 @@
+ÏÒ
+ÏÒñÐãÊÃÊ
\ No newline at end of file
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i
new file mode 100644
@ -891,7 +920,7 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab
new file mode 100644
index 0000000..85d9216
index 0000000..1298c8c
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream
new file mode 100644
@ -907,7 +936,7 @@ index 0000000..41d6c24
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at
new file mode 100644
index 0000000..dfc60fb
index 0000000..7c991ce
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i
new file mode 100644
@ -919,7 +948,7 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab
new file mode 100644
index 0000000..95c2c01
index 0000000..5a73f92
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream
new file mode 100644
@ -935,7 +964,7 @@ index 0000000..385642d
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at
new file mode 100644
index 0000000..195d865
index 0000000..119e277
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i
new file mode 100644
@ -947,7 +976,7 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab
new file mode 100644
index 0000000..d98dace
index 0000000..52ad30e
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream
new file mode 100644
@ -963,7 +992,7 @@ index 0000000..42df8b9
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at
new file mode 100644
index 0000000..bc345c6
index 0000000..8157519
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i
new file mode 100644
@ -975,16 +1004,16 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab
new file mode 100644
index 0000000..672070d
index 0000000..1c94f4d
--- /dev/null
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab
@@ -0,0 +1,2 @@
+44
+49
+0
\ No newline at end of file
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab
new file mode 100644
index 0000000..1878d5a
index 0000000..b2fe254
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream
new file mode 100644
@ -1000,7 +1029,7 @@ index 0000000..41d6c24
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at
new file mode 100644
index 0000000..d3fcc7e
index 0000000..d69b273
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i
new file mode 100644
@ -1012,27 +1041,27 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab
new file mode 100644
index 0000000..04aeda4
index 0000000..bfd339b
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream
new file mode 100644
index 0000000..132a271
index 0000000..5ecc69a
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len
new file mode 100644
index 0000000..79ad34c
index 0000000..1c209ae
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len
new file mode 100644
index 0000000..41d6c24
index 0000000..31ed51e
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at
new file mode 100644
index 0000000..c834290
index 0000000..17f0ceb
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i
new file mode 100644
index 0000000..1601c02
index 0000000..c49a939
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len
new file mode 100644
@ -1040,27 +1069,39 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab
new file mode 100644
index 0000000..5fc703e
index 0000000..cb83174
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream
new file mode 100644
index 0000000..39f4cac
index 0000000..9652ad6
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len
new file mode 100644
index 0000000..e381b23
index 0000000..33b23e4
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len
new file mode 100644
index 0000000..35ed991
index 0000000..47e3ec3
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values
new file mode 100644
index 0000000..19d0fa5
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at
new file mode 100644
index 0000000..a262c4a
index 0000000..00fa3dd
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.s b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.s
new file mode 100644
index 0000000..9ae424c
--- /dev/null
+++ b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.s
@@ -0,0 +1 @@
+ì‡
\ No newline at end of file
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i
new file mode 100644
index 0000000..1bfa622
index 0000000..ab80614
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len
new file mode 100644
@ -1068,16 +1109,16 @@ index 0000000..131e265
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/last-build.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/last-build.bin
new file mode 100644
index 0000000..1fe138d
index 0000000..dcaf58a
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/cacheable/last-build.bin differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin
new file mode 100644
index 0000000..fb7ec91
index 0000000..d7d62e0
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/outputs/aar/react-native-video-debug.aar b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/outputs/aar/react-native-video-debug.aar
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/local-state/build-history.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/local-state/build-history.bin
new file mode 100644
index 0000000..a704fe4
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/outputs/aar/react-native-video-debug.aar differ
index 0000000..ee3ec97
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/kotlin/compileDebugKotlin/local-state/build-history.bin differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/outputs/logs/manifest-merger-debug-report.txt b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/outputs/logs/manifest-merger-debug-report.txt
new file mode 100644
index 0000000..a388215
@ -1100,9 +1141,37 @@ index 0000000..a388215
+ INJECTED from /Users/nayifnoushad/Documents/Projects/NuvioStreaming/node_modules/react-native-video/android/src/main/AndroidManifestNew.xml
+ android:minSdkVersion
+ INJECTED from /Users/nayifnoushad/Documents/Projects/NuvioStreaming/node_modules/react-native-video/android/src/main/AndroidManifestNew.xml
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$1.class.uniqueId3 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$1.class.uniqueId3
new file mode 100644
index 0000000..2a5b980
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$1.class.uniqueId3 differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$2.class.uniqueId5 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$2.class.uniqueId5
new file mode 100644
index 0000000..750c145
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$2.class.uniqueId5 differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$3.class.uniqueId1 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$3.class.uniqueId1
new file mode 100644
index 0000000..19b4277
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$3.class.uniqueId1 differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$4.class.uniqueId6 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$4.class.uniqueId6
new file mode 100644
index 0000000..9f6af89
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$4.class.uniqueId6 differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$OnAudioFocusChangedListener.class.uniqueId2 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$OnAudioFocusChangedListener.class.uniqueId2
new file mode 100644
index 0000000..99b4560
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$OnAudioFocusChangedListener.class.uniqueId2 differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$RNVLoadControl.class.uniqueId4 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$RNVLoadControl.class.uniqueId4
new file mode 100644
index 0000000..077afc1
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView$RNVLoadControl.class.uniqueId4 differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView.class.uniqueId0 b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView.class.uniqueId0
new file mode 100644
index 0000000..4d14dd4
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/ReactExoplayerView.class.uniqueId0 differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin
new file mode 100644
index 0000000..b63c0e3
index 0000000..0f0a9cf
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/META-INF/react-native-video_debug.kotlin_module b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/META-INF/react-native-video_debug.kotlin_module
new file mode 100644
@ -1214,11 +1283,11 @@ index 0000000..763511c
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/Source.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle$Companion.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle$Companion.class
new file mode 100644
index 0000000..1ef82f1
index 0000000..efa138a
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle$Companion.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle.class
new file mode 100644
index 0000000..4ebb38c
index 0000000..c328ffb
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/SubtitleStyle.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/TimedMetadata.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/common/api/TimedMetadata.class
new file mode 100644
@ -1314,15 +1383,15 @@ index 0000000..7d79368
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/DefaultReactExoplayerConfig.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$Companion.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$Companion.class
new file mode 100644
index 0000000..345dd50
index 0000000..7c35b62
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$Companion.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$playerListener$1.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$playerListener$1.class
new file mode 100644
index 0000000..4dcc80e
index 0000000..055451a
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView$playerListener$1.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView.class
new file mode 100644
index 0000000..df0ace7
index 0000000..be801e5
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/ExoPlayerView.class differ
diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/FullScreenPlayerView$KeepScreenOnUpdater$Companion.class b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/exoplayer/FullScreenPlayerView$KeepScreenOnUpdater$Companion.class
new file mode 100644
@ -1452,11 +1521,377 @@ diff --git a/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda
new file mode 100644
index 0000000..0143f64
Binary files /dev/null and b/node_modules/react-native-video/android/buildOutput_a15d4dee7fc4eda61b91308cbb6a2e72/tmp/kotlin-classes/debug/com/brentvatne/receiver/PictureInPictureReceiver.class differ
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
index 1ac0fd0..953eb59 100644
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
@@ -1,5 +1,6 @@
package com.brentvatne.common.api
+import android.graphics.Color
import com.brentvatne.common.toolbox.ReactBridgeUtils
import com.facebook.react.bridge.ReadableMap
@@ -22,6 +23,17 @@ class SubtitleStyle public constructor() {
var subtitlesFollowVideo = true
private set
+ // Extended styling (used by ExoPlayerView via Media3 SubtitleView)
+ // Stored as Android color ints to avoid parsing multiple times.
+ var textColor: Int? = null
+ private set
+ var backgroundColor: Int? = null
+ private set
+ var edgeType: String? = null
+ private set
+ var edgeColor: Int? = null
+ private set
+
companion object {
private const val PROP_FONT_SIZE_TRACK = "fontSize"
private const val PROP_PADDING_BOTTOM = "paddingBottom"
@@ -31,6 +43,21 @@ class SubtitleStyle public constructor() {
private const val PROP_OPACITY = "opacity"
private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo"
+ // Extended props (optional)
+ private const val PROP_TEXT_COLOR = "textColor"
+ private const val PROP_BACKGROUND_COLOR = "backgroundColor"
+ private const val PROP_EDGE_TYPE = "edgeType"
+ private const val PROP_EDGE_COLOR = "edgeColor"
+
+ private fun parseColorOrNull(value: String?): Int? {
+ if (value.isNullOrBlank()) return null
+ return try {
+ Color.parseColor(value)
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
+
@JvmStatic
fun parse(src: ReadableMap?): SubtitleStyle {
val subtitleStyle = SubtitleStyle()
@@ -41,6 +68,13 @@ class SubtitleStyle public constructor() {
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true)
+
+ // Extended styling
+ subtitleStyle.textColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_TEXT_COLOR, null))
+ subtitleStyle.backgroundColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_BACKGROUND_COLOR, null))
+ subtitleStyle.edgeType = ReactBridgeUtils.safeGetString(src, PROP_EDGE_TYPE, null)
+ subtitleStyle.edgeColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_EDGE_COLOR, null))
+
return subtitleStyle
}
}
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
index bb945fe..2d3f8ca 100644
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
@@ -10,11 +10,14 @@ import android.widget.FrameLayout
import android.widget.TextView
import androidx.media3.common.Player
import androidx.media3.common.Timeline
+import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
+import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
+import androidx.media3.ui.SubtitleView
import com.brentvatne.common.api.ResizeMode
import com.brentvatne.common.api.SubtitleStyle
@@ -52,15 +55,58 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
+ /**
+ * Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame).
+ * This keeps subtitles anchored in-place even when the video surface/content frame moves
+ * due to aspect ratio / resizeMode changes.
+ *
+ * Controlled by SubtitleStyle.subtitlesFollowVideo.
+ */
+ private val overlaySubtitleView = SubtitleView(context).apply {
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+ visibility = View.GONE
+ // We control styling via SubtitleStyle; don't pull Android system caption defaults.
+ setApplyEmbeddedStyles(true)
+ setApplyEmbeddedFontSizes(true)
+ }
+
+ private fun updateSubtitleRenderingMode() {
+ val internalSubtitleView = playerView.subtitleView
+ val followVideo = localStyle.subtitlesFollowVideo
+ val shouldShow = localStyle.opacity != 0.0f
+
+ if (followVideo) {
+ internalSubtitleView?.visibility = if (shouldShow) View.VISIBLE else View.GONE
+ overlaySubtitleView.visibility = View.GONE
+ } else {
+ // Hard-disable PlayerView's internal subtitle view. PlayerView can recreate/toggle this view
+ // during resize/layout, so we re-assert this in multiple lifecycle points.
+ internalSubtitleView?.visibility = View.GONE
+ internalSubtitleView?.alpha = 0f
+ overlaySubtitleView.visibility = if (shouldShow) View.VISIBLE else View.GONE
+ overlaySubtitleView.alpha = 1f
+ }
+ }
+
init {
// Add PlayerView with explicit layout parameters
val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
addView(playerView, playerViewLayoutParams)
+ // Add overlay subtitles above PlayerView (so it doesn't move with video content frame)
+ val subtitleOverlayLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+ addView(overlaySubtitleView, subtitleOverlayLayoutParams)
+
// Add live badge with its own layout parameters
val liveBadgeLayoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
liveBadgeLayoutParams.setMargins(16, 16, 16, 16)
addView(liveBadge, liveBadgeLayoutParams)
+
+ // PlayerView may internally recreate its subtitle view during relayouts (e.g. resizeMode changes).
+ // Ensure our rendering mode is re-applied whenever PlayerView lays out.
+ playerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ updateSubtitleRenderingMode()
+ }
}
fun setPlayer(player: ExoPlayer?) {
@@ -80,6 +126,10 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
playerView.resizeMode = resizeMode
}
}
+
+ // Re-assert subtitle rendering mode for the current style.
+ updateSubtitleRenderingMode()
+ applySubtitleStyle(localStyle)
}
fun getPlayerView(): PlayerView = playerView
@@ -108,23 +158,63 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
fun setSubtitleStyle(style: SubtitleStyle) {
+ localStyle = style
+ applySubtitleStyle(localStyle)
+ }
+
+ private fun applySubtitleStyle(style: SubtitleStyle) {
+ updateSubtitleRenderingMode()
+
playerView.subtitleView?.let { subtitleView ->
- // Reset to defaults
- subtitleView.setUserDefaultStyle()
- subtitleView.setUserDefaultTextSize()
+ // Important:
+ // Avoid inheriting Android system caption settings via setUserDefaultStyle(),
+ // because those can force a background/window that the app doesn't want.
+ val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
+ val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
+ val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
+
+ val resolvedEdgeType = when (style.edgeType?.lowercase()) {
+ "outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
+ "shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
+ else -> CaptionStyleCompat.EDGE_TYPE_NONE
+ }
+
+ // windowColor MUST be transparent to avoid the "caption window" background.
+ val captionStyle = CaptionStyleCompat(
+ resolvedTextColor,
+ resolvedBackgroundColor,
+ Color.TRANSPARENT,
+ resolvedEdgeType,
+ resolvedEdgeColor,
+ null
+ )
+ subtitleView.setStyle(captionStyle)
- // Apply custom styling
+ // Text size: if not provided, fall back to user default size.
if (style.fontSize > 0) {
- subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
+ // Use DIP so the value matches React Native's dp-based fontSize more closely.
+ // SP would multiply by system fontScale and makes "30" look larger than RN "30".
+ subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
+ } else {
+ subtitleView.setUserDefaultTextSize()
}
+ // Horizontal padding is still useful (safe area); vertical offset is handled via bottomPaddingFraction.
subtitleView.setPadding(
style.paddingLeft,
style.paddingTop,
style.paddingRight,
- style.paddingBottom
+ 0
)
+ // Bottom offset for *internal* subtitles:
+ // Use Media3 SubtitleView's bottomPaddingFraction (moves cues up) rather than raw view padding.
+ if (style.paddingBottom > 0 && playerView.height > 0) {
+ val fraction = (style.paddingBottom.toFloat() / playerView.height.toFloat())
+ .coerceIn(0f, 0.9f)
+ subtitleView.setBottomPaddingFraction(fraction)
+ }
+
if (style.opacity != 0.0f) {
subtitleView.alpha = style.opacity
subtitleView.visibility = android.view.View.VISIBLE
@@ -132,7 +222,59 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
subtitleView.visibility = android.view.View.GONE
}
}
- localStyle = style
+
+ // Apply the same styling to the overlay subtitle view.
+ run {
+ val subtitleView = overlaySubtitleView
+
+ val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
+ val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
+ val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
+
+ val resolvedEdgeType = when (style.edgeType?.lowercase()) {
+ "outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
+ "shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
+ else -> CaptionStyleCompat.EDGE_TYPE_NONE
+ }
+
+ val captionStyle = CaptionStyleCompat(
+ resolvedTextColor,
+ resolvedBackgroundColor,
+ Color.TRANSPARENT,
+ resolvedEdgeType,
+ resolvedEdgeColor,
+ null
+ )
+ subtitleView.setStyle(captionStyle)
+
+ if (style.fontSize > 0) {
+ // Use DIP so the value matches React Native's dp-based fontSize more closely.
+ subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
+ } else {
+ subtitleView.setUserDefaultTextSize()
+ }
+
+ subtitleView.setPadding(
+ style.paddingLeft,
+ style.paddingTop,
+ style.paddingRight,
+ 0
+ )
+
+ // Bottom offset relative to the full view height (stable even when video content frame moves).
+ val h = height.takeIf { it > 0 } ?: subtitleView.height
+ if (style.paddingBottom > 0 && h > 0) {
+ val fraction = (style.paddingBottom.toFloat() / h.toFloat())
+ .coerceIn(0f, 0.9f)
+ subtitleView.setBottomPaddingFraction(fraction)
+ } else {
+ subtitleView.setBottomPaddingFraction(0f)
+ }
+
+ if (style.opacity != 0.0f) {
+ subtitleView.alpha = style.opacity
+ }
+ }
}
fun setShutterColor(color: Int) {
@@ -223,6 +365,13 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
private val playerListener = object : Player.Listener {
+ override fun onCues(cueGroup: CueGroup) {
+ // Keep overlay subtitles in sync. This does NOT interfere with PlayerView's own subtitle rendering.
+ // When subtitlesFollowVideo=false, overlaySubtitleView is the visible one.
+ updateSubtitleRenderingMode()
+ overlaySubtitleView.setCues(cueGroup.cues)
+ }
+
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
playerView.post {
playerView.requestLayout()
@@ -284,6 +433,9 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
+ // Re-apply bottomPaddingFraction once we have a concrete height.
+ updateSubtitleRenderingMode()
+ applySubtitleStyle(localStyle)
}
}
}
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
index f175dec..87e436a 100644
index 539ecfd..54312f7 100644
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
@@ -726,7 +726,7 @@ public class ReactExoplayerView extends FrameLayout implements
@@ -161,10 +161,11 @@ public class ReactExoplayerView extends FrameLayout implements
AdEvent.AdEventListener,
AdErrorEvent.AdErrorListener {
- public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 1;
+ public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 0.5;
public static final double DEFAULT_MIN_BUFFER_MEMORY_RESERVE = 0;
private static final String TAG = "ReactExoplayerView";
+ private static final ExecutorService SHARED_EXECUTOR = Executors.newSingleThreadExecutor();
private static final CookieManager DEFAULT_COOKIE_MANAGER;
private static final int SHOW_PROGRESS = 1;
@@ -211,6 +212,7 @@ public class ReactExoplayerView extends FrameLayout implements
private float audioVolume = 1f;
private int maxBitRate = 0;
private boolean hasDrmFailed = false;
+ private int drmRetryCount = 0;
private boolean isUsingContentResolution = false;
private boolean selectTrackWhenReady = false;
private final Handler mainHandler;
@@ -243,7 +245,7 @@ public class ReactExoplayerView extends FrameLayout implements
private BufferingStrategy.BufferingStrategyEnum bufferingStrategy;
private boolean disableDisconnectError;
private boolean preventsDisplaySleepDuringVideoPlayback = true;
- private float mProgressUpdateInterval = 250.0f;
+ private float mProgressUpdateInterval = 1000.0f;
protected boolean playInBackground = false;
private boolean mReportBandwidth = false;
private boolean controls = false;
@@ -643,9 +645,12 @@ public class ReactExoplayerView extends FrameLayout implements
PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave);
}
if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) {
+ long requestedCacheSize = source.getBufferConfig().getCacheSize();
+ long MAX_SAFE_CACHE_SIZE = 100L * 1024 * 1024;
+ long effectiveCacheSize = Math.min(requestedCacheSize, MAX_SAFE_CACHE_SIZE);
RNVSimpleCache.INSTANCE.setSimpleCache(
this.getContext(),
- source.getBufferConfig().getCacheSize()
+ (int) effectiveCacheSize
);
useCache = true;
} else {
@@ -654,9 +659,10 @@ public class ReactExoplayerView extends FrameLayout implements
if (playerNeedsSource) {
// Will force display of shutter view if needed
exoPlayerView.invalidateAspectRatio();
+ drmRetryCount = 0;
+ hasDrmFailed = false;
// DRM session manager creation must be done on a different thread to prevent crashes so we start a new thread
- ExecutorService es = Executors.newSingleThreadExecutor();
- es.execute(() -> {
+ SHARED_EXECUTOR.execute(() -> {
// DRM initialization must run on a different thread
if (viewHasDropped && runningSource == source) {
return;
@@ -727,7 +733,7 @@ public class ReactExoplayerView extends FrameLayout implements
DefaultRenderersFactory renderersFactory =
new DefaultRenderersFactory(getContext())
@ -1465,3 +1900,110 @@ index f175dec..87e436a 100644
.setEnableDecoderFallback(true)
.forceEnableMediaCodecAsynchronousQueueing();
@@ -851,13 +857,10 @@ public class ReactExoplayerView extends FrameLayout implements
MediaSource mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, videoSource);
// wait for player to be set
- while (player == null) {
- try {
- wait();
- } catch (InterruptedException ex) {
- Thread.currentThread().interrupt();
- DebugLog.e(TAG, ex.toString());
- }
+ if (player == null) {
+ DebugLog.w(TAG, "Player not ready yet, aborting source initialization");
+ playerNeedsSource = true;
+ return;
}
boolean haveResumePosition = resumeWindow != C.INDEX_UNSET;
@@ -1484,8 +1487,7 @@ public class ReactExoplayerView extends FrameLayout implements
ArrayList<Track> textTracks = getTextTrackInfo();
if (source.getContentStartTime() != -1) {
- ExecutorService es = Executors.newSingleThreadExecutor();
- es.execute(() -> {
+ SHARED_EXECUTOR.execute(() -> {
// To prevent ANRs caused by getVideoTrackInfo we run this on a different thread and notify the player only when we're done
ArrayList<VideoTrack> videoTracks = getVideoTrackInfoFromManifest();
if (videoTracks != null) {
@@ -1596,12 +1598,11 @@ public class ReactExoplayerView extends FrameLayout implements
// We need retry count to in case where minefest request fails from poor network conditions
@WorkerThread
private ArrayList<VideoTrack> getVideoTrackInfoFromManifest(int retryCount) {
- ExecutorService es = Executors.newSingleThreadExecutor();
final DataSource dataSource = this.mediaDataSourceFactory.createDataSource();
final Uri sourceUri = source.getUri();
final long startTime = source.getContentStartTime() * 1000 - 100; // s -> ms with 100ms offset
- Future<ArrayList<VideoTrack>> result = es.submit(new Callable() {
+ Future<ArrayList<VideoTrack>> result = SHARED_EXECUTOR.submit(new Callable<ArrayList<VideoTrack>>() {
final DataSource ds = dataSource;
final Uri uri = sourceUri;
final long startTimeUs = startTime * 1000; // ms -> us
@@ -1648,7 +1649,6 @@ public class ReactExoplayerView extends FrameLayout implements
if (results == null && retryCount < 1) {
return this.getVideoTrackInfoFromManifest(++retryCount);
}
- es.shutdown();
return results;
} catch (Exception e) {
DebugLog.w(TAG, "error in getVideoTrackInfoFromManifest handling request:" + e.getMessage());
@@ -1939,12 +1939,15 @@ public class ReactExoplayerView extends FrameLayout implements
case PlaybackException.ERROR_CODE_DRM_UNSPECIFIED:
if (!hasDrmFailed) {
// When DRM fails to reach the app level certificate server it will fail with a source error so we assume that it is DRM related and try one more time
- hasDrmFailed = true;
- playerNeedsSource = true;
- updateResumePosition();
- initializePlayer();
- setPlayWhenReady(true);
- return;
+ if (drmRetryCount < 1) {
+ drmRetryCount++;
+ hasDrmFailed = true;
+ playerNeedsSource = true;
+ updateResumePosition();
+ initializePlayer();
+ setPlayWhenReady(true);
+ return;
+ }
}
break;
default:
@@ -2123,6 +2126,16 @@ public class ReactExoplayerView extends FrameLayout implements
if (textRendererIndex != C.INDEX_UNSET) {
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
boolean trackFound = false;
+ // NOTE:
+ // RNVideo emits textTracks as a flattened list (Track.index is assigned using textTracks.size()).
+ // However, previous logic compared the requested "index" against the *trackIndex within a group*,
+ // which makes any index > 0 either select the wrong subtitle or keep the first one.
+ // Here we interpret type="index" as the flattened index, matching the JS list order.
+ int targetFlatIndex = -1;
+ if ("index".equals(type)) {
+ targetFlatIndex = ReactBridgeUtils.safeParseInt(value, -1);
+ }
+ int flatIndex = 0;
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
TrackGroup group = groups.get(groupIndex);
@@ -2135,8 +2148,7 @@ public class ReactExoplayerView extends FrameLayout implements
} else if ("title".equals(type) && format.label != null && format.label.equals(value)) {
isMatch = true;
} else if ("index".equals(type)) {
- int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
- if (targetIndex == trackIndex) {
+ if (targetFlatIndex != -1 && targetFlatIndex == flatIndex) {
isMatch = true;
}
}
@@ -2148,6 +2160,7 @@ public class ReactExoplayerView extends FrameLayout implements
trackFound = true;
break;
}
+ flatIndex++;
}
if (trackFound) break;
}

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

@ -1,21 +1,20 @@
import React, { memo, useEffect, useState } from 'react';
import React, { memo, useEffect, useState, useCallback, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
ActivityIndicator,
FlatList,
Platform,
ScrollView,
TouchableOpacity,
} from 'react-native';
import { LegendList } from '@legendapp/list';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { BlurView as ExpoBlurView } from 'expo-blur';
import Animated, {
useSharedValue,
useAnimatedStyle,
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
Easing
@ -45,36 +44,36 @@ interface TabletStreamsLayoutProps {
metadata?: any;
type: string;
currentEpisode?: any;
// Movie logo props
movieLogoError: boolean;
setMovieLogoError: (error: boolean) => void;
// Stream-related props
streamsEmpty: boolean;
selectedProvider: string;
filterItems: Array<{ id: string; name: string; }>;
handleProviderChange: (provider: string) => void;
activeFetchingScrapers: string[];
// Loading states
isAutoplayWaiting: boolean;
autoplayTriggered: boolean;
showNoSourcesError: boolean;
showInitialLoading: boolean;
showStillFetching: boolean;
// Stream rendering props
sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null>;
renderSectionHeader: ({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => React.ReactElement;
handleStreamPress: (stream: Stream) => void;
openAlert: (title: string, message: string) => void;
// Settings and theme
settings: any;
currentTheme: any;
colors: any;
// Other props
navigation: RootStackNavigationProp;
insets: any;
@ -123,19 +122,19 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
hasStremioStreamProviders,
}) => {
const styles = React.useMemo(() => createStyles(colors), [colors]);
// Animation values for backdrop entrance
const backdropOpacity = useSharedValue(0);
const backdropScale = useSharedValue(1.05);
const [backdropLoaded, setBackdropLoaded] = useState(false);
const [backdropError, setBackdropError] = useState(false);
// Animation values for content panels
const leftPanelOpacity = useSharedValue(0);
const leftPanelTranslateX = useSharedValue(-30);
const rightPanelOpacity = useSharedValue(0);
const rightPanelTranslateX = useSharedValue(30);
// Get the backdrop source - prioritize episode thumbnail, then show backdrop, then poster
// For episodes without thumbnails, use show's backdrop instead of poster
const backdropSource = React.useMemo(() => {
@ -149,7 +148,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
backdropError
});
}
// If episodeImage failed to load, skip it and use backdrop
if (backdropError && episodeImage && episodeImage !== metadata?.poster) {
if (__DEV__) console.log('[TabletStreamsLayout] Episode thumbnail failed, falling back to backdrop');
@ -158,26 +157,55 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
return { uri: bannerImage };
}
}
// If episodeImage exists and is not the same as poster, use it (real episode thumbnail)
if (episodeImage && episodeImage !== metadata?.poster && !backdropError) {
if (__DEV__) console.log('[TabletStreamsLayout] Using episode thumbnail:', episodeImage);
return { uri: episodeImage };
}
// If episodeImage is the same as poster (fallback case), prioritize backdrop
if (bannerImage) {
if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop:', bannerImage);
return { uri: bannerImage };
}
// No fallback to poster images
if (__DEV__) console.log('[TabletStreamsLayout] No backdrop source found');
return undefined;
}, [episodeImage, bannerImage, metadata?.poster, backdropError]);
// Animate backdrop when it loads, or animate content immediately if no backdrop
useEffect(() => {
if (backdropSource?.uri && !backdropLoaded && !backdropError) {
const timeoutId = setTimeout(() => {
leftPanelOpacity.value = withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
});
leftPanelTranslateX.value = withTiming(0, {
duration: 600,
easing: Easing.out(Easing.cubic)
});
rightPanelOpacity.value = withDelay(200, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
rightPanelTranslateX.value = withDelay(200, withTiming(0, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
}, 1000);
return () => clearTimeout(timeoutId);
}
}, [backdropSource?.uri, backdropLoaded, backdropError]);
useEffect(() => {
if (backdropSource?.uri && backdropLoaded) {
// Animate backdrop first
@ -189,7 +217,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
duration: 1000,
easing: Easing.out(Easing.cubic)
});
// Animate content panels with delay after backdrop starts loading
leftPanelOpacity.value = withDelay(300, withTiming(1, {
duration: 600,
@ -199,7 +227,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
duration: 600,
easing: Easing.out(Easing.cubic)
}));
rightPanelOpacity.value = withDelay(500, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
@ -218,7 +246,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
duration: 600,
easing: Easing.out(Easing.cubic)
});
rightPanelOpacity.value = withDelay(200, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
@ -229,7 +257,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
}));
}
}, [backdropSource?.uri, backdropLoaded, backdropError]);
// Reset animation when episode changes
useEffect(() => {
backdropOpacity.value = 0;
@ -241,28 +269,28 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
setBackdropLoaded(false);
setBackdropError(false);
}, [episodeImage]);
// Animated styles for backdrop
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
transform: [{ scale: backdropScale.value }],
}));
// Animated styles for content panels
const leftPanelAnimatedStyle = useAnimatedStyle(() => ({
opacity: leftPanelOpacity.value,
transform: [{ translateX: leftPanelTranslateX.value }],
}));
const rightPanelAnimatedStyle = useAnimatedStyle(() => ({
opacity: rightPanelOpacity.value,
transform: [{ translateX: rightPanelTranslateX.value }],
}));
const handleBackdropLoad = () => {
setBackdropLoaded(true);
};
const handleBackdropError = () => {
if (__DEV__) console.log('[TabletStreamsLayout] Backdrop image failed to load:', backdropSource?.uri);
setBackdropError(true);
@ -295,8 +323,8 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>
{isAutoplayWaiting ? 'Finding best stream for autoplay...' :
showStillFetching ? 'Still fetching streams…' :
'Finding available streams...'}
showStillFetching ? 'Still fetching streams…' :
'Finding available streams...'}
</Text>
</View>
);
@ -310,78 +338,85 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
}
}
// Flatten sections into a single list with header items
type ListItem = { type: 'header'; title: string; addonId: string } | { type: 'stream'; stream: Stream; index: number };
const flatListData: ListItem[] = [];
sections
.filter(Boolean)
.filter(section => section!.data && section!.data.length > 0)
.forEach(section => {
flatListData.push({ type: 'header', title: section!.title, addonId: section!.addonId });
section!.data.forEach((stream, index) => {
flatListData.push({ type: 'stream', stream, index });
});
});
const renderItem = ({ item }: { item: ListItem }) => {
if (item.type === 'header') {
return renderSectionHeader({ section: { title: item.title, addonId: item.addonId } });
}
const stream = item.stream;
return (
<StreamCard
stream={stream}
onPress={() => handleStreamPress(stream)}
index={item.index}
isLoading={false}
statusMessage={undefined}
theme={currentTheme}
showLogos={settings.showScraperLogos}
scraperLogo={(stream.addonId && scraperLogos[stream.addonId]) || (stream as any).addon ? scraperLogos[(stream.addonId || (stream as any).addon) as string] || null : null}
showAlert={(t: string, m: string) => openAlert(t, m)}
parentTitle={metadata?.name}
parentType={type as 'movie' | 'series'}
parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
parentPosterUrl={episodeImage || metadata?.poster || undefined}
providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(stream))}
parentId={id}
parentImdbId={imdbId || undefined}
/>
);
};
const keyExtractor = (item: ListItem, index: number) => {
if (item.type === 'header') {
return `header-${item.addonId}-${index}`;
}
if (item.stream && item.stream.url) {
return `stream-${item.stream.url}-${index}`;
}
return `empty-${index}`;
};
const ListFooterComponent = () => {
if (!(loadingStreams || loadingEpisodeStreams) || !hasStremioStreamProviders) return null;
return (
<View style={styles.footerLoading}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
</View>
);
};
return (
<ScrollView
style={styles.streamsContent}
<LegendList
data={flatListData}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListFooterComponent={ListFooterComponent}
contentContainerStyle={[
styles.streamsContainer,
{ paddingBottom: insets.bottom + 100 }
]}
style={styles.streamsContent}
showsVerticalScrollIndicator={false}
bounces={true}
overScrollMode="never"
scrollEventThrottle={16}
>
{sections.filter(Boolean).map((section, sectionIndex) => (
<View key={section!.addonId || sectionIndex}>
{renderSectionHeader({ section: section! })}
{section!.data && section!.data.length > 0 ? (
<FlatList
data={section!.data}
keyExtractor={(item, index) => {
if (item && item.url) {
return `${item.url}-${sectionIndex}-${index}`;
}
return `empty-${sectionIndex}-${index}`;
}}
renderItem={({ item, index }) => (
<View>
<StreamCard
stream={item}
onPress={() => handleStreamPress(item)}
index={index}
isLoading={false}
statusMessage={undefined}
theme={currentTheme}
showLogos={settings.showScraperLogos}
scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogos[(item.addonId || (item as any).addon) as string] || null : null}
showAlert={(t: string, m: string) => openAlert(t, m)}
parentTitle={metadata?.name}
parentType={type as 'movie' | 'series'}
parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
parentPosterUrl={episodeImage || metadata?.poster || undefined}
providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
parentId={id}
parentImdbId={imdbId || undefined}
/>
</View>
)}
scrollEnabled={false}
initialNumToRender={6}
maxToRenderPerBatch={2}
windowSize={3}
removeClippedSubviews={true}
showsVerticalScrollIndicator={false}
getItemLayout={(data, index) => ({
length: 78,
offset: 78 * index,
index,
})}
/>
) : null}
</View>
))}
{(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
<View style={styles.footerLoading}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
</View>
)}
</ScrollView>
recycleItems={true}
estimatedItemSize={78}
/>
);
};
@ -408,7 +443,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
locations={[0, 0.5, 1]}
style={styles.tabletFullScreenGradient}
/>
{/* Left Panel: Movie Logo/Episode Info */}
<Animated.View style={[styles.tabletLeftPanel, leftPanelAnimatedStyle]}>
{type === 'movie' && metadata ? (

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

@ -0,0 +1,268 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
Platform,
Dimensions,
ViewStyle,
} from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
const { width } = Dimensions.get('window');
// Enhanced responsive breakpoints
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
const getDeviceType = (screenWidth: number) => {
if (screenWidth >= BREAKPOINTS.tv) return 'tv';
if (screenWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (screenWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
};
export type PosterShape = 'poster' | 'landscape' | 'square';
export interface PosterProps {
/** The poster image URL */
uri?: string | null;
/** Width of the poster */
width: number;
/** Shape of the poster - determines aspect ratio */
shape?: PosterShape;
/** Optional custom aspect ratio override */
aspectRatio?: number;
/** Optional custom border radius (uses settings.posterBorderRadius by default) */
borderRadius?: number;
/** Optional title to display below the poster */
title?: string;
/** Whether to show the title */
showTitle?: boolean;
/** Fallback text to show when no poster is available */
fallbackText?: string;
/** Additional styles for the container */
style?: ViewStyle;
/** Additional styles for the poster container */
posterStyle?: ViewStyle;
}
/**
* Shared Poster component with consistent styling across the app.
* Matches the design from ContentItem.tsx with:
* - Border: 1.5px solid rgba(255,255,255,0.15)
* - Border Radius: settings.posterBorderRadius (default 12)
* - Shadow: elevation 1 on Android, subtle shadow on iOS
* - Aspect Ratio: 2/3 for poster, 16/9 for landscape, 1/1 for square
*/
export const Poster: React.FC<PosterProps> = ({
uri,
width: posterWidth,
shape = 'poster',
aspectRatio: customAspectRatio,
borderRadius: customBorderRadius,
title,
showTitle = false,
fallbackText,
style,
posterStyle,
}) => {
const { currentTheme } = useTheme();
const { settings, isLoaded } = useSettings();
const [imageError, setImageError] = useState(false);
// Reset error state when URI changes
useEffect(() => {
setImageError(false);
}, [uri]);
// Determine aspect ratio based on shape
const aspectRatio = useMemo(() => {
if (customAspectRatio) return customAspectRatio;
switch (shape) {
case 'landscape':
return 16 / 9;
case 'square':
return 1;
case 'poster':
default:
return 2 / 3;
}
}, [shape, customAspectRatio]);
// Border radius from settings or custom
const borderRadius = customBorderRadius ??
(typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12);
// Device type for responsive title sizing
const deviceType = getDeviceType(width);
// Title font size based on device type
const titleFontSize = useMemo(() => {
switch (deviceType) {
case 'tv':
return 16;
case 'largeTablet':
return 15;
case 'tablet':
return 14;
default:
return 13;
}
}, [deviceType]);
// Optimize poster URL for TMDB
const optimizedUrl = useMemo(() => {
if (!uri || uri.includes('placeholder')) {
return null;
}
if (uri.includes('image.tmdb.org')) {
return uri.replace(/\/w\d+\//, '/w154/');
}
return uri;
}, [uri]);
// Placeholder while settings load
if (!isLoaded) {
return (
<View style={[styles.container, { width: posterWidth }, style]}>
<View
style={[
styles.posterContainer,
{
width: posterWidth,
aspectRatio,
borderRadius,
backgroundColor: currentTheme.colors.elevation1,
},
posterStyle,
]}
/>
{showTitle && <View style={{ height: 18, marginTop: 4 }} />}
</View>
);
}
return (
<View style={[styles.container, { width: posterWidth }, style]}>
<View
style={[
styles.posterContainer,
{
width: posterWidth,
aspectRatio,
borderRadius,
backgroundColor: currentTheme.colors.elevation1,
},
posterStyle,
]}
>
{optimizedUrl && !imageError ? (
<FastImage
source={{
uri: optimizedUrl,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable,
}}
style={[styles.poster, { borderRadius }]}
resizeMode={FastImage.resizeMode.cover}
onLoad={() => setImageError(false)}
onError={() => setImageError(true)}
/>
) : (
<View
style={[
styles.poster,
styles.fallbackContainer,
{
backgroundColor: currentTheme.colors.elevation1,
borderRadius,
},
]}
>
{imageError ? (
<MaterialIcons
name="broken-image"
size={24}
color={currentTheme.colors.textMuted}
/>
) : fallbackText ? (
<Text
style={[styles.fallbackText, { color: currentTheme.colors.textMuted }]}
numberOfLines={2}
>
{fallbackText.length > 20 ? `${fallbackText.substring(0, 20)}...` : fallbackText}
</Text>
) : (
<MaterialIcons
name="image"
size={24}
color={currentTheme.colors.textMuted}
/>
)}
</View>
)}
</View>
{showTitle && title && (
<Text
style={[
styles.title,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: titleFontSize,
},
]}
numberOfLines={2}
>
{title}
</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {},
posterContainer: {
overflow: 'hidden',
position: 'relative',
// Consistent shadow/elevation matching ContentItem
elevation: Platform.OS === 'android' ? 1 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
// Consistent border styling
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
marginBottom: 8,
},
poster: {
width: '100%',
height: '100%',
},
fallbackContainer: {
justifyContent: 'center',
alignItems: 'center',
},
fallbackText: {
fontSize: 10,
textAlign: 'center',
paddingHorizontal: 4,
},
title: {
fontWeight: '500',
marginTop: 4,
textAlign: 'center',
},
});
export default Poster;

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) {
@ -672,6 +675,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
id: currentItem.id,
type: currentItem.type,
title: currentItem.name,
addonId: currentItem.addonId,
metadata: {
poster: currentItem.poster,
banner: currentItem.banner,
@ -697,6 +701,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
id: currentItem.id,
type: currentItem.type,
title: currentItem.name,
addonId: currentItem.addonId,
metadata: {
poster: currentItem.poster,
banner: currentItem.banner,
@ -985,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>
@ -1184,6 +1189,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
addonId: currentItem.addonId,
});
}
}}
@ -1220,6 +1226,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
addonId: currentItem.addonId,
});
}
}}
@ -1238,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 && (
<>
@ -1258,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();
@ -228,9 +230,9 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
const contentPadding = useMemo(() => ({ paddingHorizontal: (windowWidth - cardWidth) / 2 }), [windowWidth, cardWidth]);
const handleNavigateToMetadata = useCallback((id: string, type: any) => {
navigation.navigate('Metadata', { id, type });
}, [navigation]);
const handleNavigateToMetadata = useCallback((id: string, type: any, addonId?: string) => {
navigation.navigate('Metadata', { id, type, addonId });
}, [navigation]);
// Container animation based on scroll - must be before early returns
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
@ -399,7 +401,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
colors={currentTheme.colors}
logoFailed={failedLogoIds.has(item.id)}
onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))}
onPressInfo={() => handleNavigateToMetadata(item.id, item.type)}
onPressInfo={() => handleNavigateToMetadata(item.id, item.type, item.addonId)}
scrollX={scrollX}
index={index}
flipped={!!flippedMap[item.id]}
@ -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';
@ -50,6 +51,7 @@ interface ThisWeekEpisode {
vote_average: number;
still_path: string | null;
season_poster_path: string | null;
addonId?: string;
// Grouping fields
isGroup?: boolean;
episodeCount?: number;
@ -57,6 +59,7 @@ interface ThisWeekEpisode {
}
export const ThisWeekSection = React.memo(() => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { calendarData, loading } = useCalendarData();
@ -175,7 +178,7 @@ export const ThisWeekSection = React.memo(() => {
processedItems.push({
...firstEp,
id: `group_${firstEp.seriesId}_${firstEp.releaseDate}`, // Unique ID for the group
title: `${group.length} New Episodes`,
title: t('home.new_episodes', { count: group.length }),
isReleased,
isGroup: true,
episodeCount: group.length,
@ -199,7 +202,8 @@ export const ThisWeekSection = React.memo(() => {
if (episode.isGroup) {
navigation.navigate('Metadata', {
id: episode.seriesId,
type: 'series'
type: 'series',
addonId: episode.addonId,
});
return;
}
@ -210,7 +214,8 @@ export const ThisWeekSection = React.memo(() => {
navigation.navigate('Metadata', {
id: episode.seriesId,
type: 'series',
episodeId
episodeId,
addonId: episode.addonId,
});
return;
}
@ -220,7 +225,8 @@ export const ThisWeekSection = React.memo(() => {
navigation.navigate('Streams', {
id: episode.seriesId,
type: 'series',
episodeId
episodeId,
addonId: episode.addonId,
});
};
@ -235,7 +241,7 @@ export const ThisWeekSection = React.memo(() => {
const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => {
// Handle episodes without release dates gracefully
const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null;
const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : 'TBA';
const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : t('home.tba');
const isReleased = item.isReleased;
// Use episode still image if available, fallback to series poster
@ -290,12 +296,12 @@ export const ThisWeekSection = React.memo(() => {
locations={[0, 0.4, 0.7, 1]}
>
<View style={styles.cardHeader}>
<View style={[
<View style={[
styles.statusBadge,
{ backgroundColor: isReleased ? currentTheme.colors.primary : 'rgba(0,0,0,0.6)' }
]}>
<Text style={styles.statusText}>
{isReleased ? (item.isGroup ? 'Released' : 'New') : formattedDate}
{isReleased ? (item.isGroup ? t('home.released') : t('home.new')) : formattedDate}
</Text>
</View>
</View>
@ -353,7 +359,7 @@ export const ThisWeekSection = React.memo(() => {
color: currentTheme.colors.text,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
}
]}>This Week</Text>
]}>{t('home.this_week')}</Text>
<View style={[
styles.titleUnderline,
{
@ -376,7 +382,7 @@ export const ThisWeekSection = React.memo(() => {
color: currentTheme.colors.textMuted,
fontSize: isTV ? 18 : isLargeTablet ? 16 : isTablet ? 15 : 14
}
]}>View All</Text>
]}>{t('home.view_all')}</Text>
<MaterialIcons
name="chevron-right"
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
@ -562,4 +568,4 @@ const styles = StyleSheet.create({
borderWidth: 1,
zIndex: -1,
},
});
});

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

File diff suppressed because it is too large Load diff

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

@ -7,13 +7,16 @@ import {
TouchableOpacity,
ActivityIndicator,
Dimensions,
Platform,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import FastImage from '@d11/react-native-fast-image';
import { useNavigation, StackActions } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { StreamingContent } from '../../services/catalogService';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { TMDBService } from '../../services/tmdbService';
import { catalogService } from '../../services/catalogService';
import CustomAlert from '../../components/CustomAlert';
@ -33,12 +36,15 @@ interface MoreLikeThisSectionProps {
loadingRecommendations: boolean;
}
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
}) => {
const { t } = useTranslation();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const borderRadius = settings.posterBorderRadius ?? 12;
// Determine device type
const deviceWidth = Dimensions.get('window').width;
@ -91,16 +97,16 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
try {
// Extract TMDB ID from the tmdb:123456 format
const tmdbId = item.id.replace('tmdb:', '');
// Get Stremio ID directly using catalogService
// The catalogService.getStremioId method already handles the conversion internally
const stremioId = await catalogService.getStremioId(item.type, tmdbId);
if (stremioId) {
navigation.dispatch(
StackActions.push('Metadata', {
id: stremioId,
type: item.type
StackActions.push('Metadata', {
id: stremioId,
type: item.type
})
);
} else {
@ -108,21 +114,21 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
}
} catch (error) {
if (__DEV__) console.error('Error navigating to recommendation:', error);
setAlertTitle('Error');
setAlertMessage('Unable to load this content. Please try again later.');
setAlertActions([{ label: 'OK', onPress: () => {} }]);
setAlertTitle(t('common.error'));
setAlertMessage(t('metadata.something_went_wrong'));
setAlertActions([{ label: t('common.ok'), onPress: () => { } }]);
setAlertVisible(true);
}
};
const renderItem = ({ item }: { item: StreamingContent }) => (
<TouchableOpacity
<TouchableOpacity
style={[styles.itemContainer, { width: posterWidth, marginRight: itemSpacing }]}
onPress={() => handleItemPress(item)}
>
<FastImage
source={{ uri: item.poster }}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, width: posterWidth, height: posterHeight, borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8 }]}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, width: posterWidth, height: posterHeight, borderRadius }]}
resizeMode={FastImage.resizeMode.cover}
/>
<Text style={[styles.title, { color: currentTheme.colors.mediumEmphasis, fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13, lineHeight: isTV ? 20 : 18 }]} numberOfLines={2}>
@ -144,8 +150,8 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
}
return (
<View style={[styles.container, { paddingLeft: 0 }] }>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>More Like This</Text>
<View style={[styles.container, { paddingLeft: 0 }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>{t('metadata.more_like_this')}</Text>
<FlatList
data={recommendations}
renderItem={renderItem}
@ -183,10 +189,17 @@ const styles = StyleSheet.create({
marginRight: 12, // will be overridden responsively
},
poster: {
borderRadius: 8, // overridden responsively
borderRadius: 12,
marginBottom: 8,
borderWidth: 1,
// Consistent border styling matching ContentItem
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
// Consistent shadow/elevation
elevation: Platform.OS === 'android' ? 1 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
},
title: {
fontSize: 13, // overridden responsively

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',
@ -204,15 +212,15 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
// Priority: IMDB, TMDB, Tomatoes, Metacritic
const priorityOrder = ['imdb', 'tmdb', 'tomatoes', 'metacritic', 'trakt', 'letterboxd', 'audience'];
const displayRatings = priorityOrder
.filter(source =>
source in ratings &&
.filter(source =>
source in ratings &&
ratings[source as keyof typeof ratings] !== undefined &&
(enabledProviders[source] ?? true) // Show by default if setting not found
)
.map(source => [source, ratings[source as keyof typeof ratings]!]);
return (
<Animated.View
<Animated.View
style={[
styles.container,
{
@ -231,22 +239,32 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
{displayRatings.map(([source, value]) => {
const config = ratingConfig[source as keyof typeof ratingConfig];
const displayValue = config.transform(parseFloat(value as string));
return (
<View key={source} style={[styles.compactRatingItem, { marginRight: itemSpacing }]}>
{config.isImage ? (
<Image
<Image
source={config.icon as any}
style={[styles.compactRatingIcon, { width: iconSize, height: iconSize, marginRight: iconTextGap }]}
resizeMode="contain"
/>
) : (
) : config.icon ? (
<View style={[styles.compactSvgContainer, { marginRight: iconTextGap }]}>
{React.createElement(config.icon as any, {
width: iconSize,
height: iconSize,
})}
</View>
) : (
// Text fallback
<Text style={{
color: config.color,
fontSize: textSize,
fontWeight: '900',
marginRight: iconTextGap
}}>
{config.name}
</Text>
)}
<Text style={[styles.compactRatingValue, { color: config.color, fontSize: textSize }]}>
{displayValue}

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

@ -1,5 +1,5 @@
import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
import { View, StyleSheet, Platform, Animated } from 'react-native';
import { View, StyleSheet, Platform, Animated, ToastAndroid } from 'react-native';
import { toast } from '@backpackapp-io/react-native-toast';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
@ -10,13 +10,14 @@ import {
usePlayerState,
usePlayerModals,
useSpeedControl,
useOpeningAnimation
useOpeningAnimation,
useWatchProgress
} from './hooks';
// Android-specific hooks
import { usePlayerSetup } from './android/hooks/usePlayerSetup';
import { usePlayerTracks } from './android/hooks/usePlayerTracks';
import { useWatchProgress } from './android/hooks/useWatchProgress';
import { usePlayerControls } from './android/hooks/usePlayerControls';
import { useNextEpisode } from './android/hooks/useNextEpisode';
@ -32,12 +33,18 @@ import LoadingOverlay from './modals/LoadingOverlay';
import PlayerControls from './controls/PlayerControls';
import { AudioTrackModal } from './modals/AudioTrackModal';
import { SubtitleModals } from './modals/SubtitleModals';
import { SubtitleSyncModal } from './modals/SubtitleSyncModal';
import SpeedModal from './modals/SpeedModal';
import { SourcesModal } from './modals/SourcesModal';
import { EpisodesModal } from './modals/EpisodesModal';
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
import { ErrorModal } from './modals/ErrorModal';
import { CustomSubtitles } from './subtitles/CustomSubtitles';
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
import SkipIntroButton from './overlays/SkipIntroButton';
import UpNextButton from './common/UpNextButton';
import { CustomAlert } from '../CustomAlert';
// Android-specific components
import { VideoSurface } from './android/components/VideoSurface';
@ -50,6 +57,8 @@ import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT
import { storageService } from '../../services/storageService';
import stremioService from '../../services/stremioService';
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
import { useTheme } from '../../contexts/ThemeContext';
import axios from 'axios';
const DEBUG_MODE = false;
@ -58,6 +67,7 @@ const AndroidVideoPlayer: React.FC = () => {
const navigation = useNavigation();
const route = useRoute<RouteProp<RootStackParamList, 'PlayerAndroid'>>();
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
const {
uri, title = 'Episode Name', season, episode, episodeTitle, quality, year,
@ -74,6 +84,8 @@ const AndroidVideoPlayer: React.FC = () => {
const videoRef = useRef<any>(null);
const mpvPlayerRef = useRef<MpvPlayerRef>(null);
const exoPlayerRef = useRef<any>(null);
const pinchRef = useRef(null);
const tracksHook = usePlayerTracks();
const [currentStreamUrl, setCurrentStreamUrl] = useState<string>(uri);
@ -84,6 +96,27 @@ const AndroidVideoPlayer: React.FC = () => {
const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider);
const [currentStreamName, setCurrentStreamName] = useState(streamName);
// State to force unmount VideoSurface during stream transitions
const [isTransitioningStream, setIsTransitioningStream] = useState(false);
// Dual video engine state: ExoPlayer primary, MPV fallback
// If videoPlayerEngine is 'mpv', always use MPV; otherwise use auto behavior
const shouldUseMpvOnly = settings.videoPlayerEngine === 'mpv';
const [useExoPlayer, setUseExoPlayer] = useState(!shouldUseMpvOnly);
const hasExoPlayerFailed = useRef(false);
const [showMpvSwitchAlert, setShowMpvSwitchAlert] = useState(false);
// Sync useExoPlayer with settings when videoPlayerEngine is set to 'mpv'
// Only run once on mount to avoid re-render loops
const hasAppliedEngineSettingRef = useRef(false);
useEffect(() => {
if (!hasAppliedEngineSettingRef.current && settings.videoPlayerEngine === 'mpv') {
hasAppliedEngineSettingRef.current = true;
setUseExoPlayer(false);
}
}, [settings.videoPlayerEngine]);
// Subtitle addon state
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false);
@ -91,6 +124,7 @@ const AndroidVideoPlayer: React.FC = () => {
const [useCustomSubtitles, setUseCustomSubtitles] = useState(false);
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState<string | null>(null);
// Subtitle customization state
const [subtitleSize, setSubtitleSize] = useState(28);
@ -107,6 +141,31 @@ const AndroidVideoPlayer: React.FC = () => {
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2);
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0);
// Subtitle sync modal state
const [showSyncModal, setShowSyncModal] = useState(false);
// Track auto-selection ref to prevent duplicate selections
const hasAutoSelectedTracks = useRef(false);
// 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)) {
setSubtitleOffsetSec(0);
}
// Update the ref for next comparison
previousVideoRef.current = currentVideo;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uri, episodeId]);
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] };
const hasLogo = metadata && metadata.logo;
@ -123,7 +182,9 @@ const AndroidVideoPlayer: React.FC = () => {
playerState.currentTime,
playerState.duration,
playerState.isSeeking,
playerState.isMounted
playerState.isMounted,
exoPlayerRef,
useExoPlayer
);
const traktAutosync = useTraktAutosync({
@ -146,7 +207,8 @@ const AndroidVideoPlayer: React.FC = () => {
playerState.duration,
playerState.paused,
traktAutosync,
controlsHook.seekToTime
controlsHook.seekToTime,
currentStreamProvider
);
const gestureControls = usePlayerGestureControls({
@ -172,6 +234,33 @@ const AndroidVideoPlayer: React.FC = () => {
}).start();
}, [playerState.showControls]);
// Auto-hide controls after 3 seconds of inactivity
useEffect(() => {
// Clear any existing timeout
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
controlsTimeout.current = null;
}
// Only set timeout if controls are visible and video is playing
if (playerState.showControls && !playerState.paused) {
controlsTimeout.current = setTimeout(() => {
// Don't hide if user is dragging the seek bar
if (!playerState.isDragging.current) {
playerState.setShowControls(false);
}
}, 2000); // 2 seconds delay
}
// Cleanup on unmount or when dependencies change
return () => {
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
controlsTimeout.current = null;
}
};
}, [playerState.showControls, playerState.paused, playerState.isDragging]);
useEffect(() => {
openingAnimation.startOpeningAnimation();
}, []);
@ -250,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
}));
@ -258,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
}));
@ -268,19 +360,75 @@ const AndroidVideoPlayer: React.FC = () => {
playerState.setIsVideoLoaded(true);
openingAnimation.completeOpeningAnimation();
// 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: i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
const bestAudioTrack = findBestAudioTrack(formatted, settings.preferredAudioLanguage);
if (bestAudioTrack !== null) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting audio track ${bestAudioTrack} for language: ${settings.preferredAudioLanguage}`);
tracksHook.setSelectedAudioTrack({ type: 'index', value: bestAudioTrack });
}
}
// Auto-select subtitle track based on preferences
// Only auto-select internal tracks here if preference is 'internal' or 'any'
// If preference is 'external', we wait for the useEffect to handle selection after external subs load
if (data.textTracks && data.textTracks.length > 0 && !hasAutoSelectedTracks.current && settings?.enableSubtitleAutoSelect) {
const sourcePreference = settings?.subtitleSourcePreference || 'internal';
// 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: i,
name: t.title || t.name || `Track ${i + 1}`,
language: t.language
}));
const subtitleSelection = findBestSubtitleTrack(
formatted,
[], // External subtitles not yet loaded
{
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
subtitleSourcePreference: sourcePreference,
enableSubtitleAutoSelect: true
}
);
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
tracksHook.setSelectedTextTrack(subtitleSelection.internalTrackId);
hasAutoSelectedTracks.current = true;
}
}
// If preference is 'external', don't select anything here - useEffect will handle it
}
// Handle Resume - check both initialPosition and initialSeekTargetRef
const resumeTarget = watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && videoDuration > 0) {
console.log('[AndroidVideoPlayer] Seeking to resume position:', resumeTarget, 'duration:', videoDuration);
// Use a small delay to ensure the player is ready, then seek directly
const seekPosition = Math.min(resumeTarget, videoDuration - 0.5);
console.log('[AndroidVideoPlayer] Seeking to resume position:', seekPosition, 'duration:', videoDuration, 'useExoPlayer:', useExoPlayer);
// Use a small delay to ensure the player is ready
// Directly use refs to avoid stale closure issues
setTimeout(() => {
if (mpvPlayerRef.current) {
console.log('[AndroidVideoPlayer] Calling mpvPlayerRef.current.seek directly');
mpvPlayerRef.current.seek(Math.min(resumeTarget, videoDuration - 0.5));
console.log('[AndroidVideoPlayer] Executing resume seek to:', seekPosition, 'ExoPlayer available:', !!exoPlayerRef.current, 'MPV available:', !!mpvPlayerRef.current);
if (useExoPlayer && exoPlayerRef.current) {
console.log('[AndroidVideoPlayer] Seeking ExoPlayer to resume position:', seekPosition);
exoPlayerRef.current.seek(seekPosition);
} else if (mpvPlayerRef.current) {
console.log('[AndroidVideoPlayer] Seeking MPV to resume position:', seekPosition);
mpvPlayerRef.current.seek(seekPosition);
} else {
console.warn('[AndroidVideoPlayer] No player ref available for resume seek');
}
}, 200);
}, 300);
}
}, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition]);
}, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]);
const handleProgress = useCallback((data: any) => {
if (playerState.isDragging.current || playerState.isSeeking.current || !playerState.isMounted.current || setupHook.isAppBackgrounded.current) return;
@ -291,18 +439,60 @@ const AndroidVideoPlayer: React.FC = () => {
}
}, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded]);
// Auto-select subtitles when both internal tracks and video are loaded
// This ensures we wait for internal tracks before falling back to external
useEffect(() => {
if (!playerState.isVideoLoaded || hasAutoSelectedTracks.current || !settings?.enableSubtitleAutoSelect) {
return;
}
const internalTracks = tracksHook.ksTextTracks;
const externalSubs = availableSubtitles;
// Wait a short delay to ensure tracks are fully populated
const timeoutId = setTimeout(() => {
if (hasAutoSelectedTracks.current) return;
const subtitleSelection = findBestSubtitleTrack(
internalTracks,
externalSubs,
{
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
subtitleSourcePreference: settings?.subtitleSourcePreference || 'internal',
enableSubtitleAutoSelect: true
}
);
// Trust the findBestSubtitleTrack function's decision - it already implements priority logic
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
tracksHook.setSelectedTextTrack(subtitleSelection.internalTrackId);
hasAutoSelectedTracks.current = true;
} else if (subtitleSelection.type === 'external' && subtitleSelection.externalSubtitle) {
logger.debug(`[AndroidVideoPlayer] Auto-selecting external subtitle: ${subtitleSelection.externalSubtitle.display}`);
loadWyzieSubtitle(subtitleSelection.externalSubtitle);
hasAutoSelectedTracks.current = true;
}
}, 500); // Short delay to ensure tracks are populated
return () => clearTimeout(timeoutId);
}, [playerState.isVideoLoaded, tracksHook.ksTextTracks, availableSubtitles, settings]);
// Sync custom subtitle text with current playback time
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 => !prev);
playerState.setShowControls(prev => {
// If we're showing controls, the useEffect will handle the auto-hide timer
return !prev;
});
}, []);
const hideControls = useCallback(() => {
@ -319,6 +509,44 @@ const AndroidVideoPlayer: React.FC = () => {
else navigation.reset({ index: 0, routes: [{ name: 'Home' }] } as any);
}, [navigation]);
// Handle codec errors from ExoPlayer - silently switch to MPV
const handleCodecError = useCallback(() => {
if (!hasExoPlayerFailed.current) {
hasExoPlayerFailed.current = true;
logger.warn('[AndroidVideoPlayer] ExoPlayer codec error detected, switching to MPV silently');
ToastAndroid.show('Switching to MPV due to playback issue', ToastAndroid.SHORT);
setUseExoPlayer(false);
}
}, []);
// Handle manual switch to MPV - for users experiencing black screen
const handleManualSwitchToMPV = useCallback(() => {
if (useExoPlayer && !hasExoPlayerFailed.current) {
setShowMpvSwitchAlert(true);
}
}, [useExoPlayer]);
// Confirm and execute the switch to MPV
const confirmSwitchToMPV = useCallback(() => {
hasExoPlayerFailed.current = true;
logger.info('[AndroidVideoPlayer] User confirmed switch to MPV');
ToastAndroid.show('Switching to MPV player...', ToastAndroid.SHORT);
// Store current playback position before switching
const currentPos = playerState.currentTime;
// Switch to MPV
setUseExoPlayer(false);
// Seek to current position after a brief delay to ensure MPV is loaded
setTimeout(() => {
if (mpvPlayerRef.current && currentPos > 0) {
mpvPlayerRef.current.seek(currentPos);
}
}, 500);
}, [playerState.currentTime]);
const handleSelectStream = async (newStream: any) => {
if (newStream.url === currentStreamUrl) {
modals.setShowSourcesModal(false);
@ -327,10 +555,14 @@ const AndroidVideoPlayer: React.FC = () => {
modals.setShowSourcesModal(false);
playerState.setPaused(true);
// Unmount VideoSurface first to ensure MPV is fully destroyed
setIsTransitioningStream(true);
const newQuality = newStream.quality || newStream.title?.match(/(\d+)p/)?.[0];
const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown';
const newStreamName = newStream.name || newStream.title || 'Unknown';
// Wait for unmount to complete, then navigate
setTimeout(() => {
(navigation as any).replace('PlayerAndroid', {
...route.params,
@ -341,19 +573,24 @@ const AndroidVideoPlayer: React.FC = () => {
headers: newStream.headers,
availableStreams: availableStreams
});
}, 100);
}, 300);
};
const handleEpisodeStreamSelect = async (stream: any) => {
if (!modals.selectedEpisodeForStreams) return;
modals.setShowEpisodeStreamsModal(false);
playerState.setPaused(true);
// Unmount VideoSurface first to ensure MPV is fully destroyed
setIsTransitioningStream(true);
const ep = modals.selectedEpisodeForStreams;
const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]);
const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown';
const newStreamName = stream.name || stream.title || 'Unknown Stream';
// Wait for unmount to complete, then navigate
setTimeout(() => {
(navigation as any).replace('PlayerAndroid', {
uri: stream.url,
@ -374,7 +611,7 @@ const AndroidVideoPlayer: React.FC = () => {
availableStreams: {},
groupedEpisodes: groupedEpisodes,
});
}, 100);
}, 300);
};
// Subtitle addon fetching
@ -408,6 +645,7 @@ const AndroidVideoPlayer: React.FC = () => {
setAvailableSubtitles(subs);
logger.info(`[AndroidVideoPlayer] Fetched ${subs.length} addon subtitles`);
// Auto-selection is now handled by useEffect that waits for internal tracks
} catch (e) {
logger.error('[AndroidVideoPlayer] Error fetching addon subtitles', e);
} finally {
@ -435,6 +673,7 @@ const AndroidVideoPlayer: React.FC = () => {
const parsedCues = parseSRT(srtContent);
setCustomSubtitles(parsedCues);
setUseCustomSubtitles(true);
setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle
// Disable MPV's built-in subtitle track when using custom subtitles
tracksHook.setSelectedTextTrack(-1);
@ -442,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 : '');
@ -455,12 +694,13 @@ const AndroidVideoPlayer: React.FC = () => {
} finally {
setIsLoadingSubtitles(false);
}
}, [modals, playerState.currentTime, tracksHook]);
}, [modals, playerState.currentTime, subtitleOffsetSec, tracksHook]);
const disableCustomSubtitles = useCallback(() => {
setUseCustomSubtitles(false);
setCustomSubtitles([]);
setCurrentSubtitle('');
setSelectedExternalSubtitleId(null); // Clear external selection
}, []);
const cycleResizeMode = useCallback(() => {
@ -468,6 +708,13 @@ const AndroidVideoPlayer: React.FC = () => {
else playerState.setResizeMode('contain');
}, [playerState.resizeMode]);
// Memoize selectedTextTrack to prevent unnecessary re-renders
const memoizedSelectedTextTrack = useMemo(() => {
return tracksHook.selectedTextTrack === -1
? { type: 'disabled' as const }
: { type: 'index' as const, value: tracksHook.selectedTextTrack };
}, [tracksHook.selectedTextTrack]);
return (
<View style={[styles.container, {
width: playerState.screenDimensions.width,
@ -487,73 +734,97 @@ const AndroidVideoPlayer: React.FC = () => {
/>
<View style={{ flex: 1, backgroundColor: 'black' }}>
<VideoSurface
processedStreamUrl={currentStreamUrl}
headers={headers}
volume={volume}
playbackSpeed={speedControl.playbackSpeed}
resizeMode={playerState.resizeMode}
paused={playerState.paused}
currentStreamUrl={currentStreamUrl}
toggleControls={toggleControls}
onLoad={handleLoad}
onProgress={handleProgress}
onSeek={(data) => {
playerState.isSeeking.current = false;
if (data.currentTime) traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true);
}}
onEnd={() => {
if (modals.showEpisodeStreamsModal) return;
playerState.setPaused(true);
}}
onError={(err: any) => {
logger.error('Video Error', err);
{!isTransitioningStream && (
<VideoSurface
processedStreamUrl={currentStreamUrl}
headers={headers}
volume={volume}
playbackSpeed={speedControl.playbackSpeed}
resizeMode={playerState.resizeMode}
paused={playerState.paused}
currentStreamUrl={currentStreamUrl}
toggleControls={toggleControls}
onLoad={handleLoad}
onProgress={handleProgress}
onSeek={(data) => {
playerState.isSeeking.current = false;
if (data.currentTime) traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true);
}}
onEnd={() => {
if (modals.showEpisodeStreamsModal) return;
playerState.setPaused(true);
}}
onError={(err: any) => {
logger.error('Video Error', err);
// Determine the actual error message
let displayError = 'An unknown error occurred';
// Determine the actual error message
let displayError = 'An unknown error occurred';
if (typeof err?.error === 'string') {
displayError = err.error;
} else if (err?.error?.errorString) {
displayError = err.error.errorString;
} else if (err?.errorString) {
displayError = err.errorString;
} else if (typeof err === 'string') {
displayError = err;
} else {
displayError = JSON.stringify(err);
}
if (typeof err?.error === 'string') {
displayError = err.error;
} else if (err?.error?.errorString) {
displayError = err.error.errorString;
} else if (err?.errorString) {
displayError = err.errorString;
} else if (typeof err === 'string') {
displayError = err;
} else {
displayError = JSON.stringify(err);
}
modals.setErrorDetails(displayError);
modals.setShowErrorModal(true);
}}
onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)}
onTracksChanged={(data) => {
console.log('[AndroidVideoPlayer] onTracksChanged:', data);
if (data?.audioTracks) {
const formatted = data.audioTracks.map((t: any) => ({
id: t.id,
name: t.name || `Track ${t.id}`,
language: t.language
}));
tracksHook.setRnVideoAudioTracks(formatted);
}
if (data?.subtitleTracks) {
const formatted = data.subtitleTracks.map((t: any) => ({
id: t.id,
name: t.name || `Track ${t.id}`,
language: t.language
}));
tracksHook.setRnVideoTextTracks(formatted);
}
}}
mpvPlayerRef={mpvPlayerRef}
pinchRef={useRef(null)}
onPinchGestureEvent={() => { }}
onPinchHandlerStateChange={() => { }}
screenDimensions={playerState.screenDimensions}
useHardwareDecoding={settings.useHardwareDecoding}
/>
modals.setErrorDetails(displayError);
modals.setShowErrorModal(true);
}}
onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)}
onTracksChanged={(data) => {
console.log('[AndroidVideoPlayer] onTracksChanged:', data);
if (data?.audioTracks) {
const formatted = data.audioTracks.map((t: any) => ({
id: t.id,
name: t.name || `Track ${t.id}`,
language: t.language
}));
tracksHook.setRnVideoAudioTracks(formatted);
}
if (data?.subtitleTracks) {
const formatted = data.subtitleTracks.map((t: any) => ({
id: t.id,
name: t.name || `Track ${t.id}`,
language: t.language
}));
tracksHook.setRnVideoTextTracks(formatted);
}
}}
mpvPlayerRef={mpvPlayerRef}
exoPlayerRef={exoPlayerRef}
pinchRef={pinchRef}
onPinchGestureEvent={() => { }}
onPinchHandlerStateChange={() => { }}
screenDimensions={playerState.screenDimensions}
decoderMode={settings.decoderMode}
gpuMode={settings.gpuMode}
// Dual video engine props
useExoPlayer={useExoPlayer}
onCodecError={handleCodecError}
selectedAudioTrack={tracksHook.selectedAudioTrack as any || undefined}
selectedTextTrack={memoizedSelectedTextTrack as any}
// Subtitle Styling - pass to MPV for built-in subtitle customization
// MPV uses different scaling than React Native, so we apply conversion factors:
// - Font size: MPV needs ~1.5x larger values (MPV's sub-font-size vs RN fontSize)
// - Border: MPV needs ~1.5x larger values
// - Position: MPV sub-pos uses 0=top, 100=bottom, >100=below screen
subtitleSize={Math.round(subtitleSize * 1.5)}
subtitleColor={subtitleTextColor}
subtitleBackgroundOpacity={subtitleBackground ? subtitleBgOpacity : 0}
subtitleBorderSize={subtitleOutline ? Math.round(subtitleOutlineWidth * 1.5) : 0}
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}
/>
)}
{/* Custom Subtitles for addon subtitles */}
<CustomSubtitles
@ -635,13 +906,16 @@ const AndroidVideoPlayer: React.FC = () => {
}}
buffered={playerState.buffered}
formatTime={formatTime}
playerBackend={'MPV'}
playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'}
onSwitchToMPV={handleManualSwitchToMPV}
useExoPlayer={useExoPlayer}
/>
<SpeedActivatedOverlay
visible={speedControl.showSpeedActivatedOverlay}
opacity={speedControl.speedActivatedOverlayOpacity}
speed={speedControl.holdToSpeedValue}
screenDimensions={playerState.screenDimensions}
/>
<PauseOverlay
@ -657,6 +931,52 @@ const AndroidVideoPlayer: React.FC = () => {
cast={cast}
screenDimensions={playerState.screenDimensions}
/>
{/* Parental Guide Overlay - Shows after controls first hide */}
<ParentalGuideOverlay
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
type={type as 'movie' | 'series'}
season={season}
episode={episode}
shouldShow={playerState.isVideoLoaded && !playerState.showControls && !playerState.paused}
/>
{/* Skip Intro Button - Shows during intro section of TV episodes */}
<SkipIntroButton
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
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}
controlsFixedOffset={100}
/>
{/* Up Next Button - Shows near end of episodes */}
<UpNextButton
type={type || 'movie'}
nextEpisode={nextEpisodeHook.nextEpisode}
currentTime={playerState.currentTime}
duration={playerState.duration}
insets={insets}
isLoading={false}
nextLoadingProvider={null}
nextLoadingQuality={null}
nextLoadingTitle={null}
onPress={() => {
if (nextEpisodeHook.nextEpisode) {
logger.log(`[AndroidVideoPlayer] Opening streams for next episode: S${nextEpisodeHook.nextEpisode.season_number}E${nextEpisodeHook.nextEpisode.episode_number}`);
modals.setSelectedEpisodeForStreams(nextEpisodeHook.nextEpisode);
modals.setShowEpisodeStreamsModal(true);
}
}}
metadata={metadataResult?.metadata ? { poster: metadataResult.metadata.poster, id: metadataResult.metadata.id } : undefined}
controlsVisible={playerState.showControls}
controlsFixedOffset={100}
/>
</View>
<AudioTrackModal
@ -686,16 +1006,19 @@ const AndroidVideoPlayer: React.FC = () => {
selectedTextTrack={tracksHook.computedSelectedTextTrack}
useCustomSubtitles={useCustomSubtitles}
isKsPlayerActive={true}
useExoPlayer={useExoPlayer}
subtitleSize={subtitleSize}
subtitleBackground={subtitleBackground}
fetchAvailableSubtitles={fetchAvailableSubtitles}
loadWyzieSubtitle={loadWyzieSubtitle}
selectTextTrack={(trackId) => {
tracksHook.setSelectedTextTrack(trackId);
// Actually tell MPV to switch the subtitle track
if (mpvPlayerRef.current) {
// For MPV, manually switch the subtitle track
if (!useExoPlayer && mpvPlayerRef.current) {
mpvPlayerRef.current.setSubtitleTrack(trackId);
}
// For ExoPlayer, the selectedTextTrack prop will be updated via memoizedSelectedTextTrack
// which triggers a re-render with the new track selection
// Disable custom subtitles when selecting built-in track
setUseCustomSubtitles(false);
modals.setShowSubtitleModal(false);
@ -726,6 +1049,19 @@ const AndroidVideoPlayer: React.FC = () => {
setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier}
subtitleOffsetSec={subtitleOffsetSec}
setSubtitleOffsetSec={setSubtitleOffsetSec}
selectedExternalSubtitleId={selectedExternalSubtitleId}
onOpenSyncModal={() => setShowSyncModal(true)}
/>
{/* Visual Subtitle Sync Modal */}
<SubtitleSyncModal
visible={showSyncModal}
onClose={() => setShowSyncModal(false)}
onConfirm={(offset) => setSubtitleOffsetSec(offset)}
currentOffset={subtitleOffsetSec}
currentTime={playerState.currentTime}
subtitles={customSubtitles}
primaryColor={currentTheme.colors.primary}
/>
<SourcesModal
@ -777,6 +1113,27 @@ const AndroidVideoPlayer: React.FC = () => {
metadata={{ id: id, name: title }}
/>
{/* MPV Switch Confirmation Alert */}
<CustomAlert
visible={showMpvSwitchAlert}
title="Switch to MPV Player?"
message="This will switch from ExoPlayer to MPV player. Use this if you're facing playback issues that don't automatically switch to MPV. The switch cannot be undone during this playback session."
onClose={() => setShowMpvSwitchAlert(false)}
actions={[
{
label: 'Cancel',
onPress: () => setShowMpvSwitchAlert(false),
},
{
label: 'Switch to MPV',
onPress: () => {
setShowMpvSwitchAlert(false);
confirmSwitchToMPV();
},
},
]}
/>
</View>
);
};

View file

@ -17,6 +17,9 @@ interface KSPlayerViewProps {
usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
subtitleBottomOffset?: number;
subtitleFontSize?: number;
subtitleTextColor?: string;
subtitleBackgroundColor?: string;
subtitleOutlineEnabled?: boolean;
resizeMode?: 'contain' | 'cover' | 'stretch';
onLoad?: (data: any) => void;
onProgress?: (data: any) => void;
@ -56,6 +59,9 @@ export interface KSPlayerProps {
usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
subtitleBottomOffset?: number;
subtitleFontSize?: number;
subtitleTextColor?: string;
subtitleBackgroundColor?: string;
subtitleOutlineEnabled?: boolean;
resizeMode?: 'contain' | 'cover' | 'stretch';
onLoad?: (data: any) => void;
onProgress?: (data: any) => void;
@ -120,11 +126,16 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
}
},
setTextTrack: (trackId: number) => {
console.log('[KSPlayerComponent] setTextTrack called with trackId:', trackId);
if (nativeRef.current) {
const node = findNodeHandle(nativeRef.current);
console.log('[KSPlayerComponent] setTextTrack dispatching command to node:', node);
// @ts-ignore legacy UIManager commands path for Paper
const commandId = UIManager.getViewManagerConfig('KSPlayerView').Commands.setTextTrack;
console.log('[KSPlayerComponent] setTextTrack commandId:', commandId);
UIManager.dispatchViewManagerCommand(node, commandId, [trackId]);
} else {
console.warn('[KSPlayerComponent] setTextTrack: nativeRef.current is null');
}
},
getTracks: async () => {
@ -199,6 +210,9 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
usesExternalPlaybackWhileExternalScreenIsActive={props.usesExternalPlaybackWhileExternalScreenIsActive}
subtitleBottomOffset={props.subtitleBottomOffset}
subtitleFontSize={props.subtitleFontSize}
subtitleTextColor={props.subtitleTextColor}
subtitleBackgroundColor={props.subtitleBackgroundColor}
subtitleOutlineEnabled={props.subtitleOutlineEnabled}
resizeMode={props.resizeMode}
onLoad={(e: any) => props.onLoad?.(e?.nativeEvent ?? e)}
onProgress={(e: any) => props.onProgress?.(e?.nativeEvent ?? e)}

View file

@ -11,17 +11,20 @@ import { PlayerControls } from './controls/PlayerControls';
import AudioTrackModal from './modals/AudioTrackModal';
import SpeedModal from './modals/SpeedModal';
import SubtitleModals from './modals/SubtitleModals';
import { SubtitleSyncModal } from './modals/SubtitleSyncModal';
import SourcesModal from './modals/SourcesModal';
import EpisodesModal from './modals/EpisodesModal';
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
import { ErrorModal } from './modals/ErrorModal';
import CustomSubtitles from './subtitles/CustomSubtitles';
import ResumeOverlay from './modals/ResumeOverlay';
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
import SkipIntroButton from './overlays/SkipIntroButton';
import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components';
// Platform-specific components
import { KSPlayerSurface } from './ios/components/KSPlayerSurface';
// Shared Hooks
import {
usePlayerState,
usePlayerModals,
@ -30,7 +33,9 @@ import {
usePlayerTracks,
useCustomSubtitles,
usePlayerControls,
usePlayerSetup
usePlayerSetup,
useWatchProgress,
useNextEpisode
} from './hooks';
// Platform-specific hooks
@ -47,6 +52,9 @@ import { logger } from '../../utils/logger';
import { formatTime } from './utils/playerUtils';
import { WyzieSubtitle } from './utils/playerTypes';
import { parseSRT } from './utils/subtitleParser';
import { findBestSubtitleTrack, autoSelectAudioTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
import { useSettings } from '../../hooks/useSettings';
import { useTheme } from '../../contexts/ThemeContext';
// Player route params interface
interface PlayerRouteParams {
@ -126,6 +134,42 @@ const KSPlayerCore: React.FC = () => {
const tracks = usePlayerTracks();
const { ksPlayerRef, seek } = useKSPlayer();
const customSubs = useCustomSubtitles();
const { settings } = useSettings();
const { currentTheme } = useTheme();
// Subtitle sync modal state
const [showSyncModal, setShowSyncModal] = useState(false);
// Track auto-selection refs to prevent duplicate selections
const hasAutoSelectedTracks = useRef(false);
// 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)) {
customSubs.setSubtitleOffsetSec(0);
}
// Update the ref for next comparison
previousVideoRef.current = currentVideo;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uri, episodeId]);
// Next Episode Hook
const { nextEpisode, currentEpisodeDescription } = useNextEpisode({
type,
season,
episode,
groupedEpisodes: groupedEpisodes as any,
episodeId
});
const controls = usePlayerControls({
playerRef: ksPlayerRef,
@ -137,6 +181,15 @@ const KSPlayerCore: React.FC = () => {
isMounted
});
const watchProgress = useWatchProgress(
id, type, episodeId,
currentTime,
duration,
paused,
traktAutosync,
controls.seekToTime
);
// Gestures
const fadeAnim = useRef(new Animated.Value(1)).current;
@ -157,9 +210,6 @@ const KSPlayerCore: React.FC = () => {
const [brightness, setBrightnessState] = useState(0.5);
const [isSliderDragging, setIsSliderDragging] = useState(false);
// Watch Progress State
const [initialPosition, setInitialPosition] = useState<number | null>(routeInitialPosition || null);
// Shared Gesture Hook
const gestureControls = usePlayerGestureControls({
volume: volume,
@ -173,7 +223,8 @@ const KSPlayerCore: React.FC = () => {
setScreenDimensions,
setVolume: setVolumeState,
setBrightness: setBrightnessState,
isOpeningAnimationComplete: openingAnim.isOpeningAnimationComplete
isOpeningAnimationComplete: openingAnim.isOpeningAnimationComplete,
paused: paused
});
// Refs for Logic
@ -247,19 +298,8 @@ const KSPlayerCore: React.FC = () => {
}));
customSubs.setAvailableSubtitles(subs);
if (autoSelectEnglish) {
const englishSubtitle = subs.find(sub =>
sub.language.includes('en') || sub.display.toLowerCase().includes('english')
);
if (englishSubtitle) {
loadWyzieSubtitle(englishSubtitle);
return;
}
}
if (!autoSelectEnglish) {
modals.setShowSubtitleLanguageModal(true);
}
// Auto-selection is now handled by useEffect that waits for internal tracks
// This ensures internal tracks are considered before falling back to external
} catch (e) {
logger.error('[VideoPlayer] Error fetching subtitles', e);
} finally {
@ -282,6 +322,7 @@ const KSPlayerCore: React.FC = () => {
const parsedCues = parseSRT(srtContent);
customSubs.setCustomSubtitles(parsedCues);
customSubs.setUseCustomSubtitles(true);
customSubs.setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle
tracks.selectTextTrack(-1);
const adjustedTime = currentTime + (customSubs.subtitleOffsetSec || 0);
@ -302,6 +343,45 @@ const KSPlayerCore: React.FC = () => {
}
}, [imdbId]);
// Auto-select subtitles when both internal tracks and video are loaded
// This ensures we wait for internal tracks before falling back to external
useEffect(() => {
if (!isVideoLoaded || hasAutoSelectedTracks.current || !settings?.enableSubtitleAutoSelect) {
return;
}
const internalTracks = tracks.ksTextTracks;
const externalSubs = customSubs.availableSubtitles;
// Wait a short delay to ensure tracks are fully populated
const timeoutId = setTimeout(() => {
if (hasAutoSelectedTracks.current) return;
const subtitleSelection = findBestSubtitleTrack(
internalTracks,
externalSubs,
{
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
subtitleSourcePreference: settings?.subtitleSourcePreference || 'internal',
enableSubtitleAutoSelect: true
}
);
// Trust the findBestSubtitleTrack function's decision - it already implements priority logic
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
logger.debug(`[KSPlayerCore] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
tracks.selectTextTrack(subtitleSelection.internalTrackId);
hasAutoSelectedTracks.current = true;
} else if (subtitleSelection.type === 'external' && subtitleSelection.externalSubtitle) {
logger.debug(`[KSPlayerCore] Auto-selecting external subtitle: ${subtitleSelection.externalSubtitle.display}`);
loadWyzieSubtitle(subtitleSelection.externalSubtitle);
hasAutoSelectedTracks.current = true;
}
}, 500); // Short delay to ensure tracks are populated
return () => clearTimeout(timeoutId);
}, [isVideoLoaded, tracks.ksTextTracks, customSubs.availableSubtitles, settings]);
// Sync custom subtitle text with current playback time
useEffect(() => {
if (!customSubs.useCustomSubtitles || customSubs.customSubtitles.length === 0) return;
@ -310,8 +390,12 @@ const KSPlayerCore: React.FC = () => {
const cueNow = customSubs.customSubtitles.find(
cue => adjustedTime >= cue.start && adjustedTime <= cue.end
);
customSubs.setCurrentSubtitle(cueNow ? cueNow.text : '');
}, [currentTime, customSubs.useCustomSubtitles, customSubs.customSubtitles, customSubs.subtitleOffsetSec]);
const newText = cueNow ? cueNow.text : '';
// Only update state if the text has changed to avoid unnecessary re-renders
if (newText !== customSubs.currentSubtitle) {
customSubs.setCurrentSubtitle(newText);
}
}, [currentTime, customSubs.useCustomSubtitles, customSubs.customSubtitles, customSubs.subtitleOffsetSec, customSubs.currentSubtitle]);
// Handlers
const onLoad = (data: any) => {
@ -323,10 +407,53 @@ const KSPlayerCore: React.FC = () => {
setIsPlayerReady(true);
openingAnim.completeOpeningAnimation();
// Auto-select audio track based on preferences
if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) {
const bestAudioTrack = findBestAudioTrack(data.audioTracks, settings.preferredAudioLanguage);
if (bestAudioTrack !== null) {
logger.debug(`[KSPlayerCore] Auto-selecting audio track ${bestAudioTrack} for language: ${settings.preferredAudioLanguage}`);
tracks.selectAudioTrack(bestAudioTrack);
if (ksPlayerRef.current) {
ksPlayerRef.current.setAudioTrack(bestAudioTrack);
}
}
}
// Auto-select subtitle track based on preferences
// Only auto-select internal tracks here if preference is 'internal' or 'any'
// If preference is 'external', we wait for the useEffect to handle selection after external subs load
if (data.textTracks && data.textTracks.length > 0 && !hasAutoSelectedTracks.current && settings?.enableSubtitleAutoSelect) {
const sourcePreference = settings?.subtitleSourcePreference || 'internal';
// Only pre-select internal if preference is internal or any
if (sourcePreference === 'internal' || sourcePreference === 'any') {
const subtitleSelection = findBestSubtitleTrack(
data.textTracks,
[], // External subtitles not yet loaded
{
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
subtitleSourcePreference: sourcePreference,
enableSubtitleAutoSelect: true
}
);
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
logger.debug(`[KSPlayerCore] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId} on load`);
tracks.selectTextTrack(subtitleSelection.internalTrackId);
hasAutoSelectedTracks.current = true;
}
}
// If preference is 'external', don't select anything here - useEffect will handle it
}
// Initial Seek
if (initialPosition && initialPosition > 0) {
const resumeTarget = routeInitialPosition || watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && data.duration > 0) {
setTimeout(() => {
controls.seekToTime(initialPosition);
if (ksPlayerRef.current) {
logger.debug(`[KSPlayerCore] Auto-resuming to ${resumeTarget}`);
ksPlayerRef.current.seek(resumeTarget);
}
}, 500);
}
@ -359,15 +486,38 @@ const KSPlayerCore: React.FC = () => {
modals.setShowErrorModal(true);
};
const handleClose = async () => {
const handleClose = useCallback(() => {
if (isSyncingBeforeClose.current) return;
isSyncingBeforeClose.current = true;
await traktAutosync.handleProgressUpdate(currentTime, duration, true);
await traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close');
// Fire and forget - don't block navigation on async operations
// The useWatchProgress and useTraktAutosync hooks handle cleanup on unmount
traktAutosync.handleProgressUpdate(currentTime, duration, true);
traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close');
navigation.goBack();
};
}, [navigation, currentTime, duration, traktAutosync]);
// Track selection handlers - update state, prop change triggers native update
const handleSelectTextTrack = useCallback((trackId: number) => {
console.log('[KSPlayerCore] handleSelectTextTrack called with trackId:', trackId);
// Disable custom subtitles when selecting a built-in track
// This ensures the textTrack prop is actually passed to the native player
if (trackId !== -1) {
customSubs.setUseCustomSubtitles(false);
}
// Just update state - the textTrack prop change will trigger native update
tracks.selectTextTrack(trackId);
}, [tracks, customSubs]);
const handleSelectAudioTrack = useCallback((trackId: number) => {
tracks.selectAudioTrack(trackId);
if (ksPlayerRef.current) {
ksPlayerRef.current.setAudioTrack(trackId);
}
}, [tracks, ksPlayerRef]);
// Stream selection handler
const handleSelectStream = async (newStream: any) => {
@ -466,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}
@ -478,8 +632,8 @@ const KSPlayerCore: React.FC = () => {
setZoomScale={setZoomScale}
lastZoomScale={lastZoomScale}
setLastZoomScale={setLastZoomScale}
audioTrack={tracks.selectedAudioTrack !== null ? tracks.selectedAudioTrack : undefined}
textTrack={customSubs.useCustomSubtitles ? undefined : (tracks.selectedTextTrack !== -1 ? tracks.selectedTextTrack : undefined)}
audioTrack={tracks.selectedAudioTrack ?? undefined}
textTrack={customSubs.useCustomSubtitles ? -1 : tracks.selectedTextTrack}
onAudioTracks={(d) => tracks.setKsAudioTracks(d.audioTracks || [])}
onTextTracks={(d) => tracks.setKsTextTracks(d.textTracks || [])}
onLoad={onLoad}
@ -487,7 +641,11 @@ const KSPlayerCore: React.FC = () => {
if (!isSliderDragging) {
setCurrentTime(d.currentTime);
}
setBuffered(d.buffered || 0);
// Only update buffered if it changed by more than 0.5s to reduce re-renders
const newBuffered = d.buffered || 0;
if (Math.abs(newBuffered - buffered) > 0.5) {
setBuffered(newBuffered);
}
}}
onEnd={async () => {
setCurrentTime(duration);
@ -501,6 +659,23 @@ const KSPlayerCore: React.FC = () => {
screenWidth={screenDimensions.width}
screenHeight={screenDimensions.height}
customVideoStyles={{ width: '100%', height: '100%' }}
subtitleTextColor={customSubs.subtitleTextColor}
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}
/>
{/* Custom Subtitles Overlay */}
@ -592,6 +767,24 @@ const KSPlayerCore: React.FC = () => {
visible={speedControl.showSpeedActivatedOverlay}
opacity={speedControl.speedActivatedOverlayOpacity}
speed={speedControl.holdToSpeedValue}
screenDimensions={screenDimensions}
/>
<ResumeOverlay
showResumeOverlay={watchProgress.showResumeOverlay}
resumePosition={watchProgress.resumePosition}
duration={watchProgress.savedDuration || duration}
title={title}
season={season}
episode={episode}
handleResume={() => {
watchProgress.setShowResumeOverlay(false);
if (watchProgress.resumePosition) controls.seekToTime(watchProgress.resumePosition);
}}
handleStartFromBeginning={() => {
watchProgress.setShowResumeOverlay(false);
controls.seekToTime(0);
}}
/>
{/* Pause Overlay */}
@ -609,10 +802,33 @@ const KSPlayerCore: React.FC = () => {
screenDimensions={screenDimensions}
/>
{/* Parental Guide Overlay - Shows after controls first hide */}
<ParentalGuideOverlay
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
type={type as 'movie' | 'series'}
season={season}
episode={episode}
shouldShow={isVideoLoaded && !showControls && !paused}
/>
{/* Skip Intro Button - Shows during intro section of TV episodes */}
<SkipIntroButton
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
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}
controlsFixedOffset={126}
/>
{/* Up Next Button */}
<UpNextButton
type={type}
nextEpisode={null}
nextEpisode={nextEpisode}
currentTime={currentTime}
duration={duration}
insets={insets}
@ -620,7 +836,13 @@ const KSPlayerCore: React.FC = () => {
nextLoadingProvider={null}
nextLoadingQuality={null}
nextLoadingTitle={null}
onPress={() => { }}
onPress={() => {
if (nextEpisode) {
logger.log(`[KSPlayerCore] Opening streams for next episode: S${nextEpisode.season_number}E${nextEpisode.episode_number}`);
modals.setSelectedEpisodeForStreams(nextEpisode);
modals.setShowEpisodeStreamsModal(true);
}
}}
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
controlsVisible={showControls}
controlsFixedOffset={126}
@ -632,7 +854,7 @@ const KSPlayerCore: React.FC = () => {
setShowAudioModal={modals.setShowAudioModal}
ksAudioTracks={tracks.ksAudioTracks}
selectedAudioTrack={tracks.selectedAudioTrack}
selectAudioTrack={tracks.selectAudioTrack}
selectAudioTrack={handleSelectAudioTrack}
/>
<ErrorModal
@ -694,11 +916,25 @@ const KSPlayerCore: React.FC = () => {
ksTextTracks={tracks.ksTextTracks}
selectedTextTrack={tracks.selectedTextTrack !== null ? tracks.selectedTextTrack : -1}
useCustomSubtitles={customSubs.useCustomSubtitles}
selectTextTrack={tracks.selectTextTrack}
selectTextTrack={handleSelectTextTrack}
disableCustomSubtitles={() => {
customSubs.setUseCustomSubtitles(false);
tracks.selectTextTrack(-1);
customSubs.setSelectedExternalSubtitleId(null); // Clear external selection
handleSelectTextTrack(-1);
}}
selectedExternalSubtitleId={customSubs.selectedExternalSubtitleId}
onOpenSyncModal={() => setShowSyncModal(true)}
/>
{/* Visual Subtitle Sync Modal */}
<SubtitleSyncModal
visible={showSyncModal}
onClose={() => setShowSyncModal(false)}
onConfirm={(offset) => customSubs.setSubtitleOffsetSec(offset)}
currentOffset={customSubs.subtitleOffsetSec}
currentTime={currentTime}
subtitles={customSubs.customSubtitles}
primaryColor={currentTheme.colors.primary}
/>
<SourcesModal

View file

@ -25,7 +25,18 @@ export interface MpvPlayerProps {
onEnd?: () => void;
onError?: (error: { error: string }) => void;
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
useHardwareDecoding?: boolean;
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
gpuMode?: 'gpu' | 'gpu-next';
// Subtitle Styling
subtitleSize?: number;
subtitleColor?: string;
subtitleBackgroundOpacity?: number;
subtitleBorderSize?: number;
subtitleBorderColor?: string;
subtitleShadowEnabled?: boolean;
subtitlePosition?: number;
subtitleDelay?: number;
subtitleAlignment?: 'left' | 'center' | 'right';
}
const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
@ -104,7 +115,18 @@ const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
onEnd={handleEnd}
onError={handleError}
onTracksChanged={handleTracksChanged}
useHardwareDecoding={props.useHardwareDecoding ?? false}
decoderMode={props.decoderMode ?? 'auto'}
gpuMode={props.gpuMode ?? 'gpu'}
// Subtitle Styling
subtitleSize={props.subtitleSize ?? 48}
subtitleColor={props.subtitleColor ?? '#FFFFFF'}
subtitleBackgroundOpacity={props.subtitleBackgroundOpacity ?? 0}
subtitleBorderSize={props.subtitleBorderSize ?? 3}
subtitleBorderColor={props.subtitleBorderColor ?? '#000000'}
subtitleShadowEnabled={props.subtitleShadowEnabled ?? true}
subtitlePosition={props.subtitlePosition ?? 100}
subtitleDelay={props.subtitleDelay ?? 0}
subtitleAlignment={props.subtitleAlignment ?? 'center'}
/>
);
});

View file

@ -1,228 +0,0 @@
import React, { useState, useRef } from 'react';
import { View, Text, TouchableOpacity, ScrollView, Animated, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface PauseOverlayProps {
visible: boolean;
onClose: () => void;
title: string;
episodeTitle?: string;
season?: number;
episode?: number;
year?: string | number;
type: string;
description: string;
cast: any[];
screenDimensions: { width: number, height: number };
}
export const PauseOverlay: React.FC<PauseOverlayProps> = ({
visible,
onClose,
title,
episodeTitle,
season,
episode,
year,
type,
description,
cast,
screenDimensions
}) => {
const insets = useSafeAreaInsets();
// Internal Animation State
const pauseOverlayOpacity = useRef(new Animated.Value(visible ? 1 : 0)).current;
const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current;
const metadataOpacity = useRef(new Animated.Value(1)).current;
const metadataScale = useRef(new Animated.Value(1)).current;
// Cast Details State
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [showCastDetails, setShowCastDetails] = useState(false);
const castDetailsOpacity = useRef(new Animated.Value(0)).current;
const castDetailsScale = useRef(new Animated.Value(0.95)).current;
React.useEffect(() => {
Animated.timing(pauseOverlayOpacity, {
toValue: visible ? 1 : 0,
duration: 250,
useNativeDriver: true
}).start();
}, [visible]);
if (!visible && !showCastDetails) return null;
return (
<TouchableOpacity
activeOpacity={1}
onPress={onClose}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 30,
}}
>
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: pauseOverlayOpacity,
}}
>
{/* Horizontal Fade */}
<View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: screenDimensions.width * 0.7 }}>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
locations={[0, 1]}
style={StyleSheet.absoluteFill}
/>
</View>
<LinearGradient
colors={[
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.0)'
]}
locations={[0, 0.3, 0.6, 1]}
style={StyleSheet.absoluteFill}
/>
<Animated.View style={{
position: 'absolute',
left: 24 + insets.left,
right: 24 + insets.right,
top: 24 + insets.top,
bottom: 110 + insets.bottom,
transform: [{ translateY: pauseOverlayTranslateY }]
}}>
{showCastDetails && selectedCastMember ? (
<Animated.View
style={{
flex: 1,
justifyContent: 'center',
opacity: castDetailsOpacity,
transform: [{ scale: castDetailsScale }]
}}
>
<View style={{ alignItems: 'flex-start', paddingBottom: screenDimensions.height * 0.1 }}>
<TouchableOpacity
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingVertical: 8, paddingHorizontal: 4 }}
onPress={() => {
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(false);
setSelectedCastMember(null);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={{ color: '#B8B8B8', fontSize: Math.min(14, screenDimensions.width * 0.02) }}>Back to details</Text>
</TouchableOpacity>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', width: '100%' }}>
{selectedCastMember.profile_path && (
<View style={{ marginRight: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5 }}>
<FastImage
source={{ uri: `https://image.tmdb.org/t/p/w300${selectedCastMember.profile_path}` }}
style={{ width: Math.min(120, screenDimensions.width * 0.18), height: Math.min(180, screenDimensions.width * 0.27), borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.1)' }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
)}
<View style={{ flex: 1, paddingTop: 8 }}>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(32, screenDimensions.width * 0.045), fontWeight: '800', marginBottom: 8 }} numberOfLines={2}>
{selectedCastMember.name}
</Text>
{selectedCastMember.character && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8, fontWeight: '500', fontStyle: 'italic' }} numberOfLines={2}>
as {selectedCastMember.character}
</Text>
)}
{selectedCastMember.biography && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(14, screenDimensions.width * 0.019), lineHeight: Math.min(20, screenDimensions.width * 0.026), marginTop: 16, opacity: 0.9 }} numberOfLines={4}>
{selectedCastMember.biography}
</Text>
)}
</View>
</View>
</View>
</Animated.View>
) : (
<Animated.View style={{ flex: 1, justifyContent: 'space-between', opacity: metadataOpacity, transform: [{ scale: metadataScale }] }}>
<View>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }}>You're watching</Text>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(48, screenDimensions.width * 0.06), fontWeight: '800', marginBottom: 10 }} numberOfLines={2}>
{title}
</Text>
{!!year && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }} numberOfLines={1}>
{`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`}
</Text>
)}
{!!episodeTitle && (
<Text style={{ color: '#FFFFFF', fontSize: Math.min(20, screenDimensions.width * 0.03), fontWeight: '600', marginBottom: 8 }} numberOfLines={2}>
{episodeTitle}
</Text>
)}
{description && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(18, screenDimensions.width * 0.025), lineHeight: Math.min(24, screenDimensions.width * 0.03) }} numberOfLines={3}>
{description}
</Text>
)}
{cast && cast.length > 0 && (
<View style={{ marginTop: 16 }}>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8 }}>Cast</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{cast.slice(0, 6).map((castMember: any, index: number) => (
<TouchableOpacity
key={castMember.id || index}
style={{ backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: Math.min(12, screenDimensions.width * 0.015), paddingVertical: Math.min(6, screenDimensions.height * 0.008), marginRight: 8, marginBottom: 8 }}
onPress={() => {
setSelectedCastMember(castMember);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(true);
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(14, screenDimensions.width * 0.018) }}>
{castMember.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</View>
</Animated.View>
)}
</Animated.View>
</Animated.View>
</TouchableOpacity>
);
};

View file

@ -1,32 +0,0 @@
import React from 'react';
import { View, Text, Animated, StyleSheet } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { styles } from '../../utils/playerStyles';
interface SpeedActivatedOverlayProps {
visible: boolean;
opacity: Animated.Value;
speed: number;
}
export const SpeedActivatedOverlay: React.FC<SpeedActivatedOverlayProps> = ({
visible,
opacity,
speed
}) => {
if (!visible) return null;
return (
<Animated.View
style={[
styles.speedActivatedOverlay,
{ opacity: opacity }
]}
>
<View style={styles.speedActivatedContainer}>
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
<Text style={styles.speedActivatedText}>{speed}x Speed</Text>
</View>
</Animated.View>
);
};

View file

@ -1,9 +1,35 @@
import React, { useCallback, memo } from 'react';
import React, { useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native';
import { PinchGestureHandler } from 'react-native-gesture-handler';
import Video, { VideoRef, SelectedTrack, SelectedVideoTrack, ResizeMode } from 'react-native-video';
import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer';
import { styles } from '../../utils/playerStyles';
import { ResizeModeType } from '../../utils/playerTypes';
import { logger } from '../../../../utils/logger';
// Codec error patterns that indicate we should fallback to MPV
const CODEC_ERROR_PATTERNS = [
'exceeds_capabilities',
'no_exceeds_capabilities',
'decoder_exception',
'decoder.*error',
'codec.*error',
'unsupported.*codec',
'mediacodec.*exception',
'omx.*error',
'dolby.*vision',
'hevc.*error',
'no suitable decoder',
'decoder initialization failed',
'format.no_decoder',
'no_decoder',
'decoding_failed',
'error_code_decoding',
'exoplaybackexception',
'mediacodecvideodecoder',
'mediacodecvideodecoderexception',
'decoder failed',
];
interface VideoSurfaceProps {
processedStreamUrl: string;
@ -25,6 +51,7 @@ interface VideoSurfaceProps {
// Refs
mpvPlayerRef?: React.RefObject<MpvPlayerRef>;
exoPlayerRef?: React.RefObject<VideoRef>;
pinchRef: any;
// Handlers
@ -32,9 +59,38 @@ interface VideoSurfaceProps {
onPinchHandlerStateChange: any;
screenDimensions: { width: number, height: number };
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
useHardwareDecoding?: boolean;
selectedAudioTrack?: SelectedTrack;
selectedTextTrack?: SelectedTrack;
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
gpuMode?: 'gpu' | 'gpu-next';
// Dual Engine Props
useExoPlayer?: boolean;
onCodecError?: () => void;
onEngineChange?: (engine: 'exoplayer' | 'mpv') => void;
// Subtitle Styling
subtitleSize?: number;
subtitleColor?: string;
subtitleBackgroundOpacity?: number;
subtitleBorderSize?: number;
subtitleBorderColor?: string;
subtitleShadowEnabled?: boolean;
subtitlePosition?: number;
subtitleBottomOffset?: number;
subtitleDelay?: number;
subtitleAlignment?: 'left' | 'center' | 'right';
}
// Helper function to check if error is a codec error
const isCodecError = (errorString: string): boolean => {
const lowerError = errorString.toLowerCase();
return CODEC_ERROR_PATTERNS.some(pattern => {
const regex = new RegExp(pattern, 'i');
return regex.test(lowerError);
});
};
export const VideoSurface: React.FC<VideoSurfaceProps> = ({
processedStreamUrl,
headers,
@ -51,20 +107,38 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
onError,
onBuffer,
mpvPlayerRef,
exoPlayerRef,
pinchRef,
onPinchGestureEvent,
onPinchHandlerStateChange,
screenDimensions,
onTracksChanged,
useHardwareDecoding,
selectedAudioTrack,
selectedTextTrack,
decoderMode,
gpuMode,
// Dual Engine
useExoPlayer = true,
onCodecError,
onEngineChange,
// Subtitle Styling
subtitleSize,
subtitleColor,
subtitleBackgroundOpacity,
subtitleBorderSize,
subtitleBorderColor,
subtitleShadowEnabled,
subtitlePosition,
subtitleBottomOffset,
subtitleDelay,
subtitleAlignment,
}) => {
// Use the actual stream URL
const streamUrl = currentStreamUrl || processedStreamUrl;
// Debug logging removed to prevent console spam
const handleLoad = (data: { duration: number; width: number; height: number }) => {
console.log('[VideoSurface] onLoad received:', data);
// ========== MPV Handlers ==========
const handleMpvLoad = (data: { duration: number; width: number; height: number }) => {
console.log('[VideoSurface] MPV onLoad received:', data);
onLoad({
duration: data.duration,
naturalSize: {
@ -74,15 +148,15 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
});
};
const handleProgress = (data: { currentTime: number; duration: number }) => {
const handleMpvProgress = (data: { currentTime: number; duration: number }) => {
onProgress({
currentTime: data.currentTime,
playableDuration: data.currentTime,
});
};
const handleError = (error: { error: string }) => {
console.log('[VideoSurface] onError received:', error);
const handleMpvError = (error: { error: string }) => {
console.log('[VideoSurface] MPV onError received:', error);
onError({
error: {
errorString: error.error,
@ -90,33 +164,231 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
});
};
const handleEnd = () => {
console.log('[VideoSurface] onEnd received');
const handleMpvEnd = () => {
console.log('[VideoSurface] MPV onEnd received');
onEnd();
};
// ========== ExoPlayer Handlers ==========
const handleExoLoad = (data: any) => {
console.log('[VideoSurface] ExoPlayer onLoad received:', data);
console.log('[VideoSurface] ExoPlayer textTracks raw:', JSON.stringify(data.textTracks, null, 2));
// Extract track information
// IMPORTANT:
// react-native-video expects selected*Track with { type: 'index', value: <0-based array index> }.
// Some RNVideo/Exo track objects expose `index`, but it is not guaranteed to be unique or
// aligned with the list index. Using it can cause only the first item to render/select.
const audioTracks = data.audioTracks?.map((t: any, i: number) => ({
id: i,
name: t.title || t.language || `Track ${i + 1}`,
language: t.language,
})) ?? [];
const subtitleTracks = data.textTracks?.map((t: any, i: number) => {
const track = {
id: i,
name: t.title || t.language || `Track ${i + 1}`,
language: t.language,
};
console.log('[VideoSurface] Mapped subtitle track:', track, 'original:', t);
return track;
}) ?? [];
if (onTracksChanged && (audioTracks.length > 0 || subtitleTracks.length > 0)) {
onTracksChanged({ audioTracks, subtitleTracks });
}
onLoad({
duration: data.duration,
naturalSize: data.naturalSize || { width: 1920, height: 1080 },
audioTracks: data.audioTracks,
textTracks: data.textTracks,
});
};
const handleExoProgress = (data: any) => {
onProgress({
currentTime: data.currentTime,
playableDuration: data.playableDuration || data.currentTime,
});
};
const handleExoError = (error: any) => {
console.log('[VideoSurface] ExoPlayer onError received:', JSON.stringify(error, null, 2));
// Extract error string - try multiple paths
let errorString = 'Unknown error';
const errorParts: string[] = [];
if (typeof error?.error === 'string') {
errorParts.push(error.error);
}
if (error?.error?.errorString) {
errorParts.push(error.error.errorString);
}
if (error?.error?.errorCode) {
errorParts.push(String(error.error.errorCode));
}
if (typeof error === 'string') {
errorParts.push(error);
}
if (error?.nativeStackAndroid) {
errorParts.push(error.nativeStackAndroid.join(' '));
}
if (error?.message) {
errorParts.push(error.message);
}
// Combine all error parts for comprehensive checking
errorString = errorParts.length > 0 ? errorParts.join(' ') : JSON.stringify(error);
console.log('[VideoSurface] Extracted error string:', errorString);
console.log('[VideoSurface] isCodecError result:', isCodecError(errorString));
// Check if this is a codec error that should trigger fallback
if (isCodecError(errorString)) {
logger.warn('[VideoSurface] ExoPlayer codec error detected, triggering MPV fallback:', errorString);
onCodecError?.();
return; // Don't propagate codec errors - we're falling back silently
}
// Non-codec errors should be propagated
onError({
error: {
errorString: errorString,
},
});
};
const handleExoBuffer = (data: any) => {
onBuffer({ isBuffering: data.isBuffering });
};
const handleExoEnd = () => {
console.log('[VideoSurface] ExoPlayer onEnd received');
onEnd();
};
const handleExoSeek = (data: any) => {
onSeek({ currentTime: data.currentTime });
};
// Map ResizeModeType to react-native-video ResizeMode
const getExoResizeMode = (): ResizeMode => {
switch (resizeMode) {
case 'cover':
return ResizeMode.COVER;
case 'stretch':
return ResizeMode.STRETCH;
case 'contain':
default:
return ResizeMode.CONTAIN;
}
};
const alphaHex = (opacity01: number) => {
const a = Math.max(0, Math.min(1, opacity01));
return Math.round(a * 255).toString(16).padStart(2, '0').toUpperCase();
};
return (
<View style={[styles.videoContainer, {
width: screenDimensions.width,
height: screenDimensions.height,
}]}>
{/* MPV Player - rendered at the bottom of the z-order */}
<MpvPlayer
ref={mpvPlayerRef}
source={streamUrl}
headers={headers}
paused={paused}
volume={volume}
rate={playbackSpeed}
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
style={localStyles.player}
onLoad={handleLoad}
onProgress={handleProgress}
onEnd={handleEnd}
onError={handleError}
onTracksChanged={onTracksChanged}
useHardwareDecoding={useHardwareDecoding}
/>
{useExoPlayer ? (
/* ExoPlayer via react-native-video */
<Video
ref={exoPlayerRef}
source={{
uri: streamUrl,
headers: headers,
}}
paused={paused}
volume={volume}
rate={playbackSpeed}
resizeMode={getExoResizeMode()}
selectedAudioTrack={selectedAudioTrack}
selectedTextTrack={selectedTextTrack}
style={localStyles.player}
onLoad={handleExoLoad}
onProgress={handleExoProgress}
onEnd={handleExoEnd}
onError={handleExoError}
onBuffer={handleExoBuffer}
onSeek={handleExoSeek}
progressUpdateInterval={500}
playInBackground={false}
playWhenInactive={false}
ignoreSilentSwitch="ignore"
automaticallyWaitsToMinimizeStalling={true}
useTextureView={true}
// Subtitle Styling for ExoPlayer
// ExoPlayer (via our patched react-native-video) supports:
// - fontSize, paddingTop/Bottom/Left/Right, opacity, subtitlesFollowVideo
// - PLUS: textColor, backgroundColor, edgeType, edgeColor (outline/shadow)
subtitleStyle={{
// Convert MPV-scaled size back to UI size (AndroidVideoPlayer passes MPV-scaled values here)
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 28,
paddingTop: 0,
// IMPORTANT:
// Use the same unit as external subtitles (RN CustomSubtitles uses dp bottomOffset directly).
// Using MPV's subtitlePosition mapping makes internal/external offsets feel inconsistent.
paddingBottom: Math.max(0, Math.round(subtitleBottomOffset ?? 0)),
paddingLeft: 16,
paddingRight: 16,
// Opacity controls entire subtitle view visibility
// Always keep text visible (opacity 1), background control is limited in ExoPlayer
opacity: 1,
subtitlesFollowVideo: false,
// Extended styling (requires our patched RNVideo on Android)
textColor: subtitleColor || '#FFFFFFFF',
// Android Color.parseColor doesn't accept rgba(...). Use #AARRGGBB.
backgroundColor:
subtitleBackgroundOpacity && subtitleBackgroundOpacity > 0
? `#${alphaHex(subtitleBackgroundOpacity)}000000`
: '#00000000',
edgeType:
subtitleBorderSize && subtitleBorderSize > 0
? 'outline'
: (subtitleShadowEnabled ? 'shadow' : 'none'),
edgeColor:
(subtitleBorderSize && subtitleBorderSize > 0 && subtitleBorderColor)
? subtitleBorderColor
: (subtitleShadowEnabled ? '#FF000000' : 'transparent'),
} as any}
/>
) : (
/* MPV Player fallback */
<MpvPlayer
ref={mpvPlayerRef}
source={streamUrl}
headers={headers}
paused={paused}
volume={volume}
rate={playbackSpeed}
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
style={localStyles.player}
onLoad={handleMpvLoad}
onProgress={handleMpvProgress}
onEnd={handleMpvEnd}
onError={handleMpvError}
onTracksChanged={onTracksChanged}
decoderMode={decoderMode}
gpuMode={gpuMode}
// Subtitle Styling
subtitleSize={subtitleSize}
subtitleColor={subtitleColor}
subtitleBackgroundOpacity={subtitleBackgroundOpacity}
subtitleBorderSize={subtitleBorderSize}
subtitleBorderColor={subtitleBorderColor}
subtitleShadowEnabled={subtitleShadowEnabled}
subtitlePosition={subtitlePosition}
subtitleDelay={subtitleDelay}
subtitleAlignment={subtitleAlignment}
/>
)}
{/* Gesture overlay - transparent, on top of the player */}
<PinchGestureHandler

View file

@ -1,5 +1,6 @@
import { useRef, useCallback } from 'react';
import { Platform } from 'react-native';
import { VideoRef } from 'react-native-video';
import { logger } from '../../../../utils/logger';
const DEBUG_MODE = true; // Temporarily enable for debugging seek
@ -12,7 +13,10 @@ export const usePlayerControls = (
currentTime: number,
duration: number,
isSeeking: React.MutableRefObject<boolean>,
isMounted: React.MutableRefObject<boolean>
isMounted: React.MutableRefObject<boolean>,
// Dual engine support
exoPlayerRef?: React.RefObject<VideoRef>,
useExoPlayer?: boolean
) => {
// iOS seeking helpers
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
@ -28,12 +32,30 @@ export const usePlayerControls = (
rawSeconds,
timeInSeconds,
hasMpvRef: !!mpvPlayerRef?.current,
hasExoRef: !!exoPlayerRef?.current,
useExoPlayer,
duration,
isSeeking: isSeeking.current
});
// MPV Player
if (mpvPlayerRef.current && duration > 0) {
// ExoPlayer
if (useExoPlayer && exoPlayerRef?.current && duration > 0) {
console.log(`[usePlayerControls][ExoPlayer] Seeking to ${timeInSeconds}`);
isSeeking.current = true;
exoPlayerRef.current.seek(timeInSeconds);
// Reset seeking flag after a delay
setTimeout(() => {
if (isMounted.current) {
isSeeking.current = false;
}
}, 500);
return;
}
// MPV Player (fallback or when useExoPlayer is false)
if (mpvPlayerRef?.current && duration > 0) {
console.log(`[usePlayerControls][MPV] Seeking to ${timeInSeconds}`);
isSeeking.current = true;
@ -45,13 +67,16 @@ export const usePlayerControls = (
isSeeking.current = false;
}
}, 500);
} else {
console.log('[usePlayerControls][MPV] Cannot seek - ref or duration invalid:', {
hasRef: !!mpvPlayerRef?.current,
duration
});
return;
}
}, [duration, paused, setPaused, mpvPlayerRef, isSeeking, isMounted]);
console.log('[usePlayerControls] Cannot seek - no valid ref:', {
hasExoRef: !!exoPlayerRef?.current,
hasMpvRef: !!mpvPlayerRef?.current,
useExoPlayer,
duration
});
}, [duration, paused, setPaused, mpvPlayerRef, exoPlayerRef, useExoPlayer, isSeeking, isMounted]);
const skip = useCallback((seconds: number) => {
console.log('[usePlayerControls] skip called:', { seconds, currentTime, newTime: currentTime + seconds });

View file

@ -3,6 +3,7 @@ import { StatusBar, Platform, Dimensions, AppState } from 'react-native';
import RNImmersiveMode from 'react-native-immersive-mode';
import * as NavigationBar from 'expo-navigation-bar';
import * as Brightness from 'expo-brightness';
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
import { logger } from '../../../../utils/logger';
import { useFocusEffect } from '@react-navigation/native';
import { useCallback } from 'react';
@ -19,6 +20,19 @@ export const usePlayerSetup = (
const originalSystemBrightnessModeRef = useRef<number | null>(null);
const isAppBackgrounded = useRef(false);
// Prevent screen sleep while playing
// Prevent screen sleep while playing
useEffect(() => {
if (!paused) {
activateKeepAwakeAsync();
} else {
deactivateKeepAwake();
}
return () => {
deactivateKeepAwake();
};
}, [paused]);
const enableImmersiveMode = async () => {
if (Platform.OS === 'android') {
// Standard immersive mode

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

@ -105,7 +105,10 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
// Animate vertical offset based on controls visibility
useEffect(() => {
const target = controlsVisible ? -Math.max(0, controlsFixedOffset - 8) : 0;
// Android needs more offset to clear the slider
const androidOffset = controlsFixedOffset - 8;
const iosOffset = controlsFixedOffset / 2;
const target = controlsVisible ? -(Platform.OS === 'android' ? androidOffset : iosOffset) : 0;
Animated.timing(translateY, {
toValue: target,
duration: 220,

View file

@ -1,35 +1,57 @@
/**
* Shared Speed Activated Overlay Component
* Used by both Android (VLC) and iOS (KSPlayer) players
*/
import React from 'react';
import { View, Text, Animated } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { styles } from '../utils/playerStyles';
interface SpeedActivatedOverlayProps {
visible: boolean;
opacity: Animated.Value;
opacity: Animated.Value | number;
speed: number;
screenDimensions: { width: number; height: number };
}
export const SpeedActivatedOverlay: React.FC<SpeedActivatedOverlayProps> = ({
visible,
opacity,
speed
speed,
screenDimensions
}) => {
if (!visible) return null;
// Safety check to prevent the 'height' of undefined error
if (!visible || !screenDimensions) return null;
return (
<Animated.View
style={[
styles.speedActivatedOverlay,
{ opacity: opacity }
]}
style={{
position: 'absolute',
top: screenDimensions.height * 0.06,
left: 0,
right: 0,
alignItems: 'center',
opacity: opacity,
zIndex: 1000,
}}
>
<View style={styles.speedActivatedContainer}>
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
<Text style={styles.speedActivatedText}>{speed}x Speed</Text>
<View style={{
flexDirection: 'row',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 35,
paddingHorizontal: 16,
paddingVertical: 10,
alignItems: 'center',
elevation: 5,
}}>
<MaterialIcons
name="fast-forward"
size={20}
color="white"
style={{ marginRight: 6 }}
/>
<Text style={{
color: 'white',
fontSize: 15,
fontWeight: '600',
}}>
{speed}x
</Text>
</View>
</Animated.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';
@ -24,7 +25,7 @@ interface PlayerControlsProps {
duration: number;
zoomScale: number;
currentResizeMode?: string;
ksAudioTracks: Array<{id: number, name: string, language?: string}>;
ksAudioTracks: Array<{ id: number, name: string, language?: string }>;
selectedAudioTrack: number | null;
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
togglePlayback: () => void;
@ -50,6 +51,9 @@ interface PlayerControlsProps {
isAirPlayActive?: boolean;
allowsAirPlay?: boolean;
onAirPlayPress?: () => void;
// MPV Switch (Android only)
onSwitchToMPV?: () => void;
useExoPlayer?: boolean;
}
export const PlayerControls: React.FC<PlayerControlsProps> = ({
@ -92,8 +96,11 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
isAirPlayActive,
allowsAirPlay,
onAirPlayPress,
onSwitchToMPV,
useExoPlayer,
}) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
/* Responsive Spacing */
@ -110,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;
@ -131,7 +145,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
/* Handle Seek with Animation */
const handleSeekWithAnimation = (seconds: number) => {
const isForward = seconds > 0;
if (isForward) {
setShowForwardSign(true);
} else {
@ -275,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}
@ -286,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>
@ -314,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}>
@ -336,6 +362,19 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
/>
</TouchableOpacity>
)}
{/* Switch to MPV Button - Android only, when using ExoPlayer */}
{Platform.OS === 'android' && onSwitchToMPV && useExoPlayer && (
<TouchableOpacity
style={{ padding: 8 }}
onPress={onSwitchToMPV}
>
<Ionicons
name="swap-horizontal"
size={closeIconSize}
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
<Ionicons name="close" size={closeIconSize} color="white" />
</TouchableOpacity>
@ -343,34 +382,35 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</View>
</LinearGradient>
{/* Center Controls - CloudStream Style */}
<View style={[styles.controls, {
transform: [{ translateY: -(playButtonSize / 2) }]
<View style={[styles.controls, {
transform: [{ translateY: -(playButtonSize / 2) }]
}]}>
{/* Backward Seek Button (-10s) */}
<TouchableOpacity
onPress={() => handleSeekWithAnimation(-10)}
<TouchableOpacity
onPress={() => handleSeekWithAnimation(-10)}
activeOpacity={0.7}
>
<Animated.View style={[
styles.seekButtonContainer,
{
{
width: seekButtonSize,
height: seekButtonSize,
transform: [{ scale: backwardScaleAnim }]
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,
{
{
opacity: backwardPressAnim,
width: seekButtonSize * 0.6,
height: seekButtonSize * 0.6,
@ -383,65 +423,65 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
}]}>
<Animated.Text style={[
styles.seekNumber,
{
{
fontSize: seekNumberSize,
marginLeft: 7,
transform: [{ translateX: backwardSlideAnim }]
transform: [{ translateX: backwardSlideAnim }]
}
]}>
{showBackwardSign ? '-10' : '10'}
</Animated.Text>
</View>
</Animated.View>
<Animated.View style={[
styles.arcContainer,
</Animated.View>
<Animated.View style={[
styles.arcContainer,
{
width: seekButtonSize,
height: seekButtonSize,
opacity: backwardArcOpacity,
transform: [{
rotate: backwardArcRotation.interpolate({
inputRange: [0, 1],
outputRange: ['90deg', '-90deg']
})
}]
}
]}>
<View style={[
styles.arcLeft,
{
width: seekButtonSize,
height: seekButtonSize,
opacity: backwardArcOpacity,
transform: [{
rotate: backwardArcRotation.interpolate({
inputRange: [0, 1],
outputRange: ['90deg', '-90deg']
})
}]
borderRadius: seekButtonSize / 2,
borderWidth: arcBorderWidth,
}
]}>
<View style={[
styles.arcLeft,
{
width: seekButtonSize,
height: seekButtonSize,
borderRadius: seekButtonSize / 2,
borderWidth: arcBorderWidth,
}
]} />
</Animated.View>
]} />
</Animated.View>
</TouchableOpacity>
{/* Play/Pause Button */}
<TouchableOpacity
onPress={handlePlayPauseWithAnimation}
<TouchableOpacity
onPress={handlePlayPauseWithAnimation}
activeOpacity={0.7}
style={{ marginHorizontal: buttonSpacing }}
>
<View style={[styles.playButtonCircle, { width: playButtonSize, height: playButtonSize }]}>
<Animated.View style={[
styles.playPressCircle,
{
{
opacity: playPressAnim,
width: playButtonSize * 0.85,
height: playButtonSize * 0.85,
borderRadius: (playButtonSize * 0.85) / 2,
}
]} />
<Animated.View style={{
<Animated.View style={{
transform: [{ scale: playIconScale }],
opacity: playIconOpacity
opacity: playIconOpacity
}}>
<Ionicons
name={paused ? "play" : "pause"}
size={playIconSizeCalculated}
<Ionicons
name={paused ? "play" : "pause"}
size={playIconSizeCalculated}
color="#FFFFFF"
/>
</Animated.View>
@ -449,26 +489,26 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</TouchableOpacity>
{/* Forward Seek Button (+10s) */}
<TouchableOpacity
onPress={() => handleSeekWithAnimation(10)}
activeOpacity={0.7}
>
<Animated.View style={[
styles.seekButtonContainer,
{
width: seekButtonSize,
height: seekButtonSize,
transform: [{ scale: forwardScaleAnim }]
}
]}>
<Ionicons
name="reload-outline"
size={seekIconSize}
color="white"
/>
<TouchableOpacity
onPress={() => handleSeekWithAnimation(10)}
activeOpacity={0.7}
>
<Animated.View style={[
styles.seekButtonContainer,
{
width: seekButtonSize,
height: seekButtonSize,
transform: [{ scale: forwardScaleAnim }]
}
]}>
<Ionicons
name="reload-outline"
size={seekIconSize}
color="white"
/>
<Animated.View style={[
styles.buttonCircle,
{
{
opacity: forwardPressAnim,
width: seekButtonSize * 0.6,
height: seekButtonSize * 0.6,
@ -481,9 +521,9 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
}]}>
<Animated.Text style={[
styles.seekNumber,
{
{
fontSize: seekNumberSize,
transform: [{ translateX: forwardSlideAnim }]
transform: [{ translateX: forwardSlideAnim }]
}
]}>
{showForwardSign ? '+10' : '10'}
@ -566,10 +606,10 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
onPress={() => setShowAudioModal(true)}
disabled={ksAudioTracks.length <= 1}
>
<Ionicons
name="musical-notes-outline"
size={24}
color={ksAudioTracks.length <= 1 ? 'grey' : 'white'}
<Ionicons
name="musical-notes-outline"
size={24}
color={ksAudioTracks.length <= 1 ? 'grey' : 'white'}
/>
</TouchableOpacity>
@ -590,4 +630,4 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
);
};
export default PlayerControls;
export default PlayerControls;

View file

@ -19,3 +19,4 @@ export { usePlayerSetup } from './usePlayerSetup';
// Content
export { useNextEpisode } from './useNextEpisode';
export { useWatchProgress } from './useWatchProgress';

View file

@ -18,6 +18,7 @@ export const useCustomSubtitles = () => {
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState<string | null>(null);
// Loading State
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
@ -91,6 +92,7 @@ export const useCustomSubtitles = () => {
currentFormattedSegments, setCurrentFormattedSegments,
availableSubtitles, setAvailableSubtitles,
useCustomSubtitles, setUseCustomSubtitles,
selectedExternalSubtitleId, setSelectedExternalSubtitleId,
isLoadingSubtitles, setIsLoadingSubtitles,
isLoadingSubtitleList, setIsLoadingSubtitleList,
subtitleSize, setSubtitleSize,

View file

@ -46,10 +46,7 @@ export const usePlayerControls = (config: PlayerControlsConfig) => {
isSeeking.current = true;
// iOS optimization: pause while seeking for smoother experience
if (Platform.OS === 'ios') {
iosWasPausedDuringSeekRef.current = paused;
if (!paused) setPaused(true);
}
// Actually perform the seek
playerRef.current.seek(timeInSeconds);
@ -59,10 +56,7 @@ export const usePlayerControls = (config: PlayerControlsConfig) => {
if (isMounted.current && isSeeking.current) {
isSeeking.current = false;
// Resume if it was playing (iOS specific)
if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) {
setPaused(false);
iosWasPausedDuringSeekRef.current = null;
}
}
}, 500);
}

View file

@ -1,12 +1,8 @@
/**
* Shared Player Setup Hook
* Used by both Android (VLC) and iOS (KSPlayer) players
* Handles StatusBar, orientation, brightness, and app state
*/
import { useEffect, useRef, useCallback } from 'react';
import { StatusBar, Dimensions, AppState, InteractionManager, Platform } from 'react-native';
import * as Brightness from 'expo-brightness';
import * as ScreenOrientation from 'expo-screen-orientation';
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
import { logger } from '../../../utils/logger';
import { useFocusEffect } from '@react-navigation/native';
@ -15,6 +11,7 @@ interface PlayerSetupConfig {
setVolume: (vol: number) => void;
setBrightness: (bri: number) => void;
isOpeningAnimationComplete: boolean;
paused: boolean;
}
export const usePlayerSetup = (config: PlayerSetupConfig) => {
@ -22,9 +19,23 @@ export const usePlayerSetup = (config: PlayerSetupConfig) => {
setScreenDimensions,
setVolume,
setBrightness,
isOpeningAnimationComplete
isOpeningAnimationComplete,
paused
} = config;
// Prevent screen sleep while playing
// Prevent screen sleep while playing
useEffect(() => {
if (!paused) {
activateKeepAwakeAsync();
} else {
deactivateKeepAwake();
}
return () => {
deactivateKeepAwake();
};
}, [paused]);
const isAppBackgrounded = useRef(false);
const enableImmersiveMode = () => {
@ -81,22 +92,29 @@ export const usePlayerSetup = (config: PlayerSetupConfig) => {
};
}, [isOpeningAnimationComplete]);
// Handle Orientation (Lock to Landscape after opening)
const orientationLocked = useRef(false);
useEffect(() => {
if (isOpeningAnimationComplete) {
if (isOpeningAnimationComplete && !orientationLocked.current) {
const task = InteractionManager.runAfterInteractions(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)
.then(() => {
if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation');
orientationLocked.current = true;
})
.catch((error) => {
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
});
.catch(() => { });
});
return () => task.cancel();
}
}, [isOpeningAnimationComplete]);
useEffect(() => {
return () => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT)
.then(() => ScreenOrientation.unlockAsync())
.catch(() => { });
};
}, []);
// Handle App State
useEffect(() => {
const onAppStateChange = (state: string) => {

View file

@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { storageService } from '../../../../services/storageService';
import { logger } from '../../../../utils/logger';
import { useSettings } from '../../../../hooks/useSettings';
import { AppState, AppStateStatus } from 'react-native';
import { storageService } from '../../../services/storageService';
import { logger } from '../../../utils/logger';
import { useSettings } from '../../../hooks/useSettings';
export const useWatchProgress = (
id: string | undefined,
@ -11,7 +12,8 @@ export const useWatchProgress = (
duration: number,
paused: boolean,
traktAutosync: any,
seekToTime: (time: number) => void
seekToTime: (time: number) => void,
addonId?: string
) => {
const [resumePosition, setResumePosition] = useState<number | null>(null);
const [savedDuration, setSavedDuration] = useState<number | null>(null);
@ -34,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 () => {
@ -74,7 +114,8 @@ export const useWatchProgress = (
const progress = {
currentTime: currentTimeRef.current,
duration: durationRef.current,
lastUpdated: Date.now()
lastUpdated: Date.now(),
addonId: addonId
};
try {
await storageService.setWatchProgress(id, type, progress, episodeId);
@ -102,13 +143,17 @@ export const useWatchProgress = (
}
}, [id, type, paused, currentTime, duration]);
// Unmount Save
// Unmount Save - deferred to allow navigation to complete first
useEffect(() => {
return () => {
if (id && type && durationRef.current > 0) {
saveWatchProgress();
traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount');
}
// Use setTimeout(0) to defer save operations to next event loop tick
// This allows navigation animations to complete smoothly
setTimeout(() => {
if (id && type && durationRef.current > 0) {
saveWatchProgress();
traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount');
}
}, 0);
};
}, [id, type]);

View file

@ -36,6 +36,13 @@ interface KSPlayerSurfaceProps {
screenWidth: number;
screenHeight: number;
customVideoStyles: any;
// Subtitle styling
subtitleTextColor?: string;
subtitleBackgroundColor?: string;
subtitleFontSize?: number;
subtitleBottomOffset?: number;
subtitleOutlineEnabled?: boolean;
}
export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
@ -64,7 +71,12 @@ export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
onPlaybackResume,
screenWidth,
screenHeight,
customVideoStyles
customVideoStyles,
subtitleTextColor,
subtitleBackgroundColor,
subtitleFontSize,
subtitleBottomOffset,
subtitleOutlineEnabled
}) => {
const pinchRef = useRef<PinchGestureHandler>(null);
@ -87,6 +99,11 @@ export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
headers
};
// Debug: log textTrack prop changes
React.useEffect(() => {
console.log('[KSPlayerSurface] textTrack prop changed to:', textTrack);
}, [textTrack]);
// Handle buffering - KSPlayerComponent uses onBuffering callback
const handleBuffering = (data: any) => {
onBuffer(data?.isBuffering ?? false);
@ -127,6 +144,11 @@ export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
resizeMode={resizeMode}
audioTrack={audioTrack}
textTrack={textTrack}
subtitleTextColor={subtitleTextColor}
subtitleBackgroundColor={subtitleBackgroundColor}
subtitleFontSize={subtitleFontSize}
subtitleBottomOffset={subtitleBottomOffset}
subtitleOutlineEnabled={subtitleOutlineEnabled}
onLoad={handleLoad}
onProgress={onProgress}
onBuffering={handleBuffering}

View file

@ -1,228 +0,0 @@
import React, { useState, useRef } from 'react';
import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface PauseOverlayProps {
visible: boolean;
onClose: () => void;
title: string;
episodeTitle?: string;
season?: number;
episode?: number;
year?: string | number;
type: string;
description: string;
cast: any[];
screenDimensions: { width: number, height: number };
}
export const PauseOverlay: React.FC<PauseOverlayProps> = ({
visible,
onClose,
title,
episodeTitle,
season,
episode,
year,
type,
description,
cast,
screenDimensions
}) => {
const insets = useSafeAreaInsets();
// Internal Animation State
const pauseOverlayOpacity = useRef(new Animated.Value(visible ? 1 : 0)).current;
const pauseOverlayTranslateY = useRef(new Animated.Value(12)).current;
const metadataOpacity = useRef(new Animated.Value(1)).current;
const metadataScale = useRef(new Animated.Value(1)).current;
// Cast Details State
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [showCastDetails, setShowCastDetails] = useState(false);
const castDetailsOpacity = useRef(new Animated.Value(0)).current;
const castDetailsScale = useRef(new Animated.Value(0.95)).current;
React.useEffect(() => {
Animated.timing(pauseOverlayOpacity, {
toValue: visible ? 1 : 0,
duration: 250,
useNativeDriver: true
}).start();
}, [visible]);
if (!visible && !showCastDetails) return null;
return (
<TouchableOpacity
activeOpacity={1}
onPress={onClose}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 30,
}}
>
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: pauseOverlayOpacity,
}}
>
{/* Horizontal Fade */}
<View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: screenDimensions.width * 0.7 }}>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
locations={[0, 1]}
style={StyleSheet.absoluteFill}
/>
</View>
<LinearGradient
colors={[
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.0)'
]}
locations={[0, 0.3, 0.6, 1]}
style={StyleSheet.absoluteFill}
/>
<Animated.View style={{
position: 'absolute',
left: 24 + insets.left,
right: 24 + insets.right,
top: 24 + insets.top,
bottom: 110 + insets.bottom,
transform: [{ translateY: pauseOverlayTranslateY }]
}}>
{showCastDetails && selectedCastMember ? (
<Animated.View
style={{
flex: 1,
justifyContent: 'center',
opacity: castDetailsOpacity,
transform: [{ scale: castDetailsScale }]
}}
>
<View style={{ alignItems: 'flex-start', paddingBottom: screenDimensions.height * 0.1 }}>
<TouchableOpacity
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingVertical: 8, paddingHorizontal: 4 }}
onPress={() => {
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(castDetailsScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(false);
setSelectedCastMember(null);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(metadataScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={{ color: '#B8B8B8', fontSize: Math.min(14, screenDimensions.width * 0.02) }}>Back to details</Text>
</TouchableOpacity>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', width: '100%' }}>
{selectedCastMember.profile_path && (
<View style={{ marginRight: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5 }}>
<FastImage
source={{ uri: `https://image.tmdb.org/t/p/w300${selectedCastMember.profile_path}` }}
style={{ width: Math.min(120, screenDimensions.width * 0.18), height: Math.min(180, screenDimensions.width * 0.27), borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.1)' }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
)}
<View style={{ flex: 1, paddingTop: 8 }}>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(32, screenDimensions.width * 0.045), fontWeight: '800', marginBottom: 8 }} numberOfLines={2}>
{selectedCastMember.name}
</Text>
{selectedCastMember.character && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8, fontWeight: '500', fontStyle: 'italic' }} numberOfLines={2}>
as {selectedCastMember.character}
</Text>
)}
{selectedCastMember.biography && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(14, screenDimensions.width * 0.019), lineHeight: Math.min(20, screenDimensions.width * 0.026), marginTop: 16, opacity: 0.9 }} numberOfLines={4}>
{selectedCastMember.biography}
</Text>
)}
</View>
</View>
</View>
</Animated.View>
) : (
<Animated.View style={{ flex: 1, justifyContent: 'space-between', opacity: metadataOpacity, transform: [{ scale: metadataScale }] }}>
<View>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }}>You're watching</Text>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(48, screenDimensions.width * 0.06), fontWeight: '800', marginBottom: 10 }} numberOfLines={2}>
{title}
</Text>
{!!year && (
<Text style={{ color: '#CCCCCC', fontSize: Math.min(18, screenDimensions.width * 0.025), marginBottom: 8 }} numberOfLines={1}>
{`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`}
</Text>
)}
{!!episodeTitle && (
<Text style={{ color: '#FFFFFF', fontSize: Math.min(20, screenDimensions.width * 0.03), fontWeight: '600', marginBottom: 8 }} numberOfLines={2}>
{episodeTitle}
</Text>
)}
{description && (
<Text style={{ color: '#D6D6D6', fontSize: Math.min(18, screenDimensions.width * 0.025), lineHeight: Math.min(24, screenDimensions.width * 0.03) }} numberOfLines={3}>
{description}
</Text>
)}
{cast && cast.length > 0 && (
<View style={{ marginTop: 16 }}>
<Text style={{ color: '#B8B8B8', fontSize: Math.min(16, screenDimensions.width * 0.022), marginBottom: 8 }}>Cast</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{cast.slice(0, 6).map((castMember: any, index: number) => (
<TouchableOpacity
key={castMember.id || index}
style={{ backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: Math.min(12, screenDimensions.width * 0.015), paddingVertical: Math.min(6, screenDimensions.height * 0.008), marginRight: 8, marginBottom: 8 }}
onPress={() => {
setSelectedCastMember(castMember);
Animated.parallel([
Animated.timing(metadataOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(metadataScale, { toValue: 0.95, duration: 250, useNativeDriver: true })
]).start(() => {
setShowCastDetails(true);
Animated.parallel([
Animated.timing(castDetailsOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(castDetailsScale, { toValue: 1, tension: 80, friction: 8, useNativeDriver: true })
]).start();
});
}}
>
<Text style={{ color: '#FFFFFF', fontSize: Math.min(14, screenDimensions.width * 0.018) }}>
{castMember.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</View>
</Animated.View>
)}
</Animated.View>
</Animated.View>
</TouchableOpacity>
);
};

View file

@ -1,32 +0,0 @@
import React from 'react';
import { View, Text, Animated } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { styles } from '../../utils/playerStyles';
interface SpeedActivatedOverlayProps {
visible: boolean;
opacity: Animated.Value;
speed: number;
}
export const SpeedActivatedOverlay: React.FC<SpeedActivatedOverlayProps> = ({
visible,
opacity,
speed
}) => {
if (!visible) return null;
return (
<Animated.View
style={[
styles.speedActivatedOverlay,
{ opacity: opacity }
]}
>
<View style={styles.speedActivatedContainer}>
<MaterialIcons name="fast-forward" size={32} color="#FFFFFF" />
<Text style={styles.speedActivatedText}>{speed}x Speed</Text>
</View>
</Animated.View>
);
};

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