Compare commits

...

1387 commits

Author SHA1 Message Date
tapframe
58656c5c24 bump version 2026-04-16 00:58:23 +05:30
skoruppa
087b03423a
Merge pull request #1386 from skoruppa/fixes-2
Introduction of In Memory scroll setting + some bug fixes
2026-04-15 21:09:40 +02:00
skoruppa
95a8d1e98c Reverting focusManager change to address scrolling regression 2026-04-15 21:06:16 +02:00
skoruppa
5b3f2b877a Increase max memory cache to 33% 2026-04-15 20:41:12 +02:00
skoruppa
065dbdb048 Debounce for the seasons scroll 2026-04-15 20:40:45 +02:00
skoruppa
d25d5eb648 Introduction of memory only horizontal scroll option
On the grid view it is not required in my opinion
2026-04-15 20:38:35 +02:00
skoruppa
0c839f3171 Cleanup fix 2026-04-15 20:28:12 +02:00
skoruppa
dc35b955d6
Merge pull request #1385 from halibiram/fix/revert-media3-scheduling-optimizations
fix(player): remove experimental Media3 scheduling optimizations
2026-04-15 20:20:16 +02:00
HALİL İBRAHİM ÖZDEMİR
f212c0f6c9 fix(player): remove experimental Media3 scheduling optimizations
Revert the reflective dynamic scheduling and duration-to-progress flags so playback falls back to Media3's default timing behavior while investigating rebuffering on smaller sources.
2026-04-15 21:11:22 +03:00
skoruppa
b47e03ff53
Merge branch 'NuvioMedia:dev' into fixes-2 2026-04-15 18:18:03 +02:00
tapframe
029a0da4fd fix: avoid Process.isAlive for torrent process checks (fixes #1383) 2026-04-15 20:06:26 +05:30
tapframe
87caff0b0f Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-04-15 19:18:13 +05:30
tapframe
c5aaabcc49 fix: hero backdrop state to clear stale backdrop cache for classic layout 2026-04-15 19:18:06 +05:30
tapframe
6d81cd37f5 feat: collection screen to follow homescreen settings snapshot 2026-04-15 19:04:50 +05:30
tapframe
1ef970141d feat: add genre support to catalog sources for collections 2026-04-15 18:39:00 +05:30
skoruppa
66242fe549 Revert "Set proper focus even preferredTextLanguage kicks in"
This reverts commit f39432c66f.
2026-04-15 14:44:59 +02:00
skoruppa
c13166ddb9
Merge branch 'NuvioMedia:dev' into fixes-2 2026-04-15 13:36:43 +02:00
skoruppa
a119d57e87 Fix for sources navigation with duplicated names 2026-04-15 10:44:27 +02:00
skoruppa
9ea298675a
Merge pull request #1378 from skoruppa/fixes-2
Fix CW removal, watched badge ID mapping, and profile badge leak
2026-04-15 02:03:59 +02:00
skoruppa
d910ef1619 Clear Watched Badges when changing a profile 2026-04-15 01:44:18 +02:00
skoruppa
86075554aa Fixes for deleting items from CW & TMDB Cache for checkmarks 2026-04-14 23:32:10 +02:00
skoruppa
6e80908db3
Merge pull request #1375 from halibiram/feat/tmdb-deduplicate-inflight-requests
feat(tmdb): deduplicate concurrent in-flight API requests
2026-04-14 23:13:00 +02:00
skoruppa
6a708f424a
Merge pull request #1376 from SelfDL/dev
Adjustments to episode date formatting
2026-04-14 23:12:00 +02:00
SelfDL
d6e4e8c3f6
Refactor date formatting using java.time API 2026-04-14 16:12:02 -04:00
SelfDL
031a8ccd69
Update time zone to UTC in episode date parsing
Changed time zone handling from system default to UTC for parsing episode release dates.
2026-04-14 16:07:02 -04:00
SelfDL
30abde36e9
Refactor date formatting to use DateTimeFormatter 2026-04-14 14:19:01 -04:00
HALİL İBRAHİM ÖZDEMİR
2afa7f1155 feat(tmdb): deduplicate concurrent in-flight API requests
Add CompletableDeferred-based request coalescing to TmdbService and TmdbMetadataService. When multiple coroutines request the same TMDB lookup simultaneously, only the first one performs the actual network call while subsequent callers await its result.

- Add enrichmentInFlight/episodeInFlight maps to TmdbMetadataService

- Add imdbToTmdbInFlight/tmdbToImdbInFlight maps to TmdbService

- Handle CancellationException separately to propagate cancellation

- Clean up in-flight entries in finally blocks

- Add unit test verifying deduplication behavior
2026-04-14 19:09:49 +03:00
tapframe
2d287989de bump version 2026-04-14 19:43:13 +05:30
skoruppa
dba81b8836
Merge pull request #1374 from skoruppa/fixes-2
Fix subtitle selection tracking and stream panel focus
2026-04-14 15:48:29 +02:00
skoruppa
8b230d2e4a Add focusRestorer to StreamComponents 2026-04-14 15:43:25 +02:00
skoruppa
f39432c66f Set proper focus even preferredTextLanguage kicks in 2026-04-14 15:43:02 +02:00
skoruppa
fb5b0da6b8 fix: use PlayerClockOverlayHost for playback timeline state in PlayerScreen
PlayerClockOverlay was incorrectly accessing currentPosition/duration from
PlayerUiState instead of PlaybackTimelineState. Use the existing
PlayerClockOverlayHost wrapper that properly collects playbackTimeline.
2026-04-14 13:05:17 +02:00
skoruppa
902333860b Fix unescaped apostrophes in French translation strings 2026-04-14 12:54:22 +02:00
skoruppa
8c87d144e4
Merge pull request #1335 from halibiram/fix/player-surface-churn
fix(player): reduce surface churn during Compose recomposition
2026-04-14 12:39:10 +02:00
skoruppa
aee2728a2c
Merge pull request #1371 from halibiram/fix/plugin-card-focus-feedback
fix: improve plugin card focus feedback
2026-04-14 12:23:12 +02:00
skoruppa
5af80f44c2
Merge pull request #1359 from harrydbarnes/fix-settings-subtitles-punctuation
Fix settings subtitles punctuation
2026-04-14 12:21:27 +02:00
skoruppa
15b24830b8
Merge pull request #1362 from harrydbarnes/update-integrations-subtitle
Update integrations subtitle
2026-04-14 12:20:50 +02:00
skoruppa
fca552ceab
Merge pull request #1358 from danilopagotto82/Pt-br-update-v.0.6.4
Pt-br translate to P2P Setting
2026-04-14 12:10:40 +02:00
skoruppa
c713c7fc55
Merge pull request #1357 from M7-SiN/dev
Update Arabic localization
2026-04-14 12:10:19 +02:00
skoruppa
0b7d723d60
Merge pull request #1343 from KevinRouchut/dev
feat: add missing French translations
2026-04-14 12:10:05 +02:00
skoruppa
4a35b39c46
Merge pull request #1372 from skoruppa/fixes-2
Home performance, CW/Trakt stability, debrid stream fixes
2026-04-14 11:43:27 +02:00
skoruppa
be3e52538a leave timeout at 5s as it was before 2026-04-14 11:40:54 +02:00
skoruppa
812ea8609e Persist Torrent Stream when starting next episode 2026-04-14 11:27:42 +02:00
skoruppa
9d1c5858e7 Handle addons that can return both infoHash and url 2026-04-14 11:17:21 +02:00
skoruppa
73260730e5 Respect "show unaired" setting for CW 2026-04-14 11:07:55 +02:00
skoruppa
dc85ba930b CW Trakt improvements
- Stabilize whole CW row after first load
- Wait for CW to emit something
- Don't cache failed requests
- Check hydrateMetadata before requesting it again
- Trakt Episode Mapper In-flight dedup
- Better clear Cache on Trakt Login and reload CW data form disk on profile change
2026-04-14 04:09:23 +02:00
skoruppa
f0e56e78d6 Home UI optimization
- reduce amount of CW partial publish items
- add debounce to home vie catalog pipeline
- move rowBuildCache
- run independent syncs in parallel
- wait max up to 3s for catalogs to load
2026-04-14 00:35:18 +02:00
skoruppa
f941017719
Merge pull request #1336 from halibiram/fix/home-catalog-concurrency
fix: avoid concurrent home catalog mutations during enrichment
2026-04-13 23:30:12 +02:00
skoruppa
a295197b32
Merge pull request #1339 from SelfDL/dev
Remove reversed predictive search result
2026-04-13 23:26:51 +02:00
HALİL İBRAHİM ÖZDEMİR
09ca448a29 Merge remote-tracking branch 'origin/dev' into fix/player-surface-churn
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt
2026-04-14 00:12:12 +03:00
HALİL İBRAHİM ÖZDEMİR
ec69d9ee93 refactor(player): extract MpvPlayerSurface and ExoPlayerSurface composables
Extract inline AndroidView blocks into dedicated composable functions to eliminate unnecessary surface churn during Compose recomposition.

- Replace update{} side-effects with LaunchedEffect keyed on specific state parameters (keepScreenOn, aspectMode, subtitleStyle)

- Move attachMpvView lifecycle into DisposableEffect with proper keys

- Set ExoPlayer resizeMode once in factory instead of every update

- Preserve video size change and first frame listeners in ExoPlayerSurface
2026-04-14 00:01:59 +03:00
HALİL İBRAHİM ÖZDEMİR
bf910bdcc0 improve player responsiveness by isolating timeline updates 2026-04-13 23:29:27 +03:00
Harry Barnes
57b912201c
Merge branch 'NuvioMedia:dev' into update-integrations-subtitle-5729372812501752701 2026-04-13 17:54:18 +01:00
harrydbarnes
84b39bb171 Update integrations page subtitle 2026-04-13 16:53:31 +00:00
Harry Barnes
63fe742615
Merge branch 'NuvioMedia:dev' into fix-settings-subtitles-punctuation-15717156003115320466 2026-04-13 17:50:00 +01:00
harrydbarnes
f49c586b50 fix: Remove trailing full stops from settings subtitles
Removed trailing full stops from the subtitles of Plugins, Account, and Profiles settings pages to maintain consistency. This change affects both the English default and all translated string resource files.
2026-04-13 16:49:22 +00:00
CrissZollo
202f2f7426
Merge branch 'autoPlayFix' into dev 2026-04-13 18:03:58 +02:00
DaN
cb68bacb98
Update strings.xml
Stream update to fonte(s)
2026-04-13 10:25:23 -03:00
DaN
af0e5ee09e
Update strings.xml
News Strings.xml (P2P)
2026-04-13 10:20:29 -03:00
M7sin
43cfcc9eca
Update Arabic localization 2026-04-13 14:54:08 +03:00
tapframe
d5e13c6ad8 ref: remove automatic home observer push on startup 2026-04-13 12:45:00 +05:30
HALİL İBRAHİM ÖZDEMİR
e96ef17ef8 improve exoplayer scheduling for playback stability 2026-04-13 02:11:50 +03:00
HALİL İBRAHİM ÖZDEMİR
647fd0ddaf fix: improve plugin card focus feedback 2026-04-13 00:15:21 +03:00
CrissZollo
f865d7417c
Fixes auto play stream from http sources 2026-04-12 19:58:47 +02:00
SelfDL
75961773f8
Update keyboard options to disable auto-correct
Disable auto-correct for the keyboard options in SearchScreen.
2026-04-12 13:16:08 -04:00
Kevin Rouchut
b09dfacdf3 fix: correct French string formatting for consistency 2026-04-12 14:24:48 +02:00
Kevin Rouchut
cdafbb99ec fix: correct ellipsis formatting in French strings 2026-04-12 14:24:26 +02:00
Kevin Rouchut
356be64c56 feat: add French translations 2026-04-12 14:23:52 +02:00
tapframe
372ad0a77d bump version 2026-04-12 12:44:29 +05:30
tapframe
4e744549de Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-04-12 12:43:58 +05:30
tapframe
8e055575f0 fix: endsAt to calculate time based on playback speed 2026-04-12 12:43:50 +05:30
tapframe
655a62f5ee fix: prioritise addon results over plugins 2026-04-12 12:40:22 +05:30
SelfDL
4520928108
Remove reversed predictive search result 2026-04-11 21:49:44 -04:00
HALİL İBRAHİM ÖZDEMİR
91c9225471 Merge remote-tracking branch 'origin/dev' into fix/player-surface-churn 2026-04-12 03:22:44 +03:00
skoruppa
f6b7a8e1c4 build cleanup: fix duplicate string, orphaned log code, and missing import 2026-04-12 01:13:36 +02:00
skoruppa
5bd60ea2b6
Merge pull request #1322 from omavel/patch-3
Latin American Spanish Localization
2026-04-12 01:04:01 +02:00
skoruppa
d34f658b82
Merge pull request #1334 from skoruppa/fixes-2
Fix CW stability, proxy headers, and stream source OSD
2026-04-12 01:03:16 +02:00
skoruppa
e48ab58a0c Fix Stream Source Indicator not hiding after n-th episode 2026-04-12 00:53:29 +02:00
skoruppa
0bb342902c Fixes for CW - infinity loop with TMDB Enrichment, In Progress items overwritten by newer watched, Flicker of Items caused by enrichment 2026-04-12 00:48:17 +02:00
HALİL İBRAHİM ÖZDEMİR
dab780b129 fix: stabilize PlayerView updates in Compose 2026-04-12 01:46:19 +03:00
HALİL İBRAHİM ÖZDEMİR
2eb1f83c92 fix: avoid concurrent home catalog mutations during enrichment 2026-04-12 00:40:18 +03:00
skoruppa
e0d48abb8c Keep cached next-up items until they are processed by fresh pipeline 2026-04-11 23:29:42 +02:00
skoruppa
6773c001d1 Fix cookie proxyHeader 2026-04-11 21:27:59 +02:00
skoruppa
951801ef7f
Merge branch 'NuvioMedia:dev' into fixes-2 2026-04-11 21:24:05 +02:00
skoruppa
8b9ef08f5c
Merge pull request #1311 from klemenko1/dev
Update strings.xml with latest Slovenian localization 0.6.0
2026-04-11 12:51:11 +02:00
klemenko1
4ccfd2aa85
Clean up strings.xml by removing comments and whitespace
Removed unnecessary comments and whitespace in strings.xml.
2026-04-11 12:35:17 +02:00
tapframe
ab67a63c0f Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-04-11 15:30:47 +05:30
tapframe
566ee8e0f0 fix: loading state to also consider collections 2026-04-11 15:30:16 +05:30
CrissZollo
48c523fbb2
Kill torrserver if it runns elsewhere (mostly for using release and debug version) 2026-04-11 10:44:22 +02:00
CrissZollo
e7bde24667
Merge branch 'p2pBinge' into dev 2026-04-11 10:26:46 +02:00
CrissZollo
b5ce191c71
Fix binge-group matching and stream metadata for torrent episode switching 2026-04-11 10:26:00 +02:00
tapframe
a50c6c7c1e feat: collection web server support for profiles which uses primary profile's addons 2026-04-11 13:48:39 +05:30
CrissZollo
eacc3a0ca2
Fixes autoplay with using p2p 2026-04-11 09:59:07 +02:00
tapframe
2a2aa47730 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-04-11 13:26:36 +05:30
tapframe
abe4d5ba40 fix: catalog disabled state fix by making legacy keys compatible 2026-04-11 13:26:27 +05:30
CrissZollo
90db172fed
Add ProGuard keep rules for CloudStream extension runtime dependencies 2026-04-11 09:29:16 +02:00
Omavel
ef94b56f3c
Latin American Spanish Localization 2026-04-10 19:58:04 -04:00
skoruppa
2785f70857 Clear caches when switching profiles 2026-04-11 01:16:18 +02:00
skoruppa
caf7f071f9
Merge pull request #1321 from skoruppa/fixes-2
Bunch of small fixes (#1303, #1284)
2026-04-11 00:56:44 +02:00
skoruppa
1d288da553 Fix for #1284 - supressFocus when scrolling seasons 2026-04-11 00:48:53 +02:00
skoruppa
2cb34320ed Add missing Polish Translations 2026-04-11 00:31:13 +02:00
skoruppa
2db3805e77
Merge pull request #1302 from silentbil/rtl_player_direction
Revert RTL player controls to LTR
2026-04-11 00:13:10 +02:00
skoruppa
f1ebb1f3c6
Merge pull request #1310 from halibiram/fix/update-dialog-width-consistency
Fix/update dialog width consistency
2026-04-11 00:11:38 +02:00
skoruppa
8142fcf9bb Wrapped CloudStream NiceHTTP in try catch to prevent crashes on old android devices
This will fix crashing on Fire TV Gen 1 but will result in CloudStream plugins not working at all on them - maybe we need some warning in app UI
2026-04-10 23:48:53 +02:00
skoruppa
6aed24b298 Scroll always to Play button in details - even for movies 2026-04-10 23:24:45 +02:00
skoruppa
91a6f998ce Fix for series with not aired new seasons not marked as watched 2026-04-10 23:12:16 +02:00
skoruppa
fdf66a64a9 Preserve bingeGroup for cached streams 2026-04-10 20:39:15 +02:00
klemenko1
8bdc87f98e
Add files via upload 2026-04-10 17:42:07 +02:00
HALİL İBRAHİM ÖZDEMİR
c70e803136 ui: move version tag to top right and add v prefix in update dialog 2026-04-10 17:47:51 +03:00
HALİL İBRAHİM ÖZDEMİR
93112a0c9a fix(updater): auto-resume installation after unknown sources permission is granted 2026-04-10 16:06:14 +03:00
HALİL İBRAHİM ÖZDEMİR
ba0fd498c3 fix(ui): standardize UpdatePromptDialog layout width to enforce consistency across screens 2026-04-10 16:00:17 +03:00
silentbil
e3f4b70a68 revert rtl player 2026-04-10 12:56:50 +03:00
tapframe
1d3c2c2cf1 feat: syncbyline to use shared overlay for consistency 2026-04-10 11:43:00 +05:30
tapframe
49beada6fa bump version 2026-04-10 11:25:34 +05:30
skoruppa
d1ded207d0
Merge pull request #1301 from skoruppa/fixes-2
Support for addons with query parameters
2026-04-10 07:50:39 +02:00
skoruppa
f3582e5183 Support for addons with query parameters 2026-04-10 07:48:10 +02:00
Nayif
f5911781d1
Merge pull request #1219 from NuvioMedia/p2pSupport
P2p support
2026-04-10 11:14:38 +05:30
skoruppa
95fa699113
Merge pull request #1291 from WeshBg/dev
Fix player: Up on seek bar returns to fullscreen
2026-04-10 01:53:09 +02:00
WeshBg
8e0faba017 Skip Intro button UP navigation to close controls
Added UP key handling to SkipIntroButton so pressing UP from the button closes the navigation UI instead of getting stuck or looping back to the seek bar.
2026-04-09 19:44:05 -04:00
skoruppa
da7298f9ae
Merge pull request #1300 from skoruppa/fixes-2
Small cleanup for collections
2026-04-10 01:28:54 +02:00
skoruppa
1efdc6a84f Small cleanup for collections 2026-04-10 01:22:34 +02:00
skoruppa
741356aa9e
Merge pull request #1299 from skoruppa/fixes-2
Fix TMDB runtime enrichment and duplicate stream key crash
2026-04-10 00:48:48 +02:00
skoruppa
cd5555a744
Merge pull request #1298 from YaarPatandarAA/fix/collection-enhancements-2026-04-09
[FIX] Fix collection folder pagination — load all catalog items across view modes
2026-04-10 00:42:22 +02:00
Amarjit Singh
cb90a9eda0 Add pagination support to collection folder detail views
Collection folders previously only loaded the first page (~25 items)
of each catalog with no way to load more. This adds full pagination
across all view modes (TABBED_GRID, ROWS, FOLLOW_LAYOUT) by passing
supportsSkip/skipStep to getCatalog(), implementing loadMoreItems()
with deduplication, and wiring up scroll-to-load-more and inline
Load More buttons for each view mode.

Also fixes unescaped apostrophe and bare % in pt-BR strings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:37:30 -07:00
skoruppa
2e7bc818fc Enrich runtime on home as well 2026-04-10 00:24:59 +02:00
skoruppa
c8800f6252 Fix crash where 2 streams where identical 2026-04-10 00:24:02 +02:00
skoruppa
a43d0fc445
Merge pull request #1294 from zamalatb/feat-russian-translation
Add Russian translation
2026-04-09 23:02:00 +02:00
skoruppa
a48cae368f
Merge pull request #1297 from skoruppa/fixes-2
Fixes 2
2026-04-09 23:00:46 +02:00
skoruppa
7677e70b07 Fix for series checkmarks in collections 2026-04-09 22:48:36 +02:00
skoruppa
b9bcd6a2b5 Fix missing escape in PT-BR translation and better watched checkmarks in collections 2026-04-09 21:46:22 +02:00
CrissZollo
aee6c553a8
Adds setting to hide stats when loading (on by default) 2026-04-09 20:53:30 +02:00
CrissZollo
d77c10d8db
Snap loading-logo fill to 100% when stream is ready so it completes before overlay fade-out 2026-04-09 20:39:43 +02:00
CrissZollo
25390d3364
Replaces loading bar with the logo as loading bar 2026-04-09 20:30:41 +02:00
CrissZollo
4b0c5bdac6
Add compatabilty with 'reuse last link' 2026-04-09 20:12:56 +02:00
CrissZollo
dd3c6610da
Merge branch 'dev' of github.com:NuvioMedia/NuvioTV into p2pSupport 2026-04-09 19:57:10 +02:00
CrissZollo
d9f8ce94a0
Fix: Escaped both apostrophes → \'Preferir addon de meta externo\'. 2026-04-09 19:56:36 +02:00
Christoffer
2a66bdb4c7
Merge branch 'dev' into p2pSupport 2026-04-09 16:49:26 +02:00
tapframe
05a5e1b538 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-04-09 19:29:50 +05:30
tapframe
406365d526 feat: seperate trakt accounts for each profile 2026-04-09 19:28:10 +05:30
Igor Mirson
d6379222de Added russian translation 2026-04-09 15:09:40 +03:00
skoruppa
e1f2698a57
Merge pull request #1253 from danilopagotto82/Pt-br-update-v0,6,3
Update Pt-Br subtitle, tmdb, etc.
2026-04-09 13:46:27 +02:00
DaN
11c858dc17
add Collection
Add Collection
2026-04-09 08:38:42 -03:00
skoruppa
a99570bfaf
Merge pull request #1293 from skoruppa/fixes-2
Store currentVideoId - Fixes #773
2026-04-09 11:30:21 +02:00
skoruppa
48bce1af44 Store currentVideoId - Fixes #773 2026-04-09 09:57:29 +02:00
tapframe
fedd73c2a4 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-04-09 12:31:53 +05:30
tapframe
526bc6aab9 Add platform parameter to settings sync requests in Home and Profile services on recent migration 2026-04-09 12:31:33 +05:30
WeshBg
7ba2ad79ee Fix DPAD_UP from seek bar
When progress bar is focused, pressing UP now hides controls and fullscreen
2026-04-08 22:32:38 -04:00
skoruppa
664c98f84c
Merge pull request #1290 from skoruppa/fixes-2
Collection translations, TMDB enrichment for pause overlay & collections, CW fixes
2026-04-09 01:55:59 +02:00
skoruppa
612e2f1c8d Respect TMDB settings 2026-04-09 01:43:09 +02:00
skoruppa
b8e1561753 Erich Cast in Pause Overlay 2026-04-09 01:39:59 +02:00
skoruppa
78a3520e8f Trakt improvements - don't overwrite lastWatched for progress and query in parallel 2026-04-09 01:31:45 +02:00
skoruppa
0cddbaf4bc Add translations for collections 2026-04-09 01:15:40 +02:00
skoruppa
08c6c87e43 Add enrichment on focus to collections items 2026-04-09 00:35:50 +02:00
skoruppa
1cb222117a Fix for collection catalog names 2026-04-08 23:43:45 +02:00
skoruppa
d304869e06
Merge branch 'NuvioMedia:dev' into fixes-2 2026-04-08 22:59:26 +02:00
skoruppa
27a7843eed
Merge pull request #1289 from YaarPatandarAA/fix/collection-bugs
[Fix] Fix collection bugs: loading, focus, hero independence, watched badges, and WebUI
2026-04-08 22:58:54 +02:00
skoruppa
8d543b3dc3 TMDB Enrich Pause Overlay 2026-04-08 22:50:10 +02:00
Amarjit Singh
80d38244e6 Fix Modern layout focus race when catalogs load after collections
Show spinner when collections are ready but catalogs haven't arrived
yet. Prevents collections from grabbing focus before catalogs appear
above them. Also tracks initial auto-selected row to properly detect
when new rows appear before the focused row during loading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:38:20 -07:00
Amarjit Singh
247aa8e518 Add watched badges to collection folder detail views
- Inject WatchProgressRepository and WatchedSeriesStateHolder into
  FolderDetailViewModel to observe movie and series watched status
- Add movieWatchedStatus to FolderDetailUiState
- Pass isItemWatched callback to all three view modes:
  Tabbed Grid (ContentCard), Rows (CatalogRowSection), and
  Follow Layout (Classic/Grid/Modern home content renderers)
- Rebuild Follow Layout state when watched status changes so badges
  update reactively

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:06:18 -07:00
Amarjit Singh
76a28c8fae Fix stale trailer playing in hero when focusing collection folders
Clear focusedCatalogSelection when a collection folder gets focus,
not just continue watching rows. Previously the old catalog item's
trailer request stayed active and played in the hero area after
navigating to a collection folder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:33:31 -07:00
Amarjit Singh
09c5480593 Collection UI fixes: focus tracking, WebUI improvements, hideTitle in Modern
- Fix collection row focus restoration using key-based matching instead
  of positional index (survives row shifts during catalog loading)
- Add per-item FocusRequesters to CollectionRowSection for proper focus
  restore when navigating back from folder detail
- Remove Copy JSON and Paste import from Android TV (keep in WebUI)
- Default import mode to File instead of Paste
- WebUI: add Collection badge pill in Home Layout tab, send to top/bottom
  buttons, tab renamed to Home Layout, 2x2 grid for order buttons
- Update subtitle to "Manage addons, catalogs, and collections"
- Collections tab: rename Enable/Disable to Show/Hide All
- Folder detail header icon respects tileShape aspect ratio
- Respect hideTitle in Modern layout hero and card labels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:19:11 -07:00
Amarjit Singh
47aba50061 Merge remote-tracking branch 'upstream/dev' into fix/collection-bugs
# Conflicts:
#	app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt
2026-04-08 08:03:56 -07:00
tapframe
410617c0a6 ref: cleanup redundant webserver,and added tabbed sections 2026-04-08 20:21:10 +05:30
Amarjit Singh
b4c0dc3e65 Merge remote-tracking branch 'upstream/dev' into fix/collection-bugs 2026-04-08 07:35:33 -07:00
Amarjit Singh
d6e4095741 Fix Classic hero focus lost on app reopen via app switcher
The default layout (MODERN) composes first on fresh start, then
preferences load and switch to CLASSIC.  Modern's onDispose was
saving focus state (hasSavedFocus=true) which poisoned Classic's
initial focus logic, preventing the hero from receiving focus.

- Add suppressFocusSave flag to block outgoing layout's dispose save
- Defer Classic content focus with spinner until hero resolves
- Use heroSectionEnabled (default true) for deferral since
  heroCatalogKeys arrives late via 300ms preference debounce
- 2s safety timeout prevents infinite spinner if hero has no data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 07:30:18 -07:00
tapframe
5664446b23 Implement home catalog settings sync with cloud 2026-04-08 19:35:38 +05:30
DaN
ad725300bb
Update strings.xml
New Stimgs.
2026-04-08 10:27:40 -03:00
skoruppa
4103a450d4 If same timestamp, order latestEpisodeByContent by season/episode & change next up to use watched items instead of watch progress 2026-04-08 14:18:17 +02:00
skoruppa
8874ebc07d Fix Modern Home TMDB enrichment after Collections introduction 2026-04-08 12:41:08 +02:00
skoruppa
2a7d836eea Add missing class stubs after the Cloudstream merge 2026-04-08 12:40:44 +02:00
skoruppa
07f113b0f6 Don't TMDB enrich dates when it is turned off 2026-04-08 12:30:15 +02:00
skoruppa
dc477f567d
Merge pull request #1273 from skoruppa/fixes-2
Bunch of small fixes
2026-04-07 23:42:52 +02:00
skoruppa
965f0dabe2 Small changes in polish translation 2026-04-07 23:21:29 +02:00
skoruppa
a92aeafaa1 Exclude cached older items for series that the fresh pipeline evaluated but didn't produce a next-up for (e.g. fully watched series). 2026-04-07 23:21:09 +02:00
skoruppa
bd474f11b8 Add crossfade to episode thumbnails in details screen 2026-04-07 23:20:18 +02:00
skoruppa
a855133d0c
Merge pull request #251 from kernexshadow/syncSub
Add "Sync by Line" subtitle flow from the Delay overlay
2026-04-07 21:45:01 +02:00
CrissZollo
a94003dc02
Remove files 2026-04-07 20:47:36 +02:00
CrissZollo
1ef67745d5
Remove 'reuse last link' for p2p links 2026-04-07 20:40:45 +02:00
CrissZollo
218cb433e7
Merge dev with p2pSupport + merge conflicts fixes 2026-04-07 20:06:20 +02:00
CrissZollo
26eb0bab15
Fix stream results not appearing due to broken debounce in collector 2026-04-07 19:55:35 +02:00
CrissZollo
bd79d8c7a6
Fix stream results not appearing due to broken debounce in collector 2026-04-07 19:54:53 +02:00
Christoffer
0e439c3d57
Merge pull request #1175 from NuvioMedia/cloudstreamRepoSupport
Cloudstream repo support !Streams only!
2026-04-07 19:51:06 +02:00
CrissZollo
f609199573
Improve player: readable audio labels, user-friendly playback error messages 2026-04-07 19:39:19 +02:00
kernexshadow
8589018c10 tune(player): refine sync-by-line focus style and hide controls 2026-04-07 18:15:26 +01:00
kernexshadow
091b7ef469 Merge branch 'dev' of https://github.com/NuvioMedia/NuvioTV into syncSub
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerTrackSelection.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt
2026-04-07 17:37:51 +01:00
CrissZollo
6491419e51
Merges latest origin dev with cloudStreamSupport 2026-04-07 18:07:03 +02:00
skoruppa
de87857485
Merge pull request #1246 from halibiram/exoplayer-optimization
Improve ExoPlayer playback networking and recovery
2026-04-07 14:04:14 +02:00
HALİL İBRAHİM ÖZDEMİR
c671afaf2c Merge branch 'dev' into exoplayer-optimization 2026-04-07 14:11:46 +03:00
HALİL İBRAHİM ÖZDEMİR
b97c3ac861 Simplify stream handling and playback networking
Revert the multi-source stream plumbing so playback stays aligned with the single-URL dev behavior and review feedback. Keep PlayerPlaybackNetworking as a stable OkHttp-backed helper for header-aware player data sources without the Cronet path.
2026-04-07 14:05:58 +03:00
tapframe
68c8966bbd Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-04-07 16:28:50 +05:30
tapframe
7110e579c3 feat: support for custom addon names 2026-04-07 16:24:17 +05:30
tapframe
c66acf63c3 feat: add focus GIF toggle functionality for collection folders 2026-04-07 15:45:05 +05:30
Amarjit Singh
ab3f138372 Fix collections-only and hero-only home screen loading, focus, and WebUI
- Fix infinite spinner when only collections enabled (no catalogs)
- Load hero-selected catalogs independently of home row settings
- Fix hero startup race condition (300ms preference debounce)
- Fix Classic layout hero focus stolen by collections
- Fix Modern layout focus landing on collections instead of first catalog
- Modern layout HeroCarousel fallback when no carousel rows
- Add Enable All / Disable All buttons for catalogs and collections in WebUI
- Clear stale error state when content arrives after initial load

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:43:10 -07:00
skoruppa
430c32e807
Merge pull request #1260 from skoruppa/fixes-2
Original Audio Track + bunch of fixes
2026-04-07 09:45:22 +02:00
skoruppa
86fadc1174 Add missing polish translations 2026-04-07 09:42:10 +02:00
skoruppa
16d463f07b missing comma 2026-04-07 09:16:17 +02:00
skoruppa
ba6fb93123 Fix for not saving streamCacheKey after changing to a next episode from the player 2026-04-07 08:41:11 +02:00
skoruppa
0a900ce15a Deduplicate items in CW 2026-04-07 08:34:54 +02:00
skoruppa
ee6325f407 Support for "Original Audio Track" setting 2026-04-07 08:33:25 +02:00
skoruppa
b4e3a22c1a Parse username:password as auth header 2026-04-07 07:07:57 +02:00
skoruppa
7670465bf6 Focus on play button when going up from episodes section 2026-04-07 06:58:59 +02:00
skoruppa
1129daded7 Clear CW state when changing profile 2026-04-07 06:58:26 +02:00
tapframe
9f24002e78 feat:added gif support per folder 2026-04-07 10:10:29 +05:30
tapframe
2b1383c7da Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-04-07 09:55:35 +05:30
tapframe
a57161f491 feat: adding glow toggle for collection cards 2026-04-07 09:55:30 +05:30
skoruppa
5b7e6b4a56
Merge pull request #1190 from kernexshadow/videasy-default-disabled
fix crashes: Disable VideoEasy scrapers by default and require opt-in confirmation
2026-04-06 21:10:14 +02:00
skoruppa
7f3557b7de
Merge pull request #1248 from halibiram/fix/tr-missing-translations
Add missing Turkish translations for new UI strings
2026-04-06 19:58:18 +02:00
CrissZollo
1245851704
Merge branch 'dev' of github.com:NuvioMedia/NuvioTV into p2pSupport 2026-04-06 18:26:13 +02:00
CrissZollo
65e7abe251
Thread addon tracker URLs from sources field through to magnet URI construction 2026-04-06 18:24:26 +02:00
CrissZollo
1eb82495c9
Fix wrong episode playback by adding filename matching and metadata polling to file resolution 2026-04-06 18:22:29 +02:00
CrissZollo
21a4e2ce1c
Harden TorrServer startup with save_to_db, setExecutable, and faster health checks 2026-04-06 18:21:31 +02:00
tapframe
fb650c871b Merge branch 'feature/collections' into dev - feat: Custom collections and folders from installed catalogs 2026-04-06 19:32:50 +05:30
tapframe
49a49f2150 added pin to top for custom collections 2026-04-06 19:27:37 +05:30
tapframe
d8a52f6421 feat: update contentScale to FillBounds for improved image display in collection screens 2026-04-06 19:10:05 +05:30
tapframe
9e20e7c9db feat: refactor ModernHomeContent to reuse same catalogsection for custom collections 2026-04-06 18:59:16 +05:30
DaN
4ff320b5e8
Update strings.xml
Update Stimgs pt-br
2026-04-06 10:19:31 -03:00
tapframe
0ebd0b4b80 feat: adjust focus state management for grid and row layouts in collections 2026-04-06 18:46:32 +05:30
tapframe
2c45df6e1b feat: suuporting cloud sync for custom collections 2026-04-06 18:07:33 +05:30
tapframe
2947e239ea feat: add confirmation dialog for deleting collections and folders 2026-04-06 17:35:54 +05:30
tapframe
fb0b662d2c feat: adding webserver to manage custom collections 2026-04-06 17:32:34 +05:30
tapframe
5f493be9e6 bump version 2026-04-06 11:05:59 +05:30
HALİL İBRAHİM ÖZDEMİR
834729f6c1 Restore silent first auto-retry behavior
Keep the initial lightweight retry from showing the loading overlay again while preserving the configured overlay behavior for full teardown recovery.
2026-04-06 04:20:53 +03:00
skoruppa
835b671508
Merge pull request #1249 from skoruppa/fixes-2
stale in-progress items persisting in Continue Watching
2026-04-06 02:37:30 +02:00
skoruppa
cea9288022 Fix inprogress stale for trakt 2026-04-06 02:31:02 +02:00
HALİL İBRAHİM ÖZDEMİR
49e8630c7f Add missing Turkish translations for new UI strings 2026-04-06 03:19:03 +03:00
skoruppa
93e4d4fab9 Fix for stale progress in CW 2026-04-06 01:32:36 +02:00
HALİL İBRAHİM ÖZDEMİR
cb5ec2a00e Refine player playback networking 2026-04-06 02:14:14 +03:00
CrissZollo
cb0005c2e2
buffer progress display 2026-04-05 11:47:37 +02:00
HALİL İBRAHİM ÖZDEMİR
4441e9c769 Keep the recovery overlay active during player retries
Reuse a single recovery overlay path for retry and engine failover flows so playback recovery stays visible instead of briefly dropping back to stale player state.
2026-04-05 12:22:00 +03:00
CrissZollo
fa59f64792
Added more compression to decrease the size of apk files 2026-04-05 11:16:43 +02:00
CrissZollo
3ab8af4b7a
Replaced old version with new TorrServer 2026-04-05 10:58:06 +02:00
HALİL İBRAHİM ÖZDEMİR
753d8e913c Improve ExoPlayer recovery and playback behavior 2026-04-05 11:35:14 +03:00
skoruppa
9151b6f156
Merge pull request #1240 from kernexshadow/mpvHwecSettings
Mpv hwec settings
2026-04-04 23:52:21 +02:00
skoruppa
f521082299
Merge pull request #1239 from skoruppa/fixes-2
Batch of fixes - CW Nuvio Sync, Random pauses & "See all" button in search results
2026-04-04 23:21:34 +02:00
skoruppa
57003a6fc4 One small fix for search 2026-04-04 23:11:28 +02:00
kernexshadow
a28f340543 Add MPV hardware decode mode setting options 2026-04-04 22:10:47 +01:00
CrissZollo
fda45a24a3
Fix torrent download window not advancing during playback by updating piece priorities as position progresses 2026-04-04 23:04:34 +02:00
skoruppa
e044b47447 Add missing Polish translations 2026-04-04 22:59:15 +02:00
skoruppa
b265cb709f Fix for "See All" in search results 2026-04-04 22:58:47 +02:00
CrissZollo
a2db145464
Merge branch 'p2pSupport' of github.com:NuvioMedia/NuvioTV into p2pSupport 2026-04-04 22:30:21 +02:00
CrissZollo
dd69b45614
Add P2P consent dialog with legal disclaimer, show MB progress during buffering, fix piece priority and buffer controls to prevent full-file downloads 2026-04-04 22:29:53 +02:00
skoruppa
ad375c5555 Let Async Inject add Next Up for everything with Nuvio Sync. Fixes issues with big watched libraries 2026-04-04 20:43:00 +02:00
skoruppa
f39849a2f8 Fixed bug with random pauses on some devices that emits config changes during playback 2026-04-04 20:37:26 +02:00
kernexshadow
f7a5575d0e Add MPV OSD engine switch with track preference handoff and recursion guard 2026-04-04 17:10:47 +01:00
skoruppa
6fcca9093e
Merge pull request #1228 from harrydbarnes/perf-compose-lazy-list
Perf compose lazy list content types
2026-04-04 16:28:33 +02:00
skoruppa
6b6429f4c9
Merge pull request #1221 from skoruppa/fixes-2
CW pipeline overhaul: caching, batch ops, dropped show filtering, flicker fixes
2026-04-04 13:55:11 +02:00
Christoffer
56b40e0129
Merge branch 'dev' into p2pSupport 2026-04-04 13:51:26 +02:00
skoruppa
edcd9242e5
Merge pull request #1212 from kernexshadow/mpv
Mpv internal player
2026-04-04 13:46:59 +02:00
Harry Barnes
9ec3213d42
Perf compose lazy list content types
Added dynamic `contentType` mapping to the `itemsIndexed` calls in `ClassicHomeContent`, `ModernHomeRows`, and `ModernHomeContent`. It differentiates horizontal lists and individual content cards by `apiType` or `itemType` instead of using a generic static string.
2026-04-04 11:06:26 +01:00
CrissZollo
547154c1a7
Improve stream source page: sort by quality, fix display redundancy, debounce UI updates, collapsible diagnostics 2026-04-04 11:59:08 +02:00
skoruppa
a46c71d2f4 Don't change order of items after enhancement 2026-04-04 08:16:10 +02:00
skoruppa
2c6ed7f4ca Increased Next Up limit to 48 2026-04-04 01:43:06 +02:00
skoruppa
01bbe095e0 MIssing , in SearchView 2026-04-04 01:08:20 +02:00
kernexshadow
76ce82712d Adjust MPV outline width for non-ASS subtitles 2026-04-04 00:06:41 +01:00
skoruppa
32d78db60f
Merge branch 'NuvioMedia:dev' into fixes-2 2026-04-04 01:04:21 +02:00
kernexshadow
6575bac23f Stabilize Exo-to-MPV fallback startup and surface attach 2026-04-03 23:34:55 +01:00
skoruppa
b434ebdc5d correctly handle dropped shows, dismissed shows and fallback to backdrops when thumbnail fails 2026-04-04 00:27:46 +02:00
kernexshadow
87aedb85c3 feat(player): auto-switch internal engine on startup exception 2026-04-03 21:04:11 +01:00
skoruppa
2a97568d4a Load cache earlier and clear cache when turning Trakt on or off 2026-04-03 20:51:02 +02:00
kernexshadow
18a1a9250e tune(player): add 300ms sync-by-line reaction compensation 2026-04-03 19:27:37 +01:00
CrissZollo
692344a252
Potential fix for provider selection jumoing back when having more than 10+ providers 2026-04-03 19:52:18 +02:00
skoruppa
4929bd1a72
Merge pull request #1222 from halibiram/fix/youtube-visitor-cache
fix: cache YouTube visitor_data + API key to prevent 429 rate limiting
2026-04-03 19:41:43 +02:00
CrissZollo
06ca1f277c
Makes adding cloudstream repos much faster 2026-04-03 19:31:57 +02:00
CrissZollo
c610341928
Fetching and database addition for external repos (aka cloudstream) 2026-04-03 19:18:29 +02:00
kernexshadow
652bbf0025 feat(player): modernize sync by line UI flow 2026-04-03 17:22:13 +01:00
kernexshadow
b1b11b4a1a wip: 2026-04-03 16:24:30 +01:00
kernexshadow
9c3b0fca54 Merge branch 'dev' of https://github.com/NuvioMedia/NuvioTV into syncSub
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerObservers.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerTrackSelection.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/SubtitleDelayConfig.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/SubtitleDialog.kt
2026-04-03 16:23:51 +01:00
HALİL İBRAHİM ÖZDEMİR
9712f66445 fix: cache YouTube visitor_data + API key to prevent 429 rate limiting 2026-04-03 17:33:09 +03:00
skoruppa
2fa5237778 Add progress items to cache as well 2026-04-03 15:59:35 +02:00
skoruppa
ac0bb71652
Merge pull request #621 from JFeverish/searchHist
Add recent search history in Search screen
2026-04-03 14:44:26 +02:00
skoruppa
46ffca1254
Merge branch 'dev' into searchHist 2026-04-03 14:43:19 +02:00
skoruppa
50dac6e2a5 Use users/hidden/dropped 2026-04-03 14:40:25 +02:00
CrissZollo
a3ae228c54
Fix cache being cleared on exit by correcting DataStore fallback from true to false 2026-04-03 13:09:26 +02:00
skoruppa
80e23cd5bb Added CW Enrichment Cache & Batch Actions for progress tracking 2026-04-03 12:57:34 +02:00
CrissZollo
f961bf243f
Add libtorrent4j with local HTTP serving, piece prioritization, and buffered playback 2026-04-03 12:39:28 +02:00
CrissZollo
afb16eaebe
Initial version of p2p support 2026-04-03 11:25:14 +02:00
CrissZollo
f39f62692e
Fix CloudflareKiller with real WebView bypass and Conscrypt TLS, fix TmdbProvider load() flow and timeout handling 2026-04-03 09:56:13 +02:00
skoruppa
e12a608dee Made CW meta cache lighter 2026-04-03 09:49:48 +02:00
skoruppa
4a054e191d Prefer External Metadata for CW & turned on by default 2026-04-03 09:27:47 +02:00
skoruppa
c641669f72 Add only unique items to hero 2026-04-03 09:12:59 +02:00
skoruppa
49763357ec Merge branch 'fixes-2' of https://github.com/skoruppa/NuvioTV into fixes-2 2026-04-03 09:06:29 +02:00
skoruppa
38b9df324a Filter dropped series from trakt progress 2026-04-03 09:06:27 +02:00
skoruppa
1ac2ec03cf
Merge pull request #1218 from skoruppa/fixes-2
Try to match CW to the Mobile Implementation
2026-04-03 09:00:19 +02:00
skoruppa
4fb4731896 Clean skip from code 2026-04-03 00:15:44 +02:00
skoruppa
b2ee1ce4c9 Leave nextVideo as it was 2026-04-03 00:13:03 +02:00
skoruppa
e9991e077f
Merge pull request #1217 from silentbil/rtl_fixes
RTL fixes
2026-04-02 22:52:23 +02:00
natan
426e5a410a rtl fixes 2026-04-02 23:24:20 +03:00
CrissZollo
cb0d3afbf3
Fix TmdbProvider data flow and extension loading for CloudStream library compatibility 2026-04-02 22:10:37 +02:00
Christoffer
78a96ce284
Merge branch 'dev' into cloudstreamRepoSupport 2026-04-02 20:44:21 +02:00
CrissZollo
7e9c03d3f0
Integrate upstream CloudStream library v4.7.0, fix DEX permissions and extension loading compatibility 2026-04-02 20:37:01 +02:00
kernexshadow
7743da84e1 Keep entire MPV package from R8 shrinking 2026-04-02 19:31:02 +01:00
Amarjit Singh
055ff9cde5 Grey out save buttons when mandatory fields missing, Web UI polish
- Disable Save button on collection editor when title empty or no folders
- Disable Save button on folder editor when no catalog sources
- Web UI: validation badges on collections with issues, Save Changes
  disabled when any collection invalid
- Web UI: View Mode uses segmented buttons instead of dropdown
- Consistent "View Mode" label across TV, Mobile, and Web UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:16:55 -07:00
Amarjit Singh
f94e709432 Merge upstream/dev (0.5.6) into feature/collections
Resolve conflicts in AddonConfigServer (context param) and
AddonWebPage (i18n strings + tab removal → single-page layout).
Collections section adapted to upstream's new section-block structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:47:10 -07:00
kernexshadow
41b4d4423f Tune MPV subtitle font and vertical positioning 2026-04-02 17:56:50 +01:00
skoruppa
716a3fd1a0
Merge pull request #1015 from halibiram/fix/cast-screen-skeleton-animation
fix: add shimmer animation to cast screen skeleton loading
2026-04-02 18:49:44 +02:00
kernexshadow
6151d71b82 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into mpv
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt
2026-04-02 15:13:36 +01:00
kernexshadow
bc48b75488 Preserve ASS/SSA styling in MPV subtitles 2026-04-02 15:05:02 +01:00
kernexshadow
ca3f0127b2 Keep MPV JNI callback methods in R8 2026-04-02 14:35:48 +01:00
skoruppa
dea9de4405
Merge pull request #1209 from skoruppa/fixes
Items erichment improvemens
2026-04-02 15:13:05 +02:00
skoruppa
ac89e0e519
Merge pull request #1210 from halibiram/fix/player-speed-v2
fix playback speed compatibility and player exit stability
2026-04-02 14:26:47 +02:00
skoruppa
bc0f835bd3
Merge pull request #1214 from aicon/dev
Update Hungarian translation add missing strings
2026-04-02 14:20:45 +02:00
skoruppa
d202c14ba8 Try to match CW to the Mobile Implementation 2026-04-02 14:18:46 +02:00
aicon
a994f83dd3
Update Hungarian translation add missing strings 2026-04-02 14:14:13 +02:00
kernexshadow
0db1e44f6a Revert Gradle daemon tuning changes 2026-04-02 11:25:49 +01:00
kernexshadow
d4488a7b54 Merge branch 'dev' of https://github.com/NuvioMedia/NuvioTV into mvp
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt
2026-04-02 10:56:39 +01:00
kernexshadow
24fc67a3b0 Fix MPV subtitle vertical offset mapping 2026-04-02 10:42:20 +01:00
HALİL İBRAHİM ÖZDEMİR
6e32d95961 Fix playback speed change not working on DTS and other passthrough audio formats 2026-04-02 11:49:27 +03:00
skoruppa
3fff033987 Update strings.xml 2026-04-02 10:34:47 +02:00
skoruppa
2cd6507a2f
Merge branch 'NuvioMedia:dev' into fixes 2026-04-02 10:31:53 +02:00
skoruppa
baba142275
Merge pull request #564 from halibiram/feature/i18n-web-config
feature/i18n web config
2026-04-02 10:31:22 +02:00
skoruppa
8a49d820b1 Small change in translation 2026-04-02 10:26:15 +02:00
skoruppa
5efafdd98a Add rest of missing translations 2026-04-02 09:10:47 +02:00
skoruppa
2377462e72
Merge branch 'NuvioMedia:dev' into fixes 2026-04-02 09:07:44 +02:00
skoruppa
019c998447
Merge pull request #1167 from M7-SiN/patch-1
Added Arabic Language
2026-04-02 09:06:32 +02:00
skoruppa
1fc909a7cc
Merge pull request #1203 from daniele-sforza/feat/it-localization-full-parity
Updated Italian localization after hardcoded strings extraction
2026-04-02 09:03:36 +02:00
skoruppa
efe27a0bd1
Merge pull request #1205 from danilopagotto82/Pt-br-update-v0,6,2
Update pt-br strings.xml (contents and erros)
2026-04-02 09:03:03 +02:00
skoruppa
0901ef80df
Merge pull request #947 from silentbil/player_loading_status
Show player loading status
2026-04-02 08:26:14 +02:00
natan
253899da93 on by default 2026-04-02 09:00:00 +03:00
Amarjit Singh
cb19acf552 Redesign Web UI collections tab and polish folder detail
- Redesign collections tab: grouped settings, toggle switches,
  cover mode picker (None/Emoji/Image), separated Active/Add Sources
- Filter collections from catalog source picker
- Active sources scrollable with search and filter
- Sort arrows disabled during search, first/last arrows disabled
- Larger collapse arrow icons
- Update strings to mention collections
- Adjust folder detail tab spacing to 12dp

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:41:56 -07:00
Amarjit Singh
1e00669752 Render collection folders as native grid items in Grid layout
- Add CollectionHeader and CollectionFolder to GridItem sealed class
- Build grid items from homeRows for correct collection ordering
- Collections appear as section headers + folder cards in the grid
- Folder cards show cover image, emoji, or initials with title overlay
- Removes old hardcoded horizontal row insertion for collections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:41:56 -07:00
Amarjit Singh
c407eda71c Polish collection UI: tab styling, editor scroll, view mode labels, catalog names
- Fix View Mode button selected style to match Tile Shape pattern
- Show All Tab toggle now uses Card style matching Hide Title
- Emoji shows in folder detail header (Tabbed/Rows modes)
- Tabs moved to same row as folder title, title truncates with ellipsis
- All tab uses "Combined" subtitle for consistent pill sizing
- Collection editor is now a single scrollable LazyColumn
- Rename Tabbed Grid to Tabs, Follow Layout to Follow Home Layout
- Fix catalog display name showing raw ID instead of proper name
- Pass Modern landscape posters and fullscreen backdrop settings through

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:41:56 -07:00
Amarjit Singh
9029351316 Implement true Follow Layout using real home layout composables
- Follow Layout now renders actual ClassicHomeContent, GridHomeContent,
  or ModernHomeContent with folder catalog data
- Build HomeUiState from loaded CatalogRows including GridItem list
- Pass through Modern settings (landscape posters, fullscreen backdrop)
- Fix catalog display name showing raw ID instead of proper name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:41:55 -07:00
Amarjit Singh
01bcca304c Add backdrop image, view modes, and Modern hero for collections
- Add backdropImageUrl at Collection level for Modern layout hero
- Add viewMode (Tabbed Grid/Rows/Follow Layout) and showAllTab at Collection level
- Modern hero shows folder title/emoji with backdrop fallback chain
- Tabbed Grid supports "All" tab with round-robin catalog merge
- Rows mode uses CatalogRowSection for each catalog source
- Update Web UI, TV editor, and serialization for new fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:41:55 -07:00
Amarjit Singh
39a8a9cee2 Add Collections tab to Manage from Phone web UI
Extend the existing QR-code addon management feature with a Collections
tab for full CRUD of collections and folders from the phone browser.
Includes categorized emoji picker, searchable catalog source list,
collapsible sections, import/export with full validation, cover image
preview, unified catalog+collection ordering in the Catalogs tab with
correct persistence, and enable/disable toggle for collections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:41:55 -07:00
HALİL İBRAHİM ÖZDEMİR
44965795cf fix playback speed compatibility and player exit stability 2026-04-02 04:46:28 +03:00
skoruppa
85e180dc34 Erichment improvemens
A lot of people had some problems with enrichment data - either release dates, didn't want images changed on home (but with everything else enriched), or were unhappy with the imdb rating from tmdb.

With this change, Home and CW enrichment finally respects TMDB settings, it is possible to turn off only dates enrichement, and MDBList is used for getting the correct imdb rating

Fixes #1198
2026-04-02 00:52:06 +02:00
kernexshadow
a91ae52b63 Enable audio amplification for MPV 2026-04-01 21:02:12 +01:00
kernexshadow
38050a1b6a fix(player): stabilize mpv aspect, resume, and afr settle delay 2026-04-01 20:34:28 +01:00
DaN
4ea2749cf6
Update strings.xml
Errors and contents
2026-04-01 13:09:42 -03:00
Daniele Sforza
edc534a866 Updated Italian Localization 2026-04-01 17:35:57 +02:00
kernexshadow
410cda94f0 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into mvp
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt
2026-04-01 15:49:08 +01:00
M7sin
b3b372d644
Final touches 2026-04-01 15:00:58 +03:00
M7sin
953ebfb06a
Update Arabic strings 2026-04-01 13:30:16 +03:00
natan
7021a8ee25 Merge branch 'dev' into player_loading_status
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt
2026-03-31 23:15:07 +03:00
Nayif
3fdfb341d7
Merge pull request #1176 from halibiram/feat/player-aspect-mode
player aspect mode
2026-04-01 00:22:27 +05:30
Nayif
8dad475e8f
Merge pull request #1179 from halibiram/fix/detail-backdrop-scroll-bleed
Fix stale hero backdrop in MetaDetails
2026-04-01 00:22:13 +05:30
tapframe
89a769a33c bump version 2026-03-31 23:18:47 +05:30
Nayif
b32463bdf0
Merge pull request #1180 from skoruppa/fixes-2
Series Watched Badges - trakt improvements
2026-03-31 23:13:06 +05:30
Nayif
6a6e132dc3
Merge pull request #1149 from foXaCe/fix/hardcoded-strings-extraction
Extract hardcoded strings to resources for i18n
2026-03-31 23:12:46 +05:30
Nayif
9951620f1b
Merge pull request #1153 from silentbil/fix-hebrew
Fix Hebrew translations and RTL player seek direction
2026-03-31 23:12:31 +05:30
Nayif
2620ce9814
Merge pull request #1170 from skoruppa/fixes
Batch of small fixes (#1157, #1154)
2026-03-31 23:11:50 +05:30
kernexshadow
e2ff9d6ccd Disable VideoEasy scrapers by default and require opt-in confirmation 2026-03-31 15:51:29 +01:00
skoruppa
e682330058 Unify logic 2026-03-31 14:39:11 +02:00
skoruppa
9bfc70eb27 Just like Watched Series badge, check if first episode of next season already aired 2026-03-31 14:10:25 +02:00
skoruppa
1dfd3c0266 Quick fix for auto sub selection from preserved list 2026-03-31 11:28:37 +02:00
skoruppa
e4067d20bb Fix BR translation 2026-03-31 10:04:31 +02:00
skoruppa
0fa4beadb9 Series Watched Badges improvements 2026-03-31 02:19:53 +02:00
HALİL İBRAHİM ÖZDEMİR
8c31ee159d Fix stale hero backdrop in MetaDetails 2026-03-31 01:59:21 +03:00
HALİL İBRAHİM ÖZDEMİR
59dbc6a705 Warn when aspect ratio is unavailable during tunneled playback 2026-03-31 00:19:50 +03:00
HALİL İBRAHİM ÖZDEMİR
32102b30e5 Keep subtitles unaffected by aspect scaling 2026-03-31 00:14:32 +03:00
CrissZollo
aa04992fb8 Fix missing CS3 stubs for extension compatibility
Add addActorNames to LoadResponse.Companion, change
TvSeriesLoadResponse.seasonNames from val to var.
2026-03-30 23:02:33 +02:00
HALİL İBRAHİM ÖZDEMİR
b2ab419b1a Replace main aspect ratio with typed aspect modes 2026-03-30 23:59:58 +03:00
HALİL İBRAHİM ÖZDEMİR
fbfab7cc39 Fix pt-BR string resource markup 2026-03-30 23:59:40 +03:00
CrissZollo
a43a9d0ebc Universal CloudStream extension compatibility
Add missing CS3 stub methods (LoadResponse.Companion helpers, DataStore,
Episode.score, generic newMovieLoadResponse, addEpisodes, SearchResponse
properties) so extensions from any repo can execute without NoSuchMethodError.
2026-03-30 22:48:36 +02:00
CrissZollo
9e05259413
Load and search improvements 2026-03-30 21:48:02 +02:00
CrissZollo
48110872bc
Improvements to searching and implementation of search logig 2026-03-30 21:48:02 +02:00
CrissZollo
8a7e6a260c
Cloudstream extractors 2026-03-30 21:48:02 +02:00
CrissZollo
20dfa90ab2
Cloudstream 2026-03-30 21:48:02 +02:00
CrissZollo
71dffdf7ee
Disable and enable repo 2026-03-30 21:48:02 +02:00
CrissZollo
ec57a1a5a0
Added support for shortcodes 2026-03-30 21:48:02 +02:00
CrissZollo
ff0e453d9d
Fixes url support cloudstream 2026-03-30 21:48:02 +02:00
CrissZollo
38647b9e38
merging current dev branch to cloudstream branch 2026-03-30 21:48:02 +02:00
Nayif
8033a05f2f
Merge pull request #1162 from kernexshadow/SubtitlesUI
Subtitles User Experience Improvement
2026-03-30 22:21:20 +05:30
Nayif
fde06906ea
Merge pull request #1163 from daniele-sforza/feat/it-localization-full-parity
Updated Italian Localization
2026-03-30 22:19:18 +05:30
Nayif
c7e3d16950
Merge pull request #1084 from danilopagotto82/Pt-br-update-v.0.6.1
Pt-br update StreamInfoOverlay and Infitive verb (Conflict resolved)
2026-03-30 22:19:08 +05:30
skoruppa
000b4d68bf Allow for addons with selfsigned ssl - fixes #1157 2026-03-30 15:50:44 +02:00
kernexshadow
7014162cb2 Fix duplicate subtitle option keys from addon subtitles 2026-03-30 14:42:14 +01:00
DaN
e4f4efaf51
Update strings.xml
Genre Translate Pt-Br
2026-03-30 10:29:46 -03:00
skoruppa
669d5c3c3d More errors for retry & silent retry
Some users complained about seeing the loading screen when watching streams - this was caused by the retry mechanism. Because of that we now try with less aggressive retry withot loading screen and only if that fails  we teardown player and rebuild it with loadingOverlayEnabled
2026-03-30 15:18:44 +02:00
DaN
ed5f1231ac
Merge branch 'dev' into Pt-br-update-v.0.6.1 2026-03-30 10:14:59 -03:00
kernexshadow
9a33c7450c Merge branch 'dev' of https://github.com/tapframe/NuvioTV into mvp
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt
2026-03-30 13:53:00 +01:00
M7sin
f3d45423ef
Update supported locales in ThemeSettingsScreen 2026-03-30 15:49:09 +03:00
M7sin
18fbb84a4b
Added Arabic Language 2026-03-30 15:44:51 +03:00
M7sin
9c61112301
Delete app/src/main/res/Values-ar directory 2026-03-30 15:43:47 +03:00
M7sin
c2a3c3adb4
Added Arabic Language 2026-03-30 15:42:36 +03:00
M7sin
0397fb51be
Add Arabic locale to locale_config.xml 2026-03-30 15:41:27 +03:00
natan
e1ba65d8b0 more rtl fixes 2026-03-30 14:18:52 +03:00
Daniele Sforza
c4d2a6a10d Updated Italian Localization 2026-03-30 12:35:07 +02:00
skoruppa
7a3363bccd Fix for #1154 - more tags for Portuguese subs 2026-03-30 08:24:49 +02:00
Amarjit Singh
f8d2055b85 Fix ModernHomeContent build errors after 0.5.5 merge
Add missing stringResource import, define showCatalogTypeSuffixInModern
and showFullReleaseDate from uiState, build missing CarouselRowLookups
fields, and pass isActiveRow/isVerticalRowsScrolling to ModernRowSection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:35:22 -07:00
Amarjit Singh
aee205afcf Merge remote-tracking branch 'upstream/dev' into feature/collections 2026-03-29 20:32:30 -07:00
kernexshadow
b75cdad05f Refine subtitles overlay focus restore behavior 2026-03-30 00:19:48 +01:00
natan
6ad4d64a89 Fix Hebrew key and player direction 2026-03-29 22:05:22 +03:00
natan
392e741086 Fix Hebrew key and player direction 2026-03-29 22:04:15 +03:00
tapframe
dfd8a92c23 fix build error 2026-03-29 22:39:32 +05:30
tapframe
b1bd5221b5 bump version 2026-03-29 22:16:40 +05:30
Nayif
e2afe63992
Merge pull request #829 from The-cpu-max/dev
Add Hebrew (he) language support with i18n
2026-03-29 22:15:18 +05:30
Stéphane
b92c9d1721 Extract hardcoded strings to string resources for i18n support
Move ~86 hardcoded user-facing strings from Kotlin UI code to
strings.xml resources (EN + FR), enabling proper localization.

Screens: ProfileSelection, Account, Player, Search, Discover,
Library, Plugin, Addon, Settings, Trakt, Stream, and overlays.

ViewModels: add @ApplicationContext to SearchViewModel,
DebugSettingsViewModel, LibraryViewModel for getString() access.

Remaining intentional hardcoded strings: brand names (Trakt, TMDB,
MDBList, Anime-Skip), animation labels, and @Preview-only text.
2026-03-29 18:18:00 +02:00
Nayif
5d433c11c6
Merge pull request #1136 from stohle/dev
Updated Norwegian translation
2026-03-29 21:32:20 +05:30
Nayif
b4a30172b1
Merge pull request #1143 from foXaCe/fix/french-translation-audit
Complete French translation audit and corrections
2026-03-29 21:32:07 +05:30
Nayif
7898435b24
Merge pull request #1141 from Joe00011/dev
Update Japanese translation
2026-03-29 21:31:55 +05:30
Nayif
252dba7d90
Merge pull request #1139 from skoruppa/fixes
Fixes for CW & Watched Badges - properly handle both Trakt and Nuvio sync
2026-03-29 21:31:44 +05:30
tapframe
f8244157bd Implement remote deletion of watched items in WatchProgressRepository and added pagination to support more than 1000 watched items 2026-03-29 21:31:20 +05:30
skoruppa
45e0b5658e Don't add old series to CW for trakt users 2026-03-29 17:49:25 +02:00
skoruppa
713f700498 Let player to access self signed ssl streams (mp4upload) 2026-03-29 15:18:39 +02:00
skoruppa
0082521bfa Use both tmdb and imdb ids to monitor watched episodes 2026-03-29 15:16:59 +02:00
Stéphane
868c690400 Remove audit report from PR 2026-03-29 13:16:12 +02:00
Stéphane
50900785f1 Complete French translation audit and corrections
- Add 52 missing strings (genres, stream info, library filters)
- Fix 100+ vouvoiement → tutoiement throughout
- Align terminology with NuvioMobile glossary (Regarder, Distribution, Ma liste, Passer l'intro, Continuer à regarder)
- Fix Play action buttons: Lire → Regarder (8 strings)
- Fix inaccurate translations (account_sign_in_description, profile_selection_hint)
- Coverage: 1058/1058 strings (100%)
2026-03-29 13:10:56 +02:00
Joe00011
c8dae88e67
Update strings.xml 2026-03-29 16:39:24 +09:00
skoruppa
34cb855441 Update HomeViewModelContinueWatching.kt 2026-03-29 09:11:32 +02:00
skoruppa
557f00be96 Fixes for CW - properly handle both Trakt and Nuvio sync
also, clear watched items when changing to/from trakt
2026-03-29 08:21:02 +02:00
Amarjit Singh
5ea2c9f255 Merge upstream/dev (0.5.4-beta) into feature/collections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:36:33 -07:00
Amarjit Singh
0f4a61aa90 Add catalog source reorder in folders + enhanced import/export
- Add up/down reorder buttons for catalog sources within collection folders
- Export: save as JSON file to Downloads via MediaStore + clipboard copy
- Import: 3 methods - paste JSON, load from Downloads file, fetch from URL
- Add deep JSON validation with preview before importing
- Wrap import UI in LazyColumn for scrollability on TV

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:32:31 -07:00
Joe00011
d5d1febe21
Update strings.xml 2026-03-29 11:32:17 +09:00
kernexshadow
18a38fd70f Refine subtitle overlay focus behavior 2026-03-28 23:43:04 +00:00
stohle
9fe22d917e Updated weird sentences and translations from line 1 to 420 2026-03-29 00:03:20 +01:00
Joe00011
ada7eb1f58
Update strings.xml 2026-03-29 02:54:59 +09:00
tapframe
affe32142b bump version 2026-03-28 17:39:55 +05:30
tapframe
0a27abf001 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-28 17:38:03 +05:30
The-cpu-max
8d70f06a74
Merge branch 'dev' into dev 2026-03-28 12:04:59 +03:00
Nayif
161a57e8cb
Merge pull request #1124 from skoruppa/fixes 2026-03-28 14:19:24 +05:30
skoruppa
ab911d0aa9 Fix for translations duplicates after merges 2026-03-28 09:17:08 +01:00
Nayif
23bd2a6006
Merge pull request #1119 from halibiram/fix/modern-home-trailer-backpress 2026-03-28 13:32:46 +05:30
Nayif
091705d50f
Merge pull request #987 from albyalex96/updated-it-locale 2026-03-28 13:31:11 +05:30
Nayif
c22bb7f3a6
Merge pull request #1044 from stohle/dev 2026-03-28 13:31:03 +05:30
Nayif
f5d9036764
Merge pull request #1076 from mindgam3s/dev-update-german 2026-03-28 13:30:53 +05:30
Nayif
676af82b6a
Merge pull request #1116 from SnaexCZ/dev 2026-03-28 13:30:21 +05:30
Nayif
1104b0504c
Merge pull request #1113 from klemenko1/dev
Update strings.xml with latest Slovenian localization 0.5.3
2026-03-28 13:26:44 +05:30
Nayif
da310f7796
Merge pull request #1098 from Ircas/dev
FEAT: added greek translation
2026-03-28 13:26:20 +05:30
Nayif
1fc120c4d2
Merge pull request #1117 from IberianSoldierPC/main
values-es || Spanish translation updated
2026-03-28 13:26:01 +05:30
Nayif
d169e42421
Merge pull request #1011 from kernexshadow/subtitlesOffsetFix 2026-03-28 13:24:39 +05:30
Nayif
10f2806dad
Merge pull request #1122 from halibiram/stream-screen-dpad-fix
Fixes navigation lag on Stream Screen.
2026-03-28 13:22:04 +05:30
Nayif
e2881f9556
Merge pull request #1094 from Sanzin61/patch-2
update Pt-BR values. (substituição, da palavra "via" pois ela é raramente usada no PT-BR
2026-03-28 13:21:40 +05:30
Nayif
f0c9fe834a
Merge pull request #1111 from skoruppa/watched-series
More persistent watched badges - improvements for #1074
2026-03-28 13:21:07 +05:30
skoruppa
a06b81f74f Add Greek & Norwegian 2026-03-28 08:36:29 +01:00
skoruppa
e1a8d6c627 Clear dismiss key on progress 2026-03-28 08:31:01 +01:00
HALİL İBRAHİM ÖZDEMİR
88295c2920 Add dpad repeat throttling to player stream sources side panel 2026-03-28 04:37:50 +03:00
HALİL İBRAHİM ÖZDEMİR
c25a709d0b Add dpad repeat throttling to stream selection screen 2026-03-28 04:23:39 +03:00
HALİL İBRAHİM ÖZDEMİR
dd6fd89d28 add missing Turkish translations and improve naturalness of existing ones
- Added profile PIN strings (pin_title, pin_set/change/remove, overlay variants) - Added StreamInfoOverlay strings (KAYNAK, DOSYA, VİDEO, SES, ALTYAZI, etc.) - Added all genre names (Aksiyon, Komedi, Gerilim, Bilim Kurgu, etc.) - Added library filter strings (genre, year) - Added network speed test strings - Added library source badges (NUVİO, TRAKT) and syncing_library - Fixed unnatural translations: ratings_loading, cast_detail_error/retry, catalog_see_all_empty_subtitle, library_filter_type/list
2026-03-28 02:50:30 +03:00
HALİL İBRAHİM ÖZDEMİR
cf16fb4d60 intercept back press to dismiss modern home fullscreen trailers
Added BackHandler to ModernHomeContent to clear focusedCatalogSelection and expandedCatalogFocusKey when exiting a fullscreen trailer playback. This safely closes the trailer without unintentionally triggering the root navigation drawer/navbar.
2026-03-28 02:45:07 +03:00
skoruppa
86ccfcb22a Don't count not released episodes without a release date 2026-03-27 22:34:10 +01:00
skoruppa
1744f13be0 Add 2 more tags for latino and brazil 2026-03-27 22:19:26 +01:00
The-cpu-max
483b5cabd0
Delete .github/workflows/build-debug-apk.yml 2026-03-27 22:56:15 +03:00
The-cpu-max
679aa94faf
Create build-debug-apk.yml 2026-03-27 22:19:48 +03:00
The-cpu-max
9837a72186
Merge branch 'NuvioMedia:dev' into dev 2026-03-27 22:12:48 +03:00
IberianSoldierPC
7023270beb
values-es updated 2026-03-27 19:52:18 +01:00
SnaexCZ
e155de0bb6
Update Czech translations for various strings 2026-03-27 19:32:33 +01:00
skoruppa
4cff095ce7
Merge branch 'NuvioMedia:dev' into watched-series 2026-03-27 19:27:48 +01:00
klemenko1
52134d9adc
Add files via upload 2026-03-27 18:16:34 +01:00
skoruppa
f9eb54b1ab Merge branch 'watched-series' of https://github.com/skoruppa/NuvioTV into watched-series 2026-03-27 15:19:43 +01:00
skoruppa
a1304b0d39 persist watched series cache, use watchedItems as source of truth 2026-03-27 15:19:38 +01:00
Nayif
a4639dc2e5
Merge pull request #1083 from skoruppa/fixes 2026-03-27 19:30:52 +05:30
natan
27f3213fec Merge branch 'dev' into player_loading_status
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt
2026-03-27 16:10:22 +03:00
tapframe
cc10886d6a feat: adding 3h ttl cache for yt trailers 2026-03-27 18:05:07 +05:30
Nayif
cb2b3d5a93
Merge pull request #1074 from skoruppa/watched-series
Show watched badge for series & badges on all screens
2026-03-27 17:44:50 +05:30
Nayif
dca9b852d3
Merge pull request #1070 from halibiram/perf/modernhome-performance-optimization-v2
Perf/modernhome performance optimization
2026-03-27 17:42:11 +05:30
Nayif
a3802e2abb
Merge pull request #1107 from skoruppa/fixes-2
fix infinite buffering after audio track switch
2026-03-27 17:41:24 +05:30
kernexshadow
14eaa89edb Normalize external WebVTT cue positioning 2026-03-27 10:30:49 +00:00
skoruppa
acc158dbfb fix infinite buffering after audio track switch 2026-03-27 10:40:52 +01:00
skoruppa
9c7b1d4c30 Filter seriers 2026-03-27 08:46:23 +01:00
skoruppa
1090c6409c We should not care about NextUp and Inprogress 2026-03-27 08:23:43 +01:00
skoruppa
9f59e18c11 hasScannedTextTracksOnce when searching for sutbtiles 2026-03-27 07:23:57 +01:00
skoruppa
e149309beb Be sure to check primary lang for addons here as well 2026-03-27 07:10:23 +01:00
Ircas
0f1497697a
Update strings.xml 2026-03-27 03:38:12 +02:00
Ircas
17b4bfcc58
Update strings.xml 2026-03-27 03:22:52 +02:00
skoruppa
ba468c013e Take into account all episodes 2026-03-27 02:08:31 +01:00
skoruppa
b067bfde9e Addons priority by language 2026-03-27 01:54:42 +01:00
Ircas
a88d7d315d
Create strings.xml 2026-03-27 02:32:19 +02:00
Ircas
43adf5d153
Update locale_config.xml 2026-03-27 02:27:12 +02:00
Ircas
02ef9c975b
Update ThemeSettingsScreen.kt 2026-03-27 02:24:37 +02:00
skoruppa
7cf215ce4b Fix two issues for trakt found during testing
- For things that were bulk marked as watched (same datetime) we were picking incorrect episode as the next one
- When unmarking episode as watched, whole cache for series were invalidated and all episodes seemed unwatched
2026-03-27 00:30:39 +01:00
skoruppa
325b64cf59 Missing translations 2026-03-27 00:19:14 +01:00
skoruppa
3a37a6479e Merge remote-tracking branch 'upstream/dev' into watched-series 2026-03-26 23:58:54 +01:00
skoruppa
f987c04f3d Badges connected to all screens (Search, Discovery, Library, CatalogSeeAll) 2026-03-26 23:47:34 +01:00
HALİL İBRAHİM ÖZDEMİR
ab012aafbd feat(details): add hero-backed loading transition
Carry the active home hero backdrop into the details loading state so first-open navigation stays visual instead of flashing to a flat skeleton. Add backdrop-aware shimmer scrims and keep the seed backdrop available even when fullscreen hero media is disabled so the transition feels consistent across both home layout modes.
2026-03-27 01:12:44 +03:00
Sanzin61
0e9562d940
Replacements of some words that previously seemed "robotic," for better "harmony." Updated PT-BR strings.(Update strings.xml)
I made changes to some words and phrases, mainly those with the word "via" because it is rarely used in everyday Brazilian Portuguese. I opted for a simple alteration.For better understanding of the sentence (I didn't make any major changes to the sentences).I hope you like it.
2026-03-26 18:48:41 -03:00
skoruppa
a2239ca5ee getWatchedMoviesSnapshot on start 2026-03-26 22:46:07 +01:00
skoruppa
fd7e4e0756 Trimming is unnecessary 2026-03-26 21:54:33 +01:00
HALİL İBRAHİM ÖZDEMİR
1429baf8d8 fix(ModernHome): restrict key repeat conversion to D-pad directions only, preserve OK long-press 2026-03-26 23:41:57 +03:00
CrissZollo
52ea212e0f
Added support for locolization on the discovery page for genre 2026-03-26 20:33:02 +01:00
CrissZollo
bdf756f54c
Updated swedish translation 2026-03-26 20:32:00 +01:00
CrissZollo
803993027a
New library strings for localization 2026-03-26 20:31:38 +01:00
skoruppa
c485115721 Merge movie and show statuses 2026-03-26 20:16:44 +01:00
CrissZollo
2a7b522665
Adds filter genre and year for library nuvio account and local #782 2026-03-26 20:09:58 +01:00
CrissZollo
8b94063298
Rememeber sorting, account sync name in library, respect date added during sync from cloud #782 2026-03-26 19:52:40 +01:00
HALİL İBRAHİM ÖZDEMİR
0f0d8e3cbd revert: home startup decouple from continue watching sync (6c2acaf) 2026-03-26 20:24:23 +03:00
HALİL İBRAHİM ÖZDEMİR
e7e77d3243 perf(ModernHome): optimize D-pad repeat navigation with focusManager.moveFocus and axis-specific throttling 2026-03-26 20:20:54 +03:00
tapframe
d8d74d2a85 feat: add backing cw episode remapping for anime(trakt) 2026-03-26 21:58:20 +05:30
skoruppa
fc326cf256 Sneak fix for stringName new line 2026-03-26 14:34:18 +01:00
DaN
8e457a7c85
Update strings.xml 2026-03-26 10:27:36 -03:00
skoruppa
16925ba675 Added CW_MAX_NEXT_UP_LOOKUPS to not flood meta requests 2026-03-26 14:24:43 +01:00
DaN
82852cb4a1
Update strings.xml
Lines
2026-03-26 10:10:56 -03:00
DaN
fbad85229d
Update strings.xml
New Strimgs and infitive.
2026-03-26 10:05:14 -03:00
skoruppa
4b4345d614 Handle also secondary language/addons 2026-03-26 13:50:17 +01:00
skoruppa
6b3a662a7f split regional subtitle variants into separate language tabs (pt, es) 2026-03-26 13:40:32 +01:00
HALİL İBRAHİM ÖZDEMİR
4367583ac9 perf: pre-warm reverse tmdb cache during hero enrichment 2026-03-26 13:54:19 +03:00
HALİL İBRAHİM ÖZDEMİR
e255f9fbb4 Merge remote-tracking branch 'origin/dev' into perf/modernhome-performance-optimization-v2 2026-03-26 13:21:31 +03:00
HALİL İBRAHİM ÖZDEMİR
e9c528072a fix: refine backdrop reuse and warm startup rows 2026-03-26 13:15:06 +03:00
mindgam3s
0973ed64c1
translated all new strings
translated all new strings
2026-03-26 10:20:52 +01:00
skoruppa
f09e64c361
Merge branch 'NuvioMedia:dev' into watched-series 2026-03-26 09:36:22 +01:00
Nayif
34b65ddac3
Merge pull request #1021 from mindgam3s/dev-german
updated some german descriptions
2026-03-26 13:28:24 +05:30
tapframe
a1c4a98635 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-26 13:22:12 +05:30
tapframe
81361efcc6 bump version 2026-03-26 13:22:07 +05:30
mindgam3s
cf0d1944ca
Merge branch 'NuvioMedia:dev' into dev-german 2026-03-26 08:51:25 +01:00
Nayif
69cf0e65fd
Merge pull request #1057 from danilopagotto82/Pt-br-update-v0.6.0
Update Pt-Br News strings to Nuvio 0.5.2
2026-03-26 13:20:15 +05:30
Nayif
50099448f8
Merge pull request #1054 from SK-Gits/localization/update-fr-strings
feat(L10n): add 6 French strings and modify 4 strings for consistency
2026-03-26 13:19:39 +05:30
Nayif
c0c8e6c689
Merge pull request #1072 from halibiram/feat/tr-translations-update
feat(i18n): add missing Turkish translations and fix UI overflow strings
2026-03-26 13:18:36 +05:30
tapframe
e60823122f chaneg transparency when trailer plays 2026-03-26 13:07:30 +05:30
Nayif
7fdd35253c
Merge pull request #1073 from skoruppa/player-retry
Auto-retry on transient playback errors
2026-03-26 12:49:29 +05:30
Nayif
132dab246d
Merge pull request #1052 from skoruppa/fixes
Batch of small bug fixes (#1045, #1050, #1025, #958)
2026-03-26 12:48:12 +05:30
skoruppa
e2401aca43 surface old series with new episodes in CW 2026-03-26 07:59:42 +01:00
skoruppa
87da0d68c8 show watched badge for fully watched series 2026-03-26 07:46:46 +01:00
skoruppa
d309e9e141 auto-retry on transient playback errors 2026-03-26 07:30:36 +01:00
HALİL İBRAHİM ÖZDEMİR
73dd262824 feat(i18n): add missing Turkish translations and fix UI overflow strings
- Add 60+ missing TR keys: auth notices, stream errors, cw states,
  detail comments, TMDB entities, series statuses, Trakt watch progress,
  Trakt comments, debug library section, addon messages, audio mix dialog,
  subtitle style, cast detail born/age, watchlist, layout fields
- Shorten UI-overflowing strings: update_title, update_downloading,
  layout_selection_continue, layout_selection_subtitle
- Fix incorrect/garbled translations: account_title, account_linked_devices,
  ratings_loading/unavailable, discover_filter_*, plugin_enabled/disabled,
  profile_edit_title_id and more
2026-03-26 08:07:45 +03:00
HALİL İBRAHİM ÖZDEMİR
115543161b fix: eliminate modern home expand-from-topleft glitch on initial load
- Use EnterTransition.None for first appearance instead of fadeIn(320ms)
- Initialize heroItem synchronously from carouselRows instead of null
- Subsequent navigations still use fadeIn + slideInVertically animation
2026-03-26 05:33:35 +03:00
HALİL İBRAHİM ÖZDEMİR
1efbe16e30 Revert "perf: replace modern row focus restorer"
This reverts commit c6927e5059.
2026-03-26 05:10:40 +03:00
HALİL İBRAHİM ÖZDEMİR
0de3482338 perf: prefetch hero backdrop at full screen size for non-fullscreen hero mode 2026-03-26 05:05:34 +03:00
HALİL İBRAHİM ÖZDEMİR
a27f886edf feat: persist hero backdrop across Home→Detail transition to prevent re-render 2026-03-26 04:31:20 +03:00
HALİL İBRAHİM ÖZDEMİR
bc225ce533 perf: stabilize modern hero scene during scroll 2026-03-26 03:05:09 +03:00
HALİL İBRAHİM ÖZDEMİR
c6927e5059 perf: replace modern row focus restorer 2026-03-26 03:01:18 +03:00
HALİL İBRAHİM ÖZDEMİR
e966bcdfca perf: precompute modern hero row lookups 2026-03-26 02:56:25 +03:00
skoruppa
9700b4752d
Merge branch 'NuvioMedia:dev' into fixes 2026-03-25 23:52:07 +01:00
HALİL İBRAHİM ÖZDEMİR
c9fcfa486a perf: limit modern row prefetch to active row 2026-03-26 00:58:47 +03:00
HALİL İBRAHİM ÖZDEMİR
6c2acafec1 perf: decouple home startup from continue watching sync 2026-03-26 00:30:23 +03:00
mindgam3s
98801f2d2e
ADDED new episode/season strings 2026-03-25 19:39:18 +01:00
CrissZollo
e2ed555a1e
#132 ignore duplicate items on load 2026-03-25 19:31:34 +01:00
skoruppa
d3a6d740cf Fix watch progress not syncing between tmdb: and IMDB content IDs 2026-03-25 18:17:08 +01:00
DaN
60d0079c74
Update Pt-Br strings.xml
Add new strings
2026-03-25 11:59:21 -03:00
SK-Gits
744e618f4c feat(L10n): add 6 French strings and modify 4 strings for consistency 2026-03-25 14:01:22 +01:00
tapframe
b1b77bde5e feat: added support for Trakt as a source for more like this section 2026-03-25 18:25:56 +05:30
HALİL İBRAHİM ÖZDEMİR
0c2bb40707 perf: warm modern home startup rows 2026-03-25 15:54:24 +03:00
HALİL İBRAHİM ÖZDEMİR
3d42da9d37 fix: resolve modern home focus rows from presentation state 2026-03-25 15:37:01 +03:00
tapframe
fd1202a597 added pagination to trakt comments 2026-03-25 17:53:10 +05:30
kernexshadow
645756fc28 Refine subtitle offset fix 2026-03-25 11:35:15 +00:00
HALİL İBRAHİM ÖZDEMİR
f110742138 fix: remove initial home slide-in 2026-03-25 13:47:01 +03:00
skoruppa
0fa0c510bc Prevent "reause last link" auto play when going back to stream screen for new episode after playing it 2026-03-25 11:39:53 +01:00
HALİL İBRAHİM ÖZDEMİR
58d7a6f64a refactor: consume prebuilt modern home rows 2026-03-25 13:34:00 +03:00
HALİL İBRAHİM ÖZDEMİR
c64b3c43f3 refactor: move modern home mapping into viewmodel 2026-03-25 13:33:40 +03:00
skoruppa
568f606a09 Fix for #981 - even with horizontal posters, we should have max 4 lines of description 2026-03-25 11:30:12 +01:00
skoruppa
9de1912624 Fix for #958 - defer selecting secondary language if subtitles from addons are not yet loaded 2026-03-25 11:11:54 +01:00
skoruppa
c392dfa08e Possible fix for #1050 - race condition when scrolling to correct episode 2026-03-25 10:58:25 +01:00
skoruppa
c0c19ecf10 Fix for #1025 - change new line into dot to display whole information from addons
Also added translation for this screen
2026-03-25 10:53:30 +01:00
skoruppa
9eb5bb5c60 Make backdrop and logo freeze more permanent - fixes #1045 2026-03-25 09:55:52 +01:00
Amarjit Singh
8acb5165a3 Merge upstream/dev (0.5.2-beta) into feature/collections
Syncs collections branch with latest upstream changes including
fullscreen backdrop/trailer mode, blur unwatched episodes, CW
improvements, translations, and various fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 23:25:07 -07:00
tapframe
d8a9439798 bump version 2026-03-25 11:27:55 +05:30
Nayif
aa048500df
Merge pull request #1048 from skoruppa/fixes
Changes for fullscreen gradients
2026-03-25 11:25:09 +05:30
skoruppa
3708810c30 Changes for fullscreen gradients 2026-03-25 06:35:23 +01:00
Nayif
98b82b460d
Merge pull request #1032 from danilopagotto82/dev
Update Pt-Br Fullscreen strings.xml
2026-03-25 10:42:24 +05:30
tapframe
493750fbb0 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-25 10:36:25 +05:30
tapframe
60751c7f95 fix: gating content transparency only when fullscren backdrop is on 2026-03-25 10:36:11 +05:30
Nayif
42083e0d90
Merge pull request #1039 from skoruppa/fixes
Fixes for CW after #1027
2026-03-25 10:35:36 +05:30
stohle
cded16d101 Updated grammar and syntax in Norwegian. 2026-03-25 03:11:03 +01:00
stohle
6aa89ffb2d Test from VS Code 2026-03-25 03:00:48 +01:00
stohle
86300262c4
Create strings.xml
Machine translated strings in Norwegian.

Syntax/grammar errors has to be fixed.
2026-03-25 02:41:31 +01:00
skoruppa
9d86e73c92 Fullscreen backdrop polish translation 2026-03-24 23:18:08 +01:00
skoruppa
0d0216a719 Revert "suppress intermediate CW renders during initial pipeline load"
This reverts commit 0117a2eebf.
2026-03-24 22:39:27 +01:00
skoruppa
f20f2832f0 Add separate setting for blurring CW and making proper tmdb enrichment for movies in CW 2026-03-24 21:25:13 +01:00
DaN
0d284edd00
Update strings.xml
FullScren Hero
2026-03-24 15:13:28 -03:00
mindgam3s
0581a61e88
strings.xml aktualisieren 2026-03-24 19:02:07 +01:00
albyalex96
32a684487d
Merge branch 'dev' into updated-it-locale 2026-03-24 17:51:28 +01:00
tapframe
570fbab26d update release notes handling 2026-03-24 21:59:46 +05:30
tapframe
1b32045b59 update actions 2026-03-24 21:29:00 +05:30
tapframe
4a14db2d71 bump version 2026-03-24 21:24:48 +05:30
tapframe
bf7eb22c0b feat: adding fullscreen backdrop/trailer mode on modernhomelayout 2026-03-24 21:11:00 +05:30
Nayif
3db2a59498
Merge pull request #1027 from skoruppa/fixes-2
Batch of small bug fixes (#948, #849, #585, #960)
2026-03-24 20:33:00 +05:30
skoruppa
75219b04cb Make Continue Watching more consistent (blur, tmdb enrchichement, thumbnails) and control tmdb enrich with new setting
Fixes #849 #585
2026-03-24 14:50:18 +01:00
Nayif
468a3bb7ec
Merge pull request #1004 from skoruppa/fixes
Fix Continue Watching focus not landing on the first item
2026-03-24 19:04:59 +05:30
mindgam3s
d35a8661b9
updated some german descriptions
updated some german descriptions
2026-03-24 11:24:38 +01:00
skoruppa
8943545375 Fixes #948 - imdb rating is also updated on focus with tmdb enrichment 2026-03-24 11:18:31 +01:00
skoruppa
a85b6b9b9f Add missing Polish translations 2026-03-24 11:10:56 +01:00
skoruppa
940b056347 Fixes #960 - small indicator that app is listening 2026-03-24 11:05:42 +01:00
skoruppa
735b7ecfaa Fix for pressing back when "Reuse Last Link" is enabled
Cached auto-play via "Reuse Last Link" kept the stream screen on the back stack, trapping the user in a playback loop. Fixed by routing it through onAutoPlayResolved to remove the stream screen from the back stack
2026-03-24 09:41:25 +01:00
skoruppa
0117a2eebf suppress intermediate CW renders during initial pipeline load 2026-03-24 07:37:15 +01:00
tapframe
44a97dfa99 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-24 11:26:02 +05:30
tapframe
292080a75b fix: profile selection key not registering 2026-03-24 11:18:20 +05:30
Nayif
9568f58910
Merge pull request #986 from SK-Gits/localization/update-fr-strings
feat(L10n): add 64 missing French strings and modify 4 network strings for consistency
2026-03-24 10:56:09 +05:30
Nayif
1d73eb86e6
Merge pull request #993 from mccoy88f/dev
i18n: completa e allinea traduzioni italiane (strings.xml)
2026-03-24 10:55:31 +05:30
Nayif
40ca5a6c43
Merge pull request #1001 from affcosta7/patch-2
Update Portuguese (Portugal) strings.xml - added missing strings
2026-03-24 10:55:09 +05:30
Nayif
8dadcb4b52
Merge pull request #1003 from danilopagotto82/Update-translation-v6
Update Translation (PT-BR) – More Lines and Adjustments
2026-03-24 10:54:55 +05:30
Nayif
09781cead2
Merge pull request #1005 from omavel/patch-1
Update Latin American Spanish (es-419) translations – added new strin…
2026-03-24 10:54:07 +05:30
Amarjit Singh
09d062b8ab Merge upstream/dev (0.5.0-beta) into feature/collections
Syncs collections branch with latest upstream changes including
profile PIN system, Trakt comments, settings sync, posterShape-aware
card sizing, episode resolution, and hero shuffle fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:12:40 -07:00
HALİL İBRAHİM ÖZDEMİR
f719904b77 fix: add shimmer animation to cast screen skeleton loading 2026-03-24 04:34:26 +03:00
JFeverish
e6da6dd8ff Adjust external WebVTT subtitle positioning 2026-03-23 21:29:37 +00:00
tapframe
4b6255f8ae refactor: update NextUp item to use seedSeason and seedEpisode properties 2026-03-23 21:30:34 +05:30
Omavel
43d540700e
Update Latin American Spanish (es-419) translations – added new strings and adjustments
This update adds new Latin American Spanish (es-419) translations and refines existing ones to improve consistency, clarity, and overall localization quality across the app.
2026-03-23 11:46:00 -03:00
JFeverish
06a3b705db wip: 2026-03-23 14:35:18 +00:00
DaN
6484feb656
Update strings.xml
New Translate, more lines and setups.
2026-03-23 10:54:09 -03:00
SK-Gits
b1eadc0930 feat(L10n): add 53 French strings and modify 4 network strings for consistency 2026-03-23 12:00:49 +01:00
André Costa
d24bce7125
Update Portuguese (Portugal) strings.xml - added missing strings
Update Portuguese - Portugal translation for NuvioTV. Added and fixed translations for newly added keys to keep the localization up to date - added strings.
2026-03-23 09:48:04 +00:00
SK-Gits
2cd1bfb637 Merge branch 'dev' of https://github.com/NuvioMedia/NuvioTV into localization/update-fr-strings 2026-03-23 09:21:17 +01:00
tapframe
b4855b7a15 fix: random shuffling of class layout hero upon cold boot 2026-03-23 13:41:32 +05:30
tapframe
4645463524 Merge branch 'nextep' into dev 2026-03-23 13:25:50 +05:30
tapframe
bcfbf566bc feat: adding new episode resolution 2026-03-23 13:25:34 +05:30
Amarjit Singh
029fdd0cd5 Fix disabled collections still showing on home screen
Skip collection and catalog keys in disabledHomeCatalogKeys when
building home rows, so the Enable/Disable toggle in Reorder Home
Catalogs is respected for collections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 00:03:29 -07:00
tapframe
69c3d18868 Merge branch 'settingssync' into dev 2026-03-23 10:25:05 +05:30
SK-Gits
819294c1d8 Merge branch 'dev' of https://github.com/NuvioMedia/NuvioTV into localization/update-fr-strings 2026-03-22 22:18:40 +01:00
Antonello
ca9077ad63 i18n: completa e allinea traduzioni italiane (strings.xml)
Made-with: Cursor
2026-03-22 21:54:42 +01:00
natan
347d1688f2 fixes 2026-03-22 22:53:30 +02:00
tapframe
b2e1e2bf62 feat: integrate ProfileSettingsSyncService for foreground sync on activity start 2026-03-22 21:49:49 +05:30
tapframe
42dce6088a profile settings init 2026-03-22 20:45:46 +05:30
tapframe
81296e4227 Merge branch 'pin' into dev 2026-03-22 18:40:57 +05:30
tapframe
be15b8429d feat: add forgot PIN hint to profile pin overlay 2026-03-22 18:40:44 +05:30
tapframe
92e4356d2b fix: behaviour fix for single profile 2026-03-22 17:38:05 +05:30
tapframe
0c80dd75ac update pin removal/update logic 2026-03-22 17:25:00 +05:30
tapframe
23993712b1 profile pin init 2026-03-22 17:06:35 +05:30
tapframe
b0e927cca2 Merge branch 'traktreview' into dev 2026-03-22 15:40:55 +05:30
tapframe
7412612048 adjusting focus behaviours 2026-03-22 15:38:04 +05:30
tapframe
a6630e2794 Added comments overlay 2026-03-22 15:15:43 +05:30
tapframe
d1662f6101 trakt comments init 2026-03-22 14:09:00 +05:30
tapframe
7084a1e974 CatalogSection to properly respect posterShape in modernview 2026-03-22 13:00:42 +05:30
tapframe
967f489853 log cleanup 2026-03-22 12:48:51 +05:30
tapframe
547751ae37 Add startup content gate with timeout for home screen 2026-03-22 12:35:08 +05:30
tapframe
baf05ecbd6 Refactor watch progress handling: add batch save functionality 2026-03-22 12:27:21 +05:30
tapframe
663f44f930 Improved cw section render resolution - trakt 2026-03-22 11:46:35 +05:30
tapframe
4e68cc4960 reduce alpha transparency 2026-03-22 09:26:22 +05:30
albyalex96
59f6259e6d
Updated Italian Localization
Added various error and status messages in Italian.
2026-03-21 22:26:46 +01:00
SK-Gits
219fe07dbe feat(L10n): add 11 missing French strings 2026-03-21 22:09:33 +01:00
natan
70702cc26c show subtitles addon loading 2026-03-21 20:45:53 +02:00
Amarjit Singh
28ebeb7d7f Merge branch 'dev' into feature/collections
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt
2026-03-21 11:06:48 -07:00
The-cpu-max
a9749a2ce0
Create build-debug-apk.yml 2026-03-21 13:19:50 +02:00
tapframe
1296e4004d ref: dim tmdbbrowseScreen bg 2026-03-21 15:32:56 +05:30
tapframe
f392548e2d bump version 2026-03-21 13:34:39 +05:30
tapframe
ce95ca3e80 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-21 13:17:33 +05:30
tapframe
30747478a2 Merge branch 'feature/format-aware-audio-buffer' into dev 2026-03-21 13:17:28 +05:30
Nayif
41d6af505c
Merge pull request #950 from aicon/dev
Update Hungarian translation and align strings.xml structure
2026-03-21 13:08:24 +05:30
Nayif
cc22dea14d
Merge pull request #966 from kernexshadow/perf-home-startup-trakt
Fix Trakt continue watching shows not supposed to appear & Improve performance at cold start when Trakt is enabled
2026-03-21 13:05:00 +05:30
Nayif
1c2e6553ba
Merge pull request #968 from halibiram/perf/gpu-overdraw-optimizations
Perf/gpu overdraw optimizations
2026-03-21 13:04:27 +05:30
tapframe
c0fe0316f5 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-21 12:56:48 +05:30
Nayif
0065831574
Merge pull request #977 from skoruppa/fixes
home screen watched checkmark not updating until refocus
2026-03-21 12:53:08 +05:30
Amarjit Singh
ac8f6e0ef5 Fix release build: add @Keep to prevent R8 stripping serialization classes
Gson reflection-based serialization was silently failing in release
builds because R8 minification renamed/stripped the private data class
fields used for JSON parsing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:19:48 -07:00
tapframe
61e18d4b5a Merge branch 'company' into dev 2026-03-21 12:49:04 +05:30
tapframe
a2056e5c37 layout optimizations 2026-03-21 12:48:52 +05:30
Amarjit Singh
9b2587fb7b Expand flags emoji category to include all country flags
Alphabetically ordered from Afghanistan to Zimbabwe, with rainbow
and pirate flags at the top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:04:13 -07:00
Amarjit Singh
8ff1c61fd5 Add export/import collections and missing addon warning
- Export button copies all collections as JSON to clipboard with
  "Copied!" confirmation indicator
- Import screen with paste from clipboard, JSON text field, and
  validation with error messages
- Import merges by ID: existing collections are updated, new ones
  are added alongside
- Missing addon warning on catalog sources in the folder editor:
  red border + "Addon not installed: {addonId}" text
- Consistent button sizing across management screen header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:52:32 -07:00
tapframe
20aa16d863 chore:TmdbEntityBrowseScreen layout adjustments 2026-03-21 12:20:46 +05:30
skoruppa
2df4ec0c4b Don't remember isCatalogItemWatched 2026-03-21 07:35:11 +01:00
Amarjit Singh
9a7b74d87f Add image URL preview and emoji picker auto-focus
- Show a small thumbnail preview next to the image URL text field
  when a URL is entered
- Auto-focus the first emoji when opening the emoji picker
- Fix image URL mode not activating when clicking "Image URL" button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:27:15 -07:00
Amarjit Singh
22fbf47d8c Add emoji picker for folder cover icons
Folders can now use an emoji as their cover instead of an image URL.
The folder editor has a Cover section with three modes: None, Emoji,
and Image URL. The emoji picker displays a categorized grid of emojis
(streaming, genres, sports, music, nature, animals, food, travel,
people, objects, flags, symbols) in horizontally scrollable rows.

Selecting an emoji clears any image URL and vice versa — the two
modes are mutually exclusive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:22:01 -07:00
tapframe
adde31ad36 feat: logo handling with color tinting for dark monochrome logos 2026-03-21 11:26:41 +05:30
Amarjit Singh
910eb8ebfc Add Collections feature for organizing catalogs into folders on the home screen
Collections allow users to group addon catalogs into named folders that
appear as horizontal rows on the home screen. Each folder can have a
custom cover image, tile shape (poster/wide/square), and multiple
catalog sources with tab-based switching in the detail view.

- New domain models: Collection, CollectionFolder, CollectionCatalogSource
- Per-profile persistence via CollectionsDataStore (DataStore + JSON)
- HomeRow sealed class to interleave collections with catalog rows
- Collection row rendering on all three home layouts (Classic, Modern, Grid)
- Full CRUD: create/edit/delete collections and folders
- Multi-select catalog picker with toggle support
- Folder detail screen with tabbed catalog browsing
- Collections visible in Catalog Order screen for reordering
- Entry point via "Collections" card in Addon Manager

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:51:26 -07:00
tapframe
2ed5dcafe4 feat: Introduce TMDB entity browsing screens 2026-03-21 10:47:50 +05:30
tapframe
acb5eaceb8 guarded tmdb enrichment with modular toggle for modern homescreen 2026-03-21 09:46:02 +05:30
HALİL İBRAHİM ÖZDEMİR
7ffc7a0718 Perf: Implement scroll-aware image loading in Modern Home to fix vertical scroll lag 2026-03-21 06:58:24 +03:00
HALİL İBRAHİM ÖZDEMİR
0ea62c9294 perf(ui): replace heavy Coil SvgDecoder with native Compose Icon for missing poster placeholders 2026-03-21 06:04:22 +03:00
Nayif
13d7b3d4e5
Merge pull request #962 from SK-Gits/localization/update-fr-strings
feat(L10n): add 27 missing French strings
2026-03-21 08:31:23 +05:30
Nayif
f2984575dc
Merge pull request #953 from skoruppa/fix-region-logos
Use iso_3166_1 country code to correctly distinguish regional logo variants
2026-03-21 08:31:12 +05:30
Nayif
3c954f5fbf
Merge pull request #949 from skoruppa/fixes-2
Hotfix: prevent duplicate concurrent requests to reduce OOM risk
2026-03-21 08:30:33 +05:30
Nayif
13de093b23
Merge pull request #965 from skoruppa/dates
Set local date format for all dates and add a setting for full date
2026-03-21 08:30:14 +05:30
Nayif
9cb5ef8e12
Merge pull request #746 from danilopagotto82/translate-version5.0
localization: refine Brazilian Portuguese strings v5.0
2026-03-21 08:29:41 +05:30
Nayif
cbe7dad61c
Merge pull request #920 from skoruppa/fixes
Fix for #900. Use available pageSize with catalogs
2026-03-21 08:29:19 +05:30
HALİL İBRAHİM ÖZDEMİR
d9fbe2cef4 perf(home): optimize vertical scroll by replacing TV Card with Box and throttling image prefetch 2026-03-21 05:50:04 +03:00
HALİL İBRAHİM ÖZDEMİR
e256ee02a4 perf(sidebar): eliminate redundant graphicsLayer allocations and Offscreen strategy 2026-03-21 05:24:18 +03:00
HALİL İBRAHİM ÖZDEMİR
5961f23429 perf: Fix GPU overdraw by removing redundant background drawing 2026-03-21 03:59:55 +03:00
HALİL İBRAHİM ÖZDEMİR
0538b94151 perf(ui): eliminate offscreen compositing penalty and merge redundant full-screen overlapping gradients in StreamScreen 2026-03-21 03:59:55 +03:00
HALİL İBRAHİM ÖZDEMİR
8a95aa5c62 perf(ui): remove redundant full-screen gradient overdraws in CastDetailScreen 2026-03-21 03:59:55 +03:00
HALİL İBRAHİM ÖZDEMİR
1b3ffbc0a7 perf(ui): eliminate background overdraw from GridContentCard 2026-03-21 03:59:55 +03:00
HALİL İBRAHİM ÖZDEMİR
e1dc3636c4 perf(ui): eradicate massive GPU overdraws during scroll in MetaDetails by fading base opacity instead of full-screen gradient overlays 2026-03-21 03:59:55 +03:00
HALİL İBRAHİM ÖZDEMİR
0cdd9dedb9 perf(ui): restrict heavy graphicsLayer overlay to active trailer fade sequences 2026-03-21 03:59:55 +03:00
kernexshadow
2c61003aca Improve Trakt continue watching startup and next up 2026-03-20 23:49:12 +00:00
HALİL İBRAHİM ÖZDEMİR
d97cf3347d perf(core): completely remove global haze blur causing full-screen gpu overdraw when sidebar expands 2026-03-21 02:15:42 +03:00
skoruppa
6f2dc8a184 Set local date format for all dates and add a setting for full date 2026-03-21 00:11:32 +01:00
HALİL İBRAHİM ÖZDEMİR
4584f33e6d perf(ui): restore 400ms crossfade animation on hero backdrop for smoother transitions 2026-03-21 01:54:54 +03:00
HALİL İBRAHİM ÖZDEMİR
cafe537f99 perf(core): intercept static window background generation
Nullified default OS window background in MainActivity to cut a complete redundant overdraw pass throughout the whole Surface hierarchy.
2026-03-21 01:47:09 +03:00
HALİL İBRAHİM ÖZDEMİR
3d07c5e19f perf(ui): reduce GPU paints in continue watching cards
Applied transparent container optimization and clamped gradient bounds strictly to half height to prevent useless alpha fills across card posters.
2026-03-21 01:46:48 +03:00
HALİL İBRAHİM ÖZDEMİR
93ea9afff0 perf(ui): optimize hero gradient rendering and remove offscreen compositing
Squashed overlapping custom contours into constrained local gradients, and removed heavy CompositingStrategy.Offscreen layout overhead from Hero layer. Disabled Coil crossfade penalty.
2026-03-21 01:46:03 +03:00
HALİL İBRAHİM ÖZDEMİR
b5be1745b0 perf(ui): eliminate overdraw in base content and carousel cards
Make card backgrounds transparent and delegate painting to AsyncImage's placeholder to remove an entire overdraw layer across all posters.
2026-03-21 01:45:42 +03:00
SK-Gits
0d8defe6b4 feat(L10n): add 27 missing French strings 2026-03-20 21:01:22 +01:00
skoruppa
39c7fe08e8 Use iso_3166_1 country code to correctly distinguish regional logo variants 2026-03-20 15:16:16 +01:00
skoruppa
66e5b70490 Fix for episode description not translated for Continue Watching when using trakt 2026-03-20 12:47:00 +01:00
aicon
7466d5e213
Update Hungarian translation and align strings.xml structure
## Summary
This pull request continues the localization effort for the Hungarian language. It structurally aligns the Hungarian to match the exact order, grouping, and line placement of the original English file, improving long-term maintainability. Additionally, it introduces 48 previously missing string translations and refines several existing ones for better context and natural phrasing.
## Why
- **Added Missing Translations**: Translated 48 missing keys covering network speed diagnostics, audio mix amplification, library generation debugging tools, Addon stream feedback, and Trakt/Nuvio logout notifications.
- **Refined Terminology**: Reviewed and slightly adjusted specific translations for a more natural flow (e.g., updating "Advanced" settings to "Haladó", refining the update download completion message).
- **Consistent Grammar**: Maintained the informal, user-friendly tone ("tegező" phrasing) and established terminology like "bővítmény", "könyvtár", and "várólista".
## Policy check
- [x] This PR is not cosmetic-only, unless it is a translation PR.
- [x] This PR does not add a new major feature without prior approval.
- [x] This PR is small in scope and focused on one problem.
- [x] If this is a larger or directional change, I linked the issue where it was approved.
## Testing
- Verified that the total key count and XML structure perfectly match the original 
- Checked the file for structural errors and validated UTF-8 encoding so special characters display perfectly.
## Screenshots / Video (UI changes only)
None. Not applicable for this change.
## Breaking changes
There are no breaking changes in this pull request. All modifications are additive or involve structural/string refinements for the Hungarian locale.
## Linked issues
This pull request is not linked to any existing GitHub issues.
2026-03-20 11:04:33 +01:00
skoruppa
7c0717d3e2 Merge branch 'fixes-2' of https://github.com/skoruppa/NuvioTV into fixes-2 2026-03-20 09:33:04 +01:00
skoruppa
53c19f543f deduplicate concurrent meta requests to prevent OOM 2026-03-20 09:32:35 +01:00
The-cpu-max
f17b951518
Merge branch 'dev' into dev 2026-03-20 08:01:55 +02:00
natan
bd80d540b3 Merge remote-tracking branch 'upstream/dev' into player_loading_status
# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt
2026-03-20 07:47:37 +02:00
natan
4877a49d12 Merge upstream async AFR preflight with loading status changes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 07:44:05 +02:00
tapframe
b837955e07 bump version 2026-03-20 11:12:35 +05:30
natan
c64abead0f Show player loading status 2026-03-20 07:17:51 +02:00
Nayif
7983254764
Merge pull request #944 from skoruppa/fixes-2
batch of small bug fixes (#930, #942, #926, #757, #945)
2026-03-20 06:59:57 +05:30
skoruppa
2cf894e34c Fixes #945 - Use lifecycle to prevent trailers autoplay when app is not focused 2026-03-20 00:42:14 +01:00
skoruppa
f6c15b6d0d Execute AFR async to let subtitles already load in parallel 2026-03-19 23:39:05 +01:00
skoruppa
b561e806b3 Fix for #757 - Latin American Spanish needs a mapping for tmdb 2026-03-19 23:18:47 +01:00
skoruppa
2370fea863 Fix for #926 - use localized date formats for episodes 2026-03-19 22:57:50 +01:00
skoruppa
65ada4958c 500ms should be enough 2026-03-19 22:41:30 +01:00
skoruppa
ee392ec7e6 Possible fix for #930 - debounce and shared metaCache 2026-03-19 22:39:00 +01:00
skoruppa
02017d98e7 Fix for #942 - logos should also use selectBestLocalizedImagePath 2026-03-19 22:22:24 +01:00
halibiram
3ce7a0dd41
Merge branch 'dev' into feature/format-aware-audio-buffer 2026-03-19 20:24:30 +03:00
tapframe
41ee49af72 bump version 2026-03-19 22:47:53 +05:30
tapframe
ee3929ec36 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-19 22:34:09 +05:30
tapframe
768aae4428 fix(trakt): prevent addon remapping for episode history and playback mapping 2026-03-19 22:22:36 +05:30
Nayif
ebaba8b2a9
Merge pull request #832 from SK-Gits/localization/update-fr-strings
French localization: mirror of values/strings.xml and ~280 additions
2026-03-19 22:06:39 +05:30
tapframe
9921251029 Revert "Merge pull request #917 from halibiram/fix/realtek-dv-decoder-stutter"
This reverts commit 3be9e23d10, reversing changes made to 915a95f29b.

Causing crashes mid stream.
2026-03-19 22:04:40 +05:30
skoruppa
17a7ab3424 Don't cache skipStep 2026-03-19 15:21:36 +01:00
HALİL İBRAHİM ÖZDEMİR
c9f41965d8 feat(player): add format-aware AudioTrack buffer sizing for DTS-HD MA/AC3/TrueHD
- Add FormatAwareAudioTrackBufferProvider with per-codec empirical buffer sizes:
  - DTS-HD MA/HR: 4×30720 = 122KB (prevents micro-stuttering on Shield Pro)
  - TrueHD/Atmos: 2×61440 = 122KB
  - DTS Core: 8×5462 = 43KB
  - AC3: max(platformMin×3, 1536×8)
  - EAC3/DD+: 2×10752 = 21KB
  - PCM: ~200ms target buffer
- Integrate provider into SubtitleOffsetRenderersFactory.buildAudioSink
- Fixes DTS-HD MA and AC3 lag/micro-stuttering on NVIDIA Shield Pro devices
2026-03-19 11:25:23 +03:00
tapframe
e97e5af186 bump version 2026-03-18 23:28:12 +05:30
tapframe
5f3aa487f5 feat(trakt): episode remapping with concurrency and new mapping functions 2026-03-18 23:19:32 +05:30
tapframe
6a4e1d373c feat: add matchEpisodeByUniqueTitle function and integrate it into TraktEpisodeMappingService
fix: streamline videoId resolution in TraktProgressService
2026-03-18 23:17:43 +05:30
tapframe
ba7c4bffa8 Revert "feat: detect subtitle format via HEAD probe for extension-less URLs"
This reverts commit 8bb693633e.
2026-03-18 22:47:19 +05:30
Nayif
76a8f2c227
Merge pull request #915 from skoruppa/persistent-track-preference
Fix race condition & cleanup for persistent audio & subtitle track selection
2026-03-18 22:33:43 +05:30
Nayif
3be9e23d10
Merge pull request #917 from halibiram/fix/realtek-dv-decoder-stutter
fix: add DvTolerantVideoRenderer to prevent micro-stutter on Realtek DV decoders
2026-03-18 22:33:28 +05:30
skoruppa
571f17dac4 Remove old code 2026-03-18 16:41:20 +01:00
HALİL İBRAHİM ÖZDEMİR
18ae1362ce fix(player): optimize DV decoder with unlimited input buffer
- Add DvUnlimitedInputAdapterFactory to set KEY_MAX_INPUT_SIZE=0 for
  Dolby Vision decoders, preventing frame truncation on Realtek devices
- Add DvTolerantVideoRenderer to suppress rapid STATE_READY/BUFFERING
  toggling during brief DV decoder micro-stalls
- Override getCodecAdapterFactory() in SubtitleOffsetRenderersFactory
  to inject the custom adapter factory for DV codecs only
2026-03-18 17:09:42 +03:00
skoruppa
fbcf511fca Fix for #900. Use available pageSize 2026-03-18 14:02:36 +01:00
skoruppa
dbd2e65c86 Missing polish translations 2026-03-18 13:37:00 +01:00
skoruppa
7a25a2c77d Fix for NONE string in subtitles settings 2026-03-18 13:35:03 +01:00
skoruppa
6ebde313d6 Remember subtitles if fetchAddonSubtitles finish after stream execution 2026-03-18 13:18:33 +01:00
DaN
e23022018d
Update strings.xml
Born and age cast
2026-03-18 09:17:24 -03:00
skoruppa
818f060458 Always scroll to selected audio track
With our new persistent subtitles,  the lastFocusedAudioIndex could bug out when we changed source
2026-03-18 12:30:32 +01:00
HALİL İBRAHİM ÖZDEMİR
80ca675a2e fix: move saveWatchProgress outside buffering guard 2026-03-18 14:27:16 +03:00
HALİL İBRAHİM ÖZDEMİR
b1dc6809c0 fix: restore buffering guard on scrobble stop and save progress 2026-03-18 14:26:02 +03:00
HALİL İBRAHİM ÖZDEMİR
bd433f291f fix: add DvTolerantVideoRenderer to prevent micro-stutter on Realtek DV decoders
The OMX.realtek.video.dvhe.st.decoder on Realtek RTD6748 (and likely
other Realtek chipsets) produces decoded frames at an uneven rate
(19-22 QIB/s instead of 24/s) with out-of-order timestamps.

This causes ExoPlayer's video renderer to report not-ready during
brief decoder stalls (~100-200ms), triggering rapid STATE_READY to
STATE_BUFFERING transitions 5-10 times per second, resulting in
visible playback stuttering.

DvTolerantVideoRenderer wraps the video renderer via ForwardingRenderer
and overrides isReady() to return true if a frame was rendered within
the last 500ms. This absorbs micro-stalls while still surfacing
legitimate buffering (>500ms).

Also fixes a duplicate string resource in values-sl/strings.xml.
2026-03-18 13:59:34 +03:00
skoruppa
b95cf49705 Don't clear selection when there are still not tracks 2026-03-18 11:51:30 +01:00
skoruppa
100d86cf32 Use addonName instead of exact url 2026-03-18 10:40:30 +01:00
skoruppa
b4812003b3 Better matching 2026-03-18 10:25:30 +01:00
skoruppa
d5a85ef187 exact match 2026-03-18 10:10:42 +01:00
skoruppa
4206c507c6 Fix for duplicate in sl strings 2026-03-18 09:55:48 +01:00
skoruppa
574db801f4 Fix race condition & cleanup 2026-03-18 09:46:53 +01:00
Nayif
915a95f29b
Merge pull request #848 from klemenko1/dev
Update strings.xml with latest Slovenian localization v0.51
2026-03-18 07:47:03 +05:30
Nayif
6f221b213e
Merge pull request #850 from Joe00011/dev
Update Japanese translation
2026-03-18 07:46:49 +05:30
Nayif
79c6245dfb
Merge pull request #853 from vhtuan288/dev
Update Vietnamese translation
2026-03-18 07:46:36 +05:30
Nayif
f0a13c4176
Merge pull request #854 from mindgam3s/dev-german
German translation: improved wording and adjusted to fit onto screen completely
2026-03-18 07:46:23 +05:30
Nayif
b65853a643
Merge pull request #885 from affcosta7/patch-1
Update Portuguese (Portugal) strings.xml - added 300+ missing strings
2026-03-18 07:46:11 +05:30
Nayif
ac5375d338
Merge pull request #872 from skoruppa/fixes 2026-03-18 05:40:27 +05:30
Nayif
fa0baf3145
Merge pull request #894 from skoruppa/persistent-track-preference
Remember audio & subtitle track selection persistently
2026-03-18 05:35:34 +05:30
skoruppa
d38e0e297c Don't fallback to not meta addons for meta details 2026-03-18 00:40:27 +01:00
skoruppa
40617fcf2e Fixes #906 - freeze clearlogo in ModernCarouselCard to prevent flickering when enriching
For horizontal posters in modern view, we freeze clearlogo. Enriched one will still be used in Hero section/Details
2026-03-18 00:24:21 +01:00
skoruppa
9f67f32bbf Fix for #902 - clear logo and addon logo can be a svg file. Added decoderFactory 2026-03-17 23:56:37 +01:00
skoruppa
17c2580fc5 Use translated countries in details view 2026-03-17 23:29:58 +01:00
skoruppa
0895c550f2 Fix for #905 - cast details were not downloaded in selected tmdb language
with fallback to english
2026-03-17 23:22:39 +01:00
skoruppa
a555c90290 Fix for #901 - writer and director should always have their roles forced 2026-03-17 23:01:29 +01:00
skoruppa
4414930c0d Full date on Modern View Hero and dot between year and runtime 2026-03-17 14:39:19 +01:00
skoruppa
065cd44e22 Always query tmdb for continue watching to ensure everything is translated 2026-03-17 12:39:42 +01:00
skoruppa
f359b92200 Last names refactor 2026-03-17 11:13:20 +01:00
skoruppa
4fbb237cbc More refactors 2026-03-17 11:11:04 +01:00
skoruppa
adcaf2a540 Refactor names 2026-03-17 11:09:12 +01:00
skoruppa
a9e450e4c3 It should be 36 (48-12) to be consistent 2026-03-17 10:21:19 +01:00
skoruppa
5cd626097e remember audio & subtitle track selection persistently
Track preferences are now saved per content item
2026-03-17 10:05:22 +01:00
skoruppa
934a6922ba Update CatalogSeeAllScreen.kt 2026-03-17 08:58:39 +01:00
skoruppa
bdf3b2a32d Make the left padding consistent between classic and grid view 2026-03-17 08:57:03 +01:00
skoruppa
3b9f1c4db5 Revert "Fix paddings in Grid View"
This reverts commit a8bc528f09.
2026-03-17 08:45:54 +01:00
skoruppa
a8bc528f09 Fix paddings in Grid View 2026-03-17 07:27:25 +01:00
skoruppa
579cb25cd7 A fix for unnecessary puddings on Grid View's Continue Watching and Hero Carousel 2026-03-17 07:08:27 +01:00
Nayif
41f406d134
Merge pull request #874 from halibiram/fix/player-more-actions-down-state
fix: clear stale More actions state when hiding controls
2026-03-17 07:28:00 +05:30
Nayif
ac5d9430cb
Merge pull request #890 from guuilp/codex/fix-player-paused-keep-screen-on
Avoid OLED burn-in when the player is paused
2026-03-17 07:21:57 +05:30
Guilherme Lima Pereira
328a6070de Fix player keepScreenOn to disable when paused 2026-03-16 20:31:42 -03:00
André Costa
643ac8a050
Update Portuguese (Portugal) strings.xml - added 300+ missing strings
Update Portuguese - Portugal translation for NuvioTV. Added and fixed translations for newly added keys to keep the localization up to date - added +300 missing strings.
2026-03-16 14:22:37 +00:00
DaN
adb9c13871
Update strings.xml
Test Internet Strimgs Translate
2026-03-16 10:43:23 -03:00
skoruppa
3cb72d3a05 Fixes #878 #875 #868 - better shuffle for hero carousel and taking into account posterCardWidthDp 2026-03-16 14:32:41 +01:00
skoruppa
73e97e7ce0 Fixes #867 - setting contentPadding just like on home
super!
2026-03-16 14:27:24 +01:00
mindgam3s
bc9dc9a245
Merge branch 'NuvioMedia:dev' into dev-german 2026-03-16 14:10:48 +01:00
HALİL İBRAHİM ÖZDEMİR
f2b14c6fae fix: clear more actions state when hiding controls 2026-03-16 14:25:35 +03:00
skoruppa
5eb6afbbce Fixes #781 - show full release date for movies on the details view 2026-03-16 11:24:35 +01:00
skoruppa
a5f8157572 Fixes #833 #592 - trailers will stop playing when application is not in foreground
A bit less complicated fix than #235
2026-03-16 09:17:38 +01:00
skoruppa
0de9cfa5a0 Fix for #861 - handle all possible runtime formats 2026-03-16 08:48:28 +01:00
tapframe
f5bb933ce8 bump version 2026-03-16 10:44:29 +05:30
Nayif
2a3b13d28a
Merge pull request #860 from skoruppa/fixes
batch of small bug fixes (#778, #851, #726, #806, #863, #866, #865, #864)
2026-03-16 10:21:01 +05:30
Nayif
5e87215591
Merge pull request #859 from skoruppa/subtitles-probe
Detect subtitle format via HEAD probe for extension-less URLs
2026-03-16 10:18:21 +05:30
Nayif
aa25b422aa
Merge pull request #841 from guuilp/increase-subtitle-addon-timeout
Increase subtitle addon timeout to 15s
2026-03-16 10:17:49 +05:30
tapframe
2bb5e68ebb Merge branch 'speed_test' into dev 2026-03-16 10:10:24 +05:30
tapframe
21b32d0d79 feat: advanced tab, moved network under advanced category 2026-03-16 10:10:09 +05:30
tapframe
b3d76b3578 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-16 09:51:38 +05:30
tapframe
71d834620d fix: lazy scrolling for selected season in SeasonTabs. fixes #836 2026-03-16 09:51:35 +05:30
Nayif
6e81e7ac79
Merge pull request #713 from SnaexCZ/SnaexCZ-patch-2
Adding a cz
2026-03-16 09:07:09 +05:30
tapframe
27f414124f fix: cw up next card refuse to render on warm start 2026-03-16 09:05:30 +05:30
skoruppa
0236753e72 Fixes #864 - add spacer under logo 2026-03-16 03:32:29 +01:00
skoruppa
adab60a848 Fixes #865 - apply same time formatting as on details screen 2026-03-16 03:27:49 +01:00
skoruppa
21a2bd6931 Fix for #866 - If there is nothing below "ratings", set a minHeight for it to prevent jumping 2026-03-16 03:24:28 +01:00
skoruppa
f54ee01dff Fix for #863 - and making correct gradient like in modern view
As I learned how to do a better gradients than bitmaps while doing modern view performance improvements I applied it here as well, as the stream screen was a place where this gradient looked visibly worse. No need to change on details screen - especially that from my testing it still behaves better
2026-03-16 03:04:51 +01:00
skoruppa
8889c654bd Use TMDB to supply translated Logo, Title and Description in CW not only as a fallback for artwork 2026-03-16 02:06:30 +01:00
Nayif
1e4ff97b7d
Merge pull request #686 from halibiram/feature/update-dialog-ui-enhancements
fix(ui): make update notes scrollable and refine dialog entrance (fixes #677 #762)
2026-03-16 06:28:19 +05:30
skoruppa
adcd333153 Fix for "Hero layout is showing items from only one catalogue" reported on discord
Shuffling everything before taking 7 resolves the problem and adds hero section more dynamic
2026-03-16 01:37:59 +01:00
DaN
f01fed2a6e
Update strings.xml
Add new lines
2026-03-15 21:25:08 -03:00
skoruppa
9c9601f25e Fix for #806 - logo is now displayed also with backdropExpanded 2026-03-16 01:22:35 +01:00
skoruppa
ed932d9a1d Fix #726 - trailer will now play once. After opening details page again, it will play again
It is always possible to open a trailer using the button
2026-03-16 01:21:34 +01:00
skoruppa
ce8aa1703c Fix for #851 - used FlowRow to let avatars categories be in another row 2026-03-16 01:19:19 +01:00
skoruppa
a89175d2a1 Fix for #778 - moved modern menu pill to the left, just like expanded menu 2026-03-16 01:18:19 +01:00
tapframe
d1bc1bfbf7 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-16 05:24:30 +05:30
Nayif
7288697edb
Merge pull request #858 from kernexshadow/fix/playback-ipv4-dns-consistency
Fix playback HTTP 403 regression by aligning player DNS behavior with IPv4 fallback policy
2026-03-16 05:22:42 +05:30
skoruppa
8bb693633e feat: detect subtitle format via HEAD probe for extension-less URLs
- Add SubtitleFormatUtils to core/player as single source of truth
  for format detection (extension-based + HEAD Content-Type probe)
- Add format field to SubtitleItemDto and Subtitle model
- SubtitleRepositoryImpl resolves format at fetch time:
  dto.format -> extension -> HEAD probe
- PlayerSubtitleUtils.mimeTypeFromUrl uses pre-resolved format,
  no longer inspects URL itself
2026-03-16 00:03:14 +01:00
kernexshadow
105c7ac98d Align player datasource DNS with IPv4 fallback policy 2026-03-15 21:52:40 +00:00
natan
c7b1b23652 show network badge 2026-03-15 22:56:34 +02:00
natan
58475f02a0 network speed test 2026-03-15 22:31:00 +02:00
tapframe
00c1e3e991 fix: add returnToHomeOnBack navigation argument for detail and stream screens (Fixes retun to homescreen loop when playback is opened from CW section) 2026-03-16 01:06:16 +05:30
tapframe
e34bb018de fix: regression where movies not picking up watch progress - trakt 2026-03-16 00:27:33 +05:30
mindgam3s
cfd386e301
renamed "Einstellungen" in sidebar to "Optionen" as workaround for #843
renamed "Einstellungen" in sidebar to "Optionen" as workaround for #843
2026-03-15 17:34:04 +01:00
Tuan Vu
45aac72cec
Merge branch 'NuvioMedia:dev' into dev 2026-03-15 23:30:40 +07:00
Tuan Vu
ab1d6eac44
align vietnamese translation with mobile app 2026-03-15 23:30:24 +07:00
mindgam3s
45e38cdb7d
improved wording and adjusted to fit onto screen completely
Updated German translations for various strings in the app.
improved wording and adjusted to fit onto screen completely
2026-03-15 17:23:45 +01:00
Joe00011
3a9fd9cb54
Update strings.xml 2026-03-15 21:17:38 +09:00
Joe00011
285307a5c3
Update strings.xml 2026-03-15 20:17:42 +09:00
klemenko1
b4cd9eb129
Add files via upload 2026-03-15 06:57:14 +01:00
tapframe
a837c12481 Stream Repo to show addon level failure messages on stream fetch 2026-03-15 10:05:14 +05:30
tapframe
69df218b40 fix: local cw removal not taking effect on cloud 2026-03-15 09:39:59 +05:30
tapframe
4ce9d4de08 fix: edge case where adjusting audio amplification losing focus 2026-03-15 09:30:30 +05:30
tapframe
d794de6256 Add episode title handling in remapping functions and enhance episode progress resolution 2026-03-15 09:15:38 +05:30
SK-Gits
18bcef8cf6 style: synchronize values-fr/strings with values/strings and full proofreading 2026-03-15 03:05:23 +01:00
SnaexCZ
d5b5d3aa85
Merge branch 'dev' into SnaexCZ-patch-2 2026-03-14 22:06:50 +01:00
Guilherme Lima Pereira
9cef4bc233 Increase subtitle addon timeout to 15s 2026-03-14 13:55:18 -03:00
Nayif
f5b187c816
Merge pull request #837 from riddhibhavsar01/dev
Profile creation focus fix
2026-03-14 21:36:43 +05:30
tapframe
3c595231bb update github actions 2026-03-14 21:30:59 +05:30
Riddhi B
a8beff0dc7 Profile creation focus fix 2026-03-14 20:39:49 +05:30
tapframe
8e7d5bd0bb reuse last link to update cached link if user changed source within player 2026-03-14 19:37:46 +05:30
tapframe
b1082c7042 percentage for trakt cw as it doesnt return total duration 2026-03-14 19:27:13 +05:30
SK-Gits
e99bafac70 Translation: update French strings 2026-03-14 10:20:23 +01:00
tapframe
7cfe4d6853 update script to include --version-code 2026-03-14 14:42:01 +05:30
CrissZollo
6719a634ab
Merge branch 'dev' of github.com:NuvioMedia/NuvioTV into dev 2026-03-14 10:02:24 +01:00
tapframe
e66a9801c8 version bump 2026-03-14 14:31:55 +05:30
CrissZollo
e7a8b088eb
Merge branch 'dev' of github.com:NuvioMedia/NuvioTV into dev 2026-03-14 10:01:12 +01:00
tapframe
12d01db96a
Support custom tag, title, and commit message in release script 2026-03-14 10:01:00 +01:00
CrissZollo
69af0a5a64
Fixes #651, Adds fallback to ipv4 if ipv6 is not reachable or hit a timeout 2026-03-14 10:00:25 +01:00
The-cpu-max
2f745e4002
Integrate complete Hebrew translation from silentbil
This commit integrates the full Hebrew translation from @silentbil's PR #830.

All credit for the translation work goes to @silentbil.
Replaced partial translation with complete version covering all strings.

Co-authored-by: silentbil <silentbil@users.noreply.github.com>
2026-03-14 10:53:25 +02:00
tapframe
a0ce14a49c Fix optional inputs in beta release workflow 2026-03-14 14:04:27 +05:30
The-cpu-max
cfa3e19b6f
Fix Hebrew locale tag syntax error
Fixed syntax error where 'he' was incorrectly merged with 'sv' as ', "he"sv"' instead of being a separate element ', "sv", "he"'
2026-03-14 10:30:23 +02:00
tapframe
3e37443a66 Support custom tag, title, and commit message in release script 2026-03-14 13:58:34 +05:30
The-cpu-max
989792175b
Add Hebrew (he) translation - values-he/strings.xml
This is a partial Hebrew translation covering the main UI strings. Community contributions to complete the full translation are welcome.
2026-03-14 10:17:35 +02:00
The-cpu-max
84d2395783
Add Hebrew (he) to supported locales 2026-03-14 10:13:40 +02:00
The-cpu-max
d61200f6a5
Add Hebrew (he) to locale_config.xml 2026-03-14 10:11:29 +02:00
tapframe
805b5e5eb1 Add custom beta release notes override 2026-03-14 13:30:16 +05:30
tapframe
9432c362fb Support local properties secrets in beta release workflow 2026-03-14 13:29:45 +05:30
tapframe
cd7c6541fd Add CI signing fallback for beta releases 2026-03-14 13:29:45 +05:30
tapframe
c853a70db3 Fix version replacement in beta release script 2026-03-14 13:29:45 +05:30
tapframe
756447b551 Add beta release automation 2026-03-14 13:29:39 +05:30
tapframe
b51b638d53 update media3 version to 1.10.0-rc01 2026-03-14 09:24:21 +05:30
tapframe
15b23afe4b Move startup and Trakt progress sync calls from onStart() to onResume(). 2026-03-14 09:14:38 +05:30
tapframe
ac2a0e8693 feat: homelayouts to respect addon showInHome param 2026-03-14 07:36:50 +05:30
Nayif
6f4221a303
Merge pull request #790 from skoruppa/modern-performance-v2
Performance Improvements & Trailers fix
2026-03-14 06:10:57 +05:30
skoruppa
ef6e095557 add baselineProfile 2026-03-14 01:20:19 +01:00
skoruppa
4bfc0d7d42 Update ModernHomeRows.kt 2026-03-14 00:49:13 +01:00
skoruppa
69061dc1a2 Group Fixes - crossfade, trailers, vertical scrolling lag
- Replace Crossfade with simple fade
- Apply trailer patch from @CrissZollo
- Apply stuttering patch form @halibiram
2026-03-14 00:04:40 +01:00
CrissZollo
154a4bd278
Fixes #644, Adds support for more than 1000 syncing with Nuvio account + added debug library adding tool 2026-03-13 22:20:05 +01:00
skoruppa
b811699d6e Merge conflict 2026-03-13 22:02:15 +01:00
skoruppa
c79ef934f3 Merge branch 'modern-performance-v2' of https://github.com/skoruppa/NuvioTV into modern-performance-v2 2026-03-13 21:50:15 +01:00
skoruppa
f47ed89adb Fix back stack loop and trailer autoplay in modern hero 2026-03-13 21:50:12 +01:00
CrissZollo
cf2523ee85
Merge branch 'dev' of github.com:NuvioMedia/NuvioTV into dev 2026-03-13 21:20:59 +01:00
CrissZollo
5e346a1801
Fixes #143, with special IDs to trakt 2026-03-13 21:20:19 +01:00
tapframe
21ceab2154 feat: added ui state to display addon installation state 2026-03-14 01:28:42 +05:30
tapframe
c6b46f220f Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-14 01:20:50 +05:30
tapframe
4c3131d10c Merge branch 'cw' into dev - trakt 2026-03-14 01:20:46 +05:30
tapframe
56f7067bf1 ref: streamline progress flow handling (trakt) 2026-03-14 01:17:25 +05:30
tapframe
b00865ff2b feat: addition of toast notification auth status on app start 2026-03-14 00:59:57 +05:30
CrissZollo
24a3ee072d
Merge Swedish translation update and corrections 2026-03-13 20:25:49 +01:00
CrissZollo
66cfe5f604
Update swedish translation 2026-03-13 20:25:21 +01:00
tapframe
78d1162f21 fix: trakt cw to prefer local first 2026-03-14 00:48:54 +05:30
skoruppa
4ed2259ab4
Merge branch 'dev' into modern-performance-v2 2026-03-13 20:10:06 +01:00
tapframe
3d87c37025 ref: replace percentage with time remaining cw section 2026-03-14 00:31:21 +05:30
tapframe
6f4d9f7713 updating focus management - audiooverlay 2026-03-14 00:20:22 +05:30
tapframe
d7b9fd457b Merge branch 'substyle' into dev 2026-03-14 00:00:47 +05:30
tapframe
80353d6526 adjusted subtitle overlay ui 2026-03-14 00:00:12 +05:30
CrissZollo
6cdeb3973b
XML fix: Added closing </string> tags to 32 unclosed string entries in values-de/strings.xml 2026-03-13 18:29:25 +01:00
HALİL İBRAHİM ÖZDEMİR
e2a662100f fix(ui): set default focus to continue button on initial layout selection screen 2026-03-13 20:16:27 +03:00
skoruppa
afc05aa834 Small fix for navigation 2026-03-13 13:56:08 +01:00
DaN
1bd57943ae
Update strings.xml
Update Pt-Br Stings
2026-03-13 09:32:11 -03:00
skoruppa
0625d69c4e Add offscreen composition for heroBackdrop 2026-03-13 11:58:46 +01:00
skoruppa
0d0d3eb762 Added focusGroup and offscreen compositing for gradients 2026-03-13 11:15:39 +01:00
skoruppa
5b7733a0ac Fix stale closure and recomposition
- Fixed a bug where row focus tracking always thought the active row changed, causing unnecessary recompositions on every D-pad press
- LazyColumn no longer recomposes on every focus event during fast vertical scroll
2026-03-13 10:49:44 +01:00
skoruppa
3e8fc3432b One more place with moving watched Icon 2026-03-13 08:46:17 +01:00
skoruppa
fd6f3b716e Remove unnecessary Watched Icon padding animation on focus 2026-03-13 02:56:17 +01:00
skoruppa
28621730bc Trying do reduce even more recompositions
- Reduced unnecessary recompositions during navigation
- Hero backdrop now updates with proper debounce instead of on every
  key press
- Returning from sidebar now reliably restores focus to the correct
  row and card
- Fixed initial focus sometimes landing in the wrong place on cold start
2026-03-13 02:43:50 +01:00
Nayif
d3888b9b08
Merge pull request #769 from mindgam3s/dev-german
FEAT: added German translation
2026-03-13 05:47:23 +05:30
Nayif
cbdf3c924a
Merge pull request #772 from aicon/dev
Update string.xml, Update Hungarian translation (Trakt, Profiles, Set…
2026-03-13 05:47:04 +05:30
Nayif
ecc1c69b73
Merge pull request #779 from afda1/feature/lt-translation
feat: add Lithuanian localization
2026-03-13 05:46:46 +05:30
tapframe
7a67ea27b6 update issue reporting title rule 2026-03-13 05:37:57 +05:30
tapframe
cd34d3ae38 close unlabeled issues workflow. 2026-03-13 05:22:32 +05:30
skoruppa
0ef7a7a92e Fix unstable keys in LaunchedEffect and remember on home rows
- Removed payload from focus debounce effect keys
- Replaced list references with list size as effect/remember keys
- Prefetch now reads the latest row items without restarting the effect on each change
2026-03-13 00:10:58 +01:00
skoruppa
942053b770 Improve YouTube extractor reliability by CDN probing
Not a performance fix, but found during testing ;)
2026-03-13 00:01:05 +01:00
Nayif
43fbbc99dd
Merge pull request #780 from kernexshadow/amplification-upstream-dev-port 2026-03-13 03:16:06 +05:30
kernexshadow
0577a71b79 player: place audio card below header without moving header 2026-03-12 19:11:46 +00:00
kernexshadow
58c92a7b29 wip: top-align audio overlay content 2026-03-12 15:57:17 +00:00
skoruppa
92938e4297 Checkmark in one place, and update JankStats
I'm also leaving my banchmark profile in build.gradle
2026-03-12 13:13:56 +01:00
skoruppa
0f027eff17 Get rid of unnecessary focus restorer 2026-03-12 11:56:59 +01:00
skoruppa
eef89ef0d3 Add debounce to Continue Watching cards
- Continue Watching cards now use 140ms focus debounce matching catalog cards
- Prevents callback spam during rapid horizontal navigation in CW row
2026-03-12 11:45:51 +01:00
skoruppa
94a6914ac1 Optimize vertical navigation performance
- Row title text properties wrapped in remember to prevent recomposition on focus changes
- Simplified focusRestorer logic to avoid reading LazyListState during snapshot
2026-03-12 11:39:10 +01:00
skoruppa
d641fa8ab6 Optimize Continue Watching card rendering
- Continue Watching card gradients now use drawWithCache instead of pre-computed brushes
- Added CompositingStrategy.Offscreen to CW card image layer for better compositing
- Wrapped CW card computed values in remember blocks to prevent unnecessary recompositions
- Extended image prefetch to support Continue Watching cards in Modern home rows
- Stabilized onClick/onLongPress lambdas in CW row items to prevent recreations
2026-03-12 11:24:41 +01:00
skoruppa
736dd96b5b Improve image loading performance on home screen
- Prefetch poster images ahead of scroll in Modern home rows
- Fix all-row recomposition when scroll stops by isolating isRowScrolling to backdrop expansion only
- Fix all-row recomposition on focus change by pre-computing isBackdropExpanded per card
- Replace deprecated LocalImageLoader with context.imageLoader
- Bump metrics-performance to 1.0.0-rc01 to fix FrameMetricsAggregator crash
2026-03-12 09:18:37 +01:00
SnaexCZ
819eb24d97
update strings.xml for cs language 2026-03-12 09:15:13 +01:00
SnaexCZ
3f4dd7b48d
Add Hindi locale and restore Vietnamese locale 2026-03-12 08:40:12 +01:00
skoruppa
786bc8c2ff More recomposition optimization - stable lambdas
- Home screen lambdas stabilized to prevent cascading recompositions on state change
- Watched status check no longer recreated on every recomposition in catalog rows
- JankStats state tagging added for scroll and hero enrichment tracking
2026-03-12 06:52:29 +01:00
skoruppa
ca7bd52faf Fix rendering lag, slow details loading, and background I/O violations
- Checkmark badge on cards redrawn as single element instead of stacked layers
- Addon manifest cache no longer read/written on the main thread at startup
- Plugin script files no longer read/written on the main thread
- Details page enrichment data now fetched in parallel instead of one by one
- Ratings and recommendations on details page load at the same time instead of sequentially
2026-03-12 06:38:19 +01:00
SnaexCZ
b03d2f6bbb
Add Czech localization for app strings
Add Czech localization strings for various app features.
2026-03-11 21:54:05 +01:00
SnaexCZ
7b7c577950
Update supportedLocales to include 'cs' language tag 2026-03-11 21:41:42 +01:00
SnaexCZ
0fb949ef4e
Merge branch 'dev' into SnaexCZ-patch-2 2026-03-11 21:30:45 +01:00
afda1
62872fd11b feat: fully integrate lithuanian locale into system settings 2026-03-11 21:44:37 +02:00
afda1
2c16d0ff31 chore: refine lithuanian translations for context and grammar 2026-03-11 21:05:25 +02:00
afda1
4c4623ccab feat: add Lithuanian localization 2026-03-11 20:58:11 +02:00
DaN
2e2555b2b0
Update strings.xml
Ajustes
2026-03-11 15:01:06 -03:00
kernexshadow
5ba13f7fbc feat(player): port PCM amplification to upstream audio overlay 2026-03-11 16:37:34 +00:00
HALİL İBRAHİM ÖZDEMİR
38baa9012d feat: Always show changelog in update dialog, even if up-to-date 2026-03-11 18:26:46 +03:00
HALİL İBRAHİM ÖZDEMİR
6ec7893c79 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into feature/update-dialog-ui-enhancements
# Conflicts:
#	app/src/main/java/com/nuvio/tv/updater/ui/UpdatePromptDialog.kt
#	app/src/main/res/values/strings.xml
2026-03-11 17:50:05 +03:00
aicon
e463a53d30
Update string.xml, Update Hungarian translation (Trakt, Profiles, Settings, and refinements
## Summary
This pull request continues the localization effort for the Hungarian language by adding missing translations for newly introduced app features and settings. It also revises existing strings to ensure consistency, clarity, and a better user experience across the application.
## Why
- Translated a large set of new strings covering Trakt integration, Profile management, Addon/Plugin managers, and advanced Playback/Subtitle settings.
- Reviewed and refined current Hungarian strings for grammar, style, and clarity (e.g., standardizing terminology like "watchlist" to "várólista" and using user-friendly terms for timeout settings).
- Improved consistency in terminology and formatting (ensuring strict and consistent XML indentation).
- Enhanced readability and the natural flow of UI text.
- Added new Hungarian strings to cover missing scenarios, ui elements, and error states.
## Policy check
- [x] This PR is not cosmetic-only, unless it is a translation PR.
- [x] This PR does not add a new major feature without prior approval.
- [x] This PR is small in scope and focused on one problem.
- [x] If this is a larger or directional change, I linked the issue where it was approved.
## Testing
Checked the file for structural errors, verified valid XML formatting (indentation), and performed manual verification in the UI.
## Screenshots / Video (UI changes only)
None. Not applicable for this change.
## Breaking changes
There are no breaking changes in this pull request. All modifications are additive or involve string refinements for the Hungarian locale.
## Linked issues
This pull request is not linked to any existing GitHub issues.
2026-03-11 13:33:01 +01:00
DaN
10db651384
Update strings.xml
Update 11/03/2026 Strimg.xml
2026-03-11 09:02:32 -03:00
mindgam3s
01c4248136
Update supported locales in ThemeSettingsScreen 2026-03-11 10:43:46 +01:00
mindgam3s
607e43686d
Add German locale to locale_config.xml 2026-03-11 10:42:15 +01:00
mindgam3s
1a6dcfc1fd
ADDED missing german translations (mostly translated using DeepL) 2026-03-11 10:41:01 +01:00
tapframe
f33a79003a refactor: remove unused dependencies from TraktViewModel 2026-03-11 14:31:48 +05:30
tapframe
7963477a97 bump version 2026-03-11 14:11:47 +05:30
tapframe
37efd67f3a chore: added ui gate to hide "Show hero section" togglw when active layout is "Modern" 2026-03-11 14:01:03 +05:30
tapframe
dbc77f3a73 Merge branch 'traktsync' into dev 2026-03-11 13:52:44 +05:30
tapframe
f836f162e0 perf: added back horizontal debounce to modernhomerows 2026-03-11 13:52:05 +05:30
tapframe
49012c452e feat: users can now choose watchporgress source when trakt is authenticated. 2026-03-11 13:38:28 +05:30
tapframe
716e210dbe ref: subtitle style rail to render on demand 2026-03-11 13:06:23 +05:30
tapframe
1127bce09f trakt to auto refresh token every 24 hours instead of retruned 'expires_in' 2026-03-11 12:11:21 +05:30
tapframe
962c4e2f7d Merge branch 'MIME' into dev 2026-03-11 11:53:00 +05:30
tapframe
343da88044 ref: proper MIMIE type detection before playback, fixes playback issues with addons like USATV 2026-03-11 11:52:45 +05:30
tapframe
3b3fe84f91 revert back to previous subtitle offset adjustment panel 2026-03-11 11:02:51 +05:30
tapframe
300619a675 audio/video modal scroll behaviour improvements 2026-03-11 10:54:11 +05:30
tapframe
f27183e56f bump version 2026-03-11 04:15:30 +05:30
tapframe
c4605c4930 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-11 04:09:30 +05:30
Nayif
3d09e2c031
Merge pull request #743 from skoruppa/modern-performance-v2
Modern View Performance Improvements
2026-03-11 04:09:23 +05:30
tapframe
2a23b0c711 minor sub overlay and loading overlay fixes 2026-03-11 04:06:22 +05:30
tapframe
6cf3f21e73 shared overlay optimizations 2026-03-11 04:01:36 +05:30
tapframe
47c83ea209 cleanup 2026-03-11 03:59:12 +05:30
tapframe
21678d4d13 improved trakt episode mapping during scrobble 2026-03-11 03:58:57 +05:30
tapframe
bd5058657b readdressed trakt anime mapping 2026-03-11 03:49:13 +05:30
tapframe
4500585c9e Revert "trakt 201 edge case fix"
This reverts commit 154905b5d5.
2026-03-11 03:31:15 +05:30
tapframe
bfff9f87a0 Revert "optimized trakt season lookup"
This reverts commit 10f0727f8c.
2026-03-11 03:31:10 +05:30
tapframe
48ee507e7e Revert "added kitsu mal id resolving for unsupported trakt id mapping"
This reverts commit d25fb6bc06.
2026-03-11 03:31:05 +05:30
tapframe
f0e7475320 Revert "trakt scrobble to accurately map wron episode format and mal,kitsu id whenever available"
This reverts commit ad117927ef.
2026-03-11 03:30:59 +05:30
skoruppa
e899529785 Missing translations 2026-03-10 22:57:04 +01:00
skoruppa
9a7ef3df12 Handle enrichment crossfade better 2026-03-10 22:45:58 +01:00
DaN
2c5b11ab6e
Update strings.xml
Comiit about Layout
2026-03-10 17:14:11 -03:00
tapframe
1ff1d6fed8 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-11 01:14:33 +05:30
tapframe
fb4c054e25 Merge branch 'playeroverlay' into dev 2026-03-11 01:12:12 +05:30
tapframe
9ac8f05507 refactored and removed toggles which are now unified 2026-03-11 01:09:07 +05:30
tapframe
f79267fe71 some UI adjustments 2026-03-11 00:53:09 +05:30
tapframe
0603b2952a revamped sub and audio selection modal 2026-03-11 00:19:27 +05:30
DaN
32892f530f Update strings.xml
Plural update.
2026-03-10 15:19:10 -03:00
tapframe
dc39fe7ae5 stream info overlay init 2026-03-10 22:58:24 +05:30
danilopagotto82
aa406dc87d
localization: refine Brazilian Portuguese strings for TV UI and term consistency
- Standardized all Brazilian Portuguese strings to a 52-character limit to prevent UI overflow on Android TV.
- Standardized terminology: used "addon" instead of "complemento" across all settings and profiles.
- Refined localization for Playback, Layout, MDBList, TMDB, and Anime Skip modules.
- Improved Trakt integration strings for better clarity during device authentication and history management.
- Polished Profile and Debug settings for a more intuitive user experience.
2026-03-10 12:27:55 -03:00
Nayif
701e8ad121
Merge pull request #741 from sk-doctor/Add-Hindi-(hi)-translation
Add Hindi Language
2026-03-10 20:24:21 +05:30
Nayif
26d5ff61c2
Merge pull request #586 from saimuelbr/fix/subtitle-tabs-layout
fix(ui): adjust subtitle tabs to be elastic and center aligned
2026-03-10 20:23:47 +05:30
skoruppa
d2672daf8a avoid recomposing cards when metadata enrichment arrives 2026-03-10 15:09:32 +01:00
Nayif
274ba74843
Merge pull request #740 from skoruppa/fixes
Fix for Back button loop from source selection #736
2026-03-10 19:09:01 +05:30
skoruppa
ac5b7dbb6b OVERRIDE_DEPRECATION 2026-03-10 13:22:49 +01:00
skoruppa
d9231f8668 Decouple hero gradient layer from animated media layer 2026-03-10 13:17:12 +01:00
skoruppa
8e049a982b Fix missing rowHorizontalPadding 2026-03-10 12:35:53 +01:00
skoruppa
a79a9cdf59 Further reduce recompositions in modern home view 2026-03-10 12:33:10 +01:00
skoruppa
c0a4cf43be Reduce unnecessary recompositions in modern home view 2026-03-10 12:30:57 +01:00
Skutterudite
6ddc73ef86 Add Hindi Language 2026-03-10 14:41:46 +05:30
skoruppa
9199e58565 use runCatching 2026-03-10 08:07:55 +01:00
skoruppa
c784e2a87d Don't forget to popBackStack 2026-03-10 07:56:05 +01:00
skoruppa
d90fa90908 Fix a back loop from stream after stream fails
Should fix #736
2026-03-10 07:46:46 +01:00
Nayif
3350b71313
Merge pull request #738 from skoruppa/fixes
fix next episode auto-play
2026-03-10 11:35:10 +05:30
tapframe
d6f3f83a80 Reapply "Merge pull request #294 from skoruppa/tmdb-enrich-focus"
This reverts commit 22e099cb09.
2026-03-10 10:35:23 +05:30
skoruppa
c0bf613e75 Translate the new WATCHED label 2026-03-10 05:18:29 +01:00
tapframe
066e9bc76e cw regression fix 2026-03-10 09:39:58 +05:30
skoruppa
5c1505abc3 fix next episode auto-play
- call timeoutElapsed when timeout is set to 0
- add returning series, cancelled, released, planned, rumored, in production, post production
- values-pt-rBR/strings.xml: fix malformed </string tag on play_manually
2026-03-10 04:58:16 +01:00
tapframe
22e099cb09 Revert "Merge pull request #294 from skoruppa/tmdb-enrich-focus"
This reverts commit 4d23349292, reversing
changes made to 450d7dbb08.
2026-03-10 05:54:02 +05:30
tapframe
7073a7763e implement adjacent item preloading tmdb enrichment 2026-03-10 04:21:48 +05:30
Nayif
654bb1860b
Merge pull request #618 from aicon/dev
Update strings.xml with latest Hungarian localization-05.03
2026-03-10 04:18:56 +05:30
Nayif
b52f2c84c3
Merge pull request #718 from danilopagotto82/Translate-Pt-br-Version-4.0
Update strings.xml with new profile translations (Pt-Br)
2026-03-10 04:17:03 +05:30
Nayif
ffb82944e4
Merge pull request #721 from halibiram/chore/tr-translations
fix(i18n/tr): re-target PR #656 to dev branch and complete missing UI translations
2026-03-10 04:16:48 +05:30
Nayif
f0bd18ac36
Merge pull request #719 from omavel/patch-2
Update Spanish (LatAm) translations related to #620
2026-03-10 04:16:28 +05:30
tapframe
76297f20a9 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-10 04:04:34 +05:30
Nayif
bc2dabb40a
Merge pull request #712 from skoruppa/fixes
back loop on episode end, auto stream selection priority, anime skip intro season mapping
2026-03-10 04:04:26 +05:30
Nayif
c5223cb8a8
Merge pull request #727 from kernexshadow/ssaFix
Fix Stabilize libass pipeline switching with Fast startup subtitles and ASS/SSA
2026-03-10 04:03:48 +05:30
tapframe
9ed0e28323 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-10 03:58:43 +05:30
Nayif
4d23349292
Merge pull request #294 from skoruppa/tmdb-enrich-focus
Update items metadata using tmdb enrichment on focus
2026-03-10 03:58:36 +05:30
tapframe
3fe20126fd Merge branch 'exo' into dev 2026-03-10 03:56:46 +05:30
tapframe
3066634385 flushing playback state after switching source. 2026-03-10 03:56:16 +05:30
tapframe
b6ce288a39 using default httpdatasource from exo 2026-03-10 03:53:37 +05:30
skoruppa
210855a81e Add "unlimited" option 2026-03-09 22:04:54 +01:00
skoruppa
2103b826dd setting always present 2026-03-09 21:44:58 +01:00
kernexshadow
159b4b7a87 Stabilize libass pipeline switching without affecting non-ASS streams
prevents reload loop in Fast startup when ASS/SSA addon subtitles are attached;

keeps libass sticky only after ASS/SSA is actually detected for the current stream;

resets ASS/SSA detection on stream change to avoid washed-out colors on content without ASS subtitles.
2026-03-09 20:21:19 +00:00
skoruppa
15b2605661 Shorter description 2026-03-09 20:08:46 +01:00
skoruppa
68860f147b Add a setting to control the new timeout 2026-03-09 20:05:46 +01:00
tapframe
450d7dbb08 Add methods to retrieve episode progress snapshots 2026-03-10 00:19:48 +05:30
tapframe
f01e4c5c3f adjust cw label 2026-03-10 00:05:17 +05:30
tapframe
113bbc0301 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-09 23:42:41 +05:30
tapframe
ad117927ef trakt scrobble to accurately map wron episode format and mal,kitsu id whenever available 2026-03-09 23:42:38 +05:30
tapframe
d25fb6bc06 added kitsu mal id resolving for unsupported trakt id mapping 2026-03-09 23:26:37 +05:30
tapframe
10f0727f8c optimized trakt season lookup 2026-03-09 23:19:35 +05:30
Nayif
8caf79e497
Merge pull request #703 from halibiram/fix/issue-471-dropdown-highlight 2026-03-09 23:16:26 +05:30
tapframe
154905b5d5 trakt 201 edge case fix 2026-03-09 23:00:28 +05:30
tapframe
e851474cd5 refactor: deduplicate episodes in EpisodesRow 2026-03-09 22:43:03 +05:30
tapframe
9f72d733b9 ref: continue watching functionality with caching 2026-03-09 21:00:40 +05:30
HALİL İBRAHİM ÖZDEMİR
23b47d60ad chore: add missing turkish translations 2026-03-09 18:15:51 +03:00
tapframe
6ca3aecbf0 modern sidebar focus fix 2026-03-09 20:21:09 +05:30
danilopagotto82
b7e4e797b9
Update strings.xml
New Strings with profille traslated
2026-03-09 10:33:15 -03:00
Omavel
38d9729216
Update Spanish (LatAm) translations related to #620 2026-03-09 10:33:11 -03:00
skoruppa
0faaae406c Sort languages 2026-03-09 14:31:52 +01:00
skoruppa
76bc13b084 Apply the same timeout logic to PlayerRuntime next episode stream selector 2026-03-09 13:44:51 +01:00
skoruppa
0d04239d3b Wait 5s or until first one returns success 2026-03-09 13:32:32 +01:00
skoruppa
89e00b59da Don't autopick source after the first addon returns streams, wait for other or timeout after 5s 2026-03-09 13:09:20 +01:00
skoruppa
fc5df74c52 Display hero content only after enrichment is over 2026-03-09 12:52:07 +01:00
skoruppa
1af109f706 Add rest of possible series statuses 2026-03-09 12:33:28 +01:00
skoruppa
82fa515906 feat: add logo to TMDB focus enrichment with crossfade, fade-in on title/description update 2026-03-09 10:18:15 +01:00
skoruppa
f0b6bdd211 fix: limit TMDB focus enrichment to name and description only 2026-03-09 10:18:15 +01:00
skoruppa
2a3b41e8c4 fix: prevent addon prefetch from overwriting TMDB focus enrichment 2026-03-09 10:18:15 +01:00
skoruppa
5f9dd12368 feat: enrich home screen items with TMDB on focus (metadata only, no artwork) 2026-03-09 10:18:15 +01:00
skoruppa
9c78ba7612 Add translation for ended, continuing, current, fix last back loop 2026-03-09 09:40:06 +01:00
skoruppa
592fdb2f92
Merge branch 'tapframe:dev' into fixes 2026-03-09 09:13:20 +01:00
HALİL İBRAHİM ÖZDEMİR
5a42af12d1 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into chore/tr-translations 2026-03-09 10:26:47 +03:00
tapframe
e0ed68adbd Merge branch 'main' of https://github.com/tapframe/NuvioTV 2026-03-09 12:12:14 +05:30
tapframe
f30e0238d2 fix progress observation to use traktProgressService 2026-03-09 12:10:40 +05:30
skoruppa
253447ef4b Merge branch 'fixes' of https://github.com/skoruppa/NuvioTV into fixes 2026-03-09 06:55:53 +01:00
tapframe
14f366079a remember last used aspecratio 2026-03-09 06:36:55 +05:30
tapframe
5d16093e0e bump v 2026-03-09 06:28:48 +05:30
tapframe
336f487b82 update cw card season/ep text color 2026-03-09 06:14:41 +05:30
tapframe
ed08bd31ce optimise sync attempts 2026-03-09 06:10:56 +05:30
tapframe
ec0ca82aae Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-09 05:20:07 +05:30
Nayif
3084583097
Merge pull request #679 from albyalex96/dev
Updated Italian Localization
2026-03-09 05:17:28 +05:30
HALİL İBRAHİM ÖZDEMİR
973321eeff ui: improve dropdown highlight and selection visibility
- Add selectedValue parameter to LibraryDropdownPicker and DiscoverDropdownPicker to track the currently active selection.
- Implement focusedOptionValue state to manage the visual highlight of focused items within dropdown menus.
- Update DropdownMenuItem styling with custom background and text colors for focused and selected states.
- Use NuvioColors.Secondary for focus highlights and NuvioColors.FocusBackground for selection state.
- Improve accessibility and user experience in TV interface navigation.
- Fixes #471
2026-03-09 01:49:31 +03:00
tapframe
30ac31e977 perf: added modern view catalog horizontal scroll debounce 2026-03-09 04:16:14 +05:30
tapframe
90e0ad552f cw section to only show trakt progress when trakt is authenticated, no local cache 2026-03-09 04:09:16 +05:30
tapframe
2069c5acd6 added sync info 2026-03-09 02:32:46 +05:30
tapframe
010543d74e modern home layout hero adjustments 2026-03-09 02:14:28 +05:30
tapframe
2216ca3278 change hydration limit trakt library 2026-03-09 01:47:42 +05:30
SnaexCZ
49c36e28ec
Add new cs locale in locale_config.xml 2026-03-08 21:02:56 +01:00
SnaexCZ
61c9c17e0b
Add Czech locale to locale_config.xml
Added Czech locale to the locale configuration.
2026-03-08 20:58:01 +01:00
Nayif
e10acf3078
Merge pull request #656 from halibiram/chore/tr-translations
feat(i18n/tr): add missing strings with turkish translations
2026-03-09 01:17:49 +05:30
Nayif
6cd8a6ac4d
Merge pull request #630 from saimuelbr/fix/hls-subtitle-sync-persistence
fix(player): resolve subtitle delay on HLS streams
2026-03-09 01:13:00 +05:30
Nayif
74ea366a36
Merge pull request #682 from skoruppa/fixes
fix correct anime skip intro season mapping and episode focus on return
2026-03-09 01:12:03 +05:30
Nayif
cf9c70d982
Merge pull request #676 from kernexshadow/libass
fix(player): restore libass ASS/SSA subtitle pipeline
2026-03-09 01:00:58 +05:30
HALİL İBRAHİM ÖZDEMİR
2eb0cf7077 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into feature/update-dialog-ui-enhancements 2026-03-08 18:42:34 +03:00
aicon
5df2994dfb
Update string.xml, feat(l10n): improve Hungarian terminology and UI strings
- Standardized "addon" to "bővítmény" and "plugin" to "beépülő modul"
- Standardized ellipsis characters to \u2026
- Fixed grammatical errors and compound words
- Improved overall phrasing for a better TV UI experience
2026-03-08 11:46:50 +01:00
HALİL İBRAHİM ÖZDEMİR
7c67e57d4b feat(ui): refine UpdatePromptDialog with entry animation, centered buttons, and scrollable notes (fixes #677) 2026-03-08 10:19:20 +03:00
tapframe
326a130ed8 Merge branch 'aiometa' into dev 2026-03-08 10:06:06 +05:30
tapframe
b0650369c7 cw section to only fetch from first meta addon 2026-03-08 10:05:08 +05:30
tapframe
260466e38e some ui changes homehero 2026-03-08 09:45:54 +05:30
skoruppa
57ab6b8349 Better mapping for anime seasons for skipIntroRepository 2026-03-08 01:42:42 +01:00
skoruppa
0f3158081f Last missing strings and title translation 2026-03-08 00:40:10 +01:00
kernexshadow
3c06e3584b fix(player): auto-disable libass pipeline when no ASS/SSA tracks
Rebuild player with default pipeline on streams without ASS/SSA text tracks to avoid GL color washout.

Keep user libass preference and restore libass automatically when ASS/SSA tracks are detected.
2026-03-07 23:34:01 +00:00
tapframe
f0e449af24 minor legacy sidebar adjustments 2026-03-07 23:34:01 +00:00
tapframe
86c0577cdc expanded legacy swidth adjustment 2026-03-07 23:34:01 +00:00
tapframe
45c8e65e75 adjust sidebar paddings 2026-03-07 23:34:01 +00:00
skoruppa
8972724ea1 Remove +1 for focus with completed episodes
With now current logic, this is no longer necessary. After episode ends user is either already on next episode streams or is returning from a next episode player
2026-03-08 00:23:11 +01:00
tapframe
a3dd075baa adding more meta schema support 2026-03-08 04:01:01 +05:30
tapframe
e880b0ffe4 minor legacy sidebar adjustments 2026-03-08 01:40:59 +05:30
tapframe
8e4e75858c expanded legacy swidth adjustment 2026-03-08 01:28:47 +05:30
tapframe
0585292479 adjust sidebar paddings 2026-03-08 01:26:46 +05:30
kernexshadow
c128545736
Merge branch 'dev' into libass 2026-03-07 19:42:31 +00:00
tapframe
23938a2cf4 add AvatarRepository 2026-03-08 00:57:35 +05:30
tapframe
5069a10256 added more strings 2026-03-08 00:55:21 +05:30
tapframe
e81aa146f3 update metaview errorstate 2026-03-08 00:35:40 +05:30
tapframe
5d15c1e514 skip now acts dynamically instead of hardcoded 100 2026-03-08 00:34:00 +05:30
cyberalby2
058a04662f Updated Italian Localization 2026-03-07 19:58:04 +01:00
kernexshadow
f96872e284 merge: sync upstream/dev into libass and keep libass subtitle pipeline 2026-03-07 18:39:01 +00:00
kernexshadow
67a3b96a52 fix(player): restore libass ASS/SSA subtitle pipeline
Add libass parser compat for Media3 cues codecs=text/x-ssa.

Wire parser only when libass is enabled and reset when disabled.

Keep subtitle settings/toggle and related strings aligned.
2026-03-07 18:12:40 +00:00
tapframe
a201f0e60a removed duplicates 2026-03-07 22:17:15 +05:30
Nayif
ef57cfe63f
Merge pull request #655 from skoruppa/missing-strinigs
sort language list alphabetically, localize episode titles in continue watching, add missing Polish translations
2026-03-07 22:14:41 +05:30
Nayif
7d7c373f61
Merge pull request #666 from klemenko1/dev
Update strings.xml with latest Slovenian localization
2026-03-07 22:13:34 +05:30
tapframe
fc43dac1da minor default buffer increase 2026-03-07 22:01:00 +05:30
tapframe
3ea6e8d249 made about screen to be scrollable. 2026-03-07 21:40:52 +05:30
tapframe
4146cd9a2a Merge branch 'observ' into dev 2026-03-07 21:30:47 +05:30
tapframe
5791bd0d55 feat: add functionality to observe watched movie IDs 2026-03-07 21:30:32 +05:30
tapframe
f40f10072c fix: fixes an issue where parsing vtt sub file urls ending with no extensions, addresses #671 2026-03-07 21:01:32 +05:30
Nayif
b93d085ff8
Merge pull request #669 from skoruppa/fixes
resolve back stack issues in autoplay navigation flow
2026-03-07 20:17:43 +05:30
JFeverish
f66e3b2b46 wip(player): use local nextlib-mediainfo aar and tune mpv subtitle baseline 2026-03-07 09:32:00 +00:00
aicon
55523d97c1
Update string.xml, fix XML syntax error and typos in Hungarian strings
Fix build-breaking XML tag and improve Hungarian translation

- Add missing closing tag for tmdb_credits_title - Fix minor typos (e.g., mmegnézve -> megnézve) - Improve compound words and grammar
2026-03-07 09:59:36 +01:00
skoruppa
9d7e060063 resolve back stack issues in autoplay navigation flow
- Fix player->detail loop when autoplay skips Stream screen by using
  popUpTo(Player) instead of popUpTo(Stream) which no longer exists
- Reuse existing Detail on back stack via savedStateHandle instead of
  navigating to a new instance, preventing duplicate Detail screens
- Apply same fix for normal back path when Stream is absent from stack
- Suppress next-to-watch scroll/focus when returning with returnFocusEpisode
2026-03-07 09:27:29 +01:00
aicon
3ae488609f
Update string.xml Added missing strings from the last translation update, minor fixes 07.03 2026-03-07 08:58:31 +01:00
klemenko1
98368a8eb1
Add files via upload 2026-03-07 08:44:52 +01:00
klemenko1
370f530497
Delete app/src/main/res/values-sl/strings.xml
update
2026-03-07 08:42:31 +01:00
aicon
29cc64852b
Merge branch 'tapframe:dev' into dev 2026-03-07 08:21:17 +01:00
Nayif
7da190fb1c
Merge pull request #661 from skoruppa/fixes
hotfix: reduce gradient overhead and fix stream back navigation focus
2026-03-07 07:59:42 +05:30
skoruppa
00d670bd8b remove debug messages 2026-03-07 02:41:26 +01:00
skoruppa
b9293f9ba3 Generate smaller gradient and use savedStateHandle for going back from a stream screen 2026-03-07 02:39:41 +01:00
tapframe
f28b15248a Revert "feat: Remove metadata hydration limit and implement a cooldown for failed metadata fetch"
This reverts commit 289b107f86.
2026-03-07 05:21:51 +05:30
tapframe
47e9a1838f Revert "fix: library and watchlist membership checks by normalizing content IDs and matching across multiple identifiers. fixes #614"
This reverts commit b31514e65e.
2026-03-07 05:21:51 +05:30
tapframe
2d6a689489 Revert "perf: coroutine cancellation.optimize addon manifest loading with shared flows"
This reverts commit 6e049fb02d.
2026-03-07 05:21:07 +05:30
tapframe
2e5ea476e1 Revert "feat: refactor movie watched status tracking to use a global set of watched IDs instead of individual observers."
This reverts commit a7e99ca6b9.
2026-03-07 05:17:41 +05:30
HALİL İBRAHİM ÖZDEMİR
51551af2d4 feat(i18n/tr): add missing strings with turkish translations 2026-03-07 02:41:58 +03:00
skoruppa
b3838f20ae Missing context 2026-03-07 00:24:42 +01:00
skoruppa
9f8ada3262 sort language list alphabetically, localize episode titles in continue watching, add missing Polish translations 2026-03-07 00:13:55 +01:00
tapframe
5f89d0cd55 version bump 2026-03-07 03:18:12 +05:30
tapframe
9eed208147 focus fixes profilescreen 2026-03-07 03:18:04 +05:30
tapframe
53586c27ad Merge branch 'missing-strinigs' into dev 2026-03-07 02:56:16 +05:30
tapframe
fae9e59dbb fixes subtitle not preloading when changing source in player , fixes #646 2026-03-07 02:09:51 +05:30
tapframe
5e533d965a Implement track selection restoration for same series playback fixes #648 2026-03-07 01:56:34 +05:30
tapframe
5594542de1 minor padding changes 2026-03-07 01:45:03 +05:30
tapframe
5c95b60214 Add avatar management features and update profile selection ui 2026-03-07 01:09:26 +05:30
tapframe
563303965d ui changes 2026-03-07 00:48:14 +05:30
tapframe
8146622824 avatars init 2026-03-06 23:15:06 +05:30
JFeverish
6352db3a48 Merge upstream/dev into mvp 2026-03-06 17:05:17 +00:00
tapframe
5385fdc230 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-06 21:14:00 +05:30
Nayif
cb795d4135
Merge pull request #620 from omavel/dev
New Changes - Add Latin American Spanish localization
2026-03-06 21:12:52 +05:30
tapframe
e81a06f057 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-06 21:11:24 +05:30
tapframe
daedada3e2 Merge branch 'LastpostNL/dev' into dev 2026-03-06 21:09:39 +05:30
tapframe
a630fdbe9f update template 2026-03-06 21:09:19 +05:30
Nayif
9c9f649867
Merge pull request #634 from vhtuan288/dev
Add Vietnamese localization
2026-03-06 21:08:16 +05:30
Nayif
45d0ede1be
Merge pull request #637 from skoruppa/main
apply dither to gradient bitmap - feels a bit smoother
2026-03-06 21:05:53 +05:30
Nayif
b7dd963bc7
Merge pull request #643 from skoruppa/fixes
Improve Smart player close navigation
2026-03-06 21:04:51 +05:30
tapframe
3f29325c7a minor template adjustment 2026-03-06 21:01:36 +05:30
tapframe
8d3163349c UPDATING PR RULES AND TEMPLATE 2026-03-06 20:34:12 +05:30
JFeverish
61a02bb36b Merge branch 'dev' of https://github.com/tapframe/NuvioTV into mvp 2026-03-06 14:16:56 +00:00
JFeverish
2eb9ab35d4 wip(player): prevent mpv double-start and stabilize subtitle selection 2026-03-06 14:09:47 +00:00
JFeverish
1df7ae9a77 wip(player): apply audio language preferences to mpv 2026-03-06 12:09:17 +00:00
skoruppa
44f53cd7ba Improve Smart player close navigation
Should fix #622
2026-03-06 12:43:56 +01:00
skoruppa
098f4443f5 Unify language code normalization: reuse LANGUAGE_OVERRIDES in PlayerSubtitleUtils 2026-03-06 11:45:15 +01:00
skoruppa
f46250b68a improve localization - missing strings and languages names
- Show region in subtitle language names (e.g. "Portuguese (Brazil)" instead of "Portuguese")
- Add ~60 missing ISO 639-2 language code mappings
- Translate generic "Episode N" titles using locale strings
- Now language names are localized everywhere (TMDB, subtitle and audio settings)
- Add missing Polish translations
- Remove unused `type_unknown` string from all locales
- Expand available language list with 35 additional languages
2026-03-06 11:28:03 +01:00
JFeverish
1054045f92 wip(player): keep mpv loading overlay until first frame 2026-03-06 10:22:38 +00:00
JFeverish
e3038e82a3 wip(player): apply mpv subtitle delay and keep it on seek 2026-03-06 09:37:36 +00:00
JFeverish
c412fb2d26 wip(player): map mpv subtitle colors and ASS style override 2026-03-06 09:18:45 +00:00
JFeverish
5d0a9eaf0a wip(player): apply subtitle style controls to mpv 2026-03-06 08:57:31 +00:00
tapframe
070a2e749c Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-06 13:53:53 +05:30
Nayif
d550e81d08
Merge pull request #623 from danilopagotto82/update-translation-V3-Hotfix
🚨 ATTENTION! Critical Translation Error – Immediate Hotfix Required (Pt-BR)
2026-03-06 13:53:41 +05:30
aicon
5af7095205
Merge branch 'tapframe:dev' into dev 2026-03-06 08:38:55 +01:00
aicon
f909b5e76d
Update strings.xml with latest Hungarian localization-06.03
Added missing strings from the last translation update, minor changes
2026-03-06 08:20:26 +01:00
tapframe
c295a1a300 update contributors screen ui 2026-03-06 12:01:06 +05:30
tapframe
6c7efb3299 added contributors section 2026-03-06 11:33:28 +05:30
tapframe
a924764eeb feat: add manual play option to continue watching section and metadetails screen 2026-03-06 10:27:03 +05:30
tapframe
2a32a03bdb update build gradle and tmdb api key 2026-03-06 10:03:49 +05:30
Tuan Vu
b787d8b658
Add Vietnamese localization 2026-03-06 09:58:48 +07:00
Nayif
fee5ec9534
Merge pull request #632 from skoruppa/main
Perrformance Improvement - Media Details and Streams Screen
2026-03-06 07:59:11 +05:30
tapframe
a7e99ca6b9 feat: refactor movie watched status tracking to use a global set of watched IDs instead of individual observers. 2026-03-06 07:37:59 +05:30
skoruppa
e326f35704 apply dither to gradient bitmap - feels a bit smoother 2026-03-06 02:38:46 +01:00
tapframe
6e049fb02d perf: coroutine cancellation.optimize addon manifest loading with shared flows 2026-03-06 07:04:25 +05:30
skoruppa
d4bcc0c07a replace Brush gradients with pre-rendered ImageBitmap
- Replace Brush.horizontalGradient/verticalGradient with 1px ImageBitmap rendered via android.graphics.LinearGradient in MetaDetailsScreen and StreamScreen - GPU does a single texture sample per pixel instead of computing gradient per frame
2026-03-06 02:23:44 +01:00
tapframe
b31514e65e fix: library and watchlist membership checks by normalizing content IDs and matching across multiple identifiers. fixes #614 2026-03-06 06:26:10 +05:30
tapframe
e09f7fb241 adjust rail width settingscreen 2026-03-06 06:13:28 +05:30
tapframe
289b107f86 feat: Remove metadata hydration limit and implement a cooldown for failed metadata fetch 2026-03-06 06:01:26 +05:30
skoruppa
fbbc9a22d6 Last touches
- Remove leftGradient and dimColor overlays from BackdropLayer
- Restore focusedScale from 1.0 to 1.02 on season tabs and episode cards
- Replace deprecated focusRestorer(lambda) with focusRestorer(FocusRequester)
- Replace deprecated Divider with HorizontalDivider
2026-03-06 01:25:44 +01:00
JFeverish
211c4c2acf wip: mpv player fix external addons and AFR 2026-03-05 22:54:03 +00:00
JFeverish
ef7e1b7448 wip(player): refine mpv track switching and build memory settings 2026-03-05 22:23:53 +00:00
skoruppa
10bce27a28 reduce GPU/UI thread overhead in EpisodesSection and MetaDetailsScreen
EpisodesSection:
- Set focusedScale=1.0f on EpisodeCard and SeasonTabs to eliminate
  graphicsLayer offscreen buffers created by TV Material3 scale animation
- Explicitly set glow=CardDefaults.glow() on EpisodeCard to disable
  shadow GPU pass
- Remove always-visible semi-transparent border (White 14%) from EpisodeCard
- Fix inline NuvioColors reads: hoist Primary/TextPrimary/TextSecondary/FocusRing
  before remember blocks and drawWithCache
- Use episode.id as remember key for lambdas instead of full episode object
- Wrap progress/imdbRating/isMarkedWatched in remember to avoid map lookups per frame
- Cache episodeOnFocusRestored in remember instead of inline lambda

MetaDetailsScreen:
- Add noVerticalScrollResponder (BringIntoViewResponder with no-op
  bringChildIntoView) on SeasonTabs and EpisodesRow items to prevent
  LazyColumn from triggering full vertical re-layout on every horizontal
  focus movement inside nested LazyRows
2026-03-05 23:18:30 +01:00
JFeverish
f553948788 wip(player): fix audio/embedded subtitles in Libmpv (track-list, aid/sid, sub-add) 2026-03-05 21:18:08 +00:00
saimuelbr
f02e1aac6a fix(player): resolve subtitle delay on HLS streams 2026-03-05 18:14:07 -03:00
Omavel
d4e52dff35
Merge branch 'tapframe:dev' into dev 2026-03-05 18:12:31 -03:00
tapframe
154daafc64 revert url probing in regex causing issues 2026-03-06 01:44:57 +05:30
JFeverish
ce7b9ca39e wip: integrate libmpv internal engine with existing player UI 2026-03-05 20:02:18 +00:00
skoruppa
b21402e321 reduce allocations and recompositions in EpisodesSection and SeasonTabs
- EpisodeCard: isFocused reads as State.value in drawWithCache to skip recomposition
- EpisodeCard: cache onClick/onLongPress/onFocused lambdas in remember
- EpisodeCard: wrap isWatched/shouldBlur/progressPercent/showProgress in remember
- EpisodeCard: hoist CardDefaults.shape/colors/border/scale and FocusRing before Card
- EpisodeCard: replace background+border with single drawWithCache pass
- SeasonTabs: hoist shape, border, scale, textStyle, textSecondary before LazyRow
- SeasonTabs: cache strCdWatched stringResource
2026-03-05 20:54:39 +01:00
LastpostNL
eac4b6bf0f
Merge branch 'tapframe:dev' into dev 2026-03-05 19:52:32 +01:00
LastpostNL
f0ecc71a4b
Update Dutch translations in strings.xml 2026-03-05 19:48:10 +01:00
LastpostNL
c60a1d5749
Add Dutch language support to ThemeSettingsScreen 2026-03-05 19:46:30 +01:00
LastpostNL
33d4fdfd21
Add Dutch locale to locale_config.xml 2026-03-05 19:45:00 +01:00
danilopagotto82
2868f111cc
Update strings.xml
Open/close translation adapted!
2026-03-05 15:02:14 -03:00
skoruppa
b160fc7110 eliminate scale animation and cache colors in EpisodeCard, add prefetch to CastSection
- EpisodeCard: focusedScale 1.02f -> 1.0f, cache borderColor and notStartedBadgeColor in remember
- CastSection: add LazyListPrefetchStrategy(nestedPrefetchItemCount=2)
2026-03-05 15:01:06 +01:00
skoruppa
496639e273 frame allocations in CastSection and EpisodesSection
- CastMemberItem: crossfade(false), remove redundant clip(CircleShape),
  cache 3x TextStyle in remember(typography)
- EpisodeCard: hoist badgeShape and progressBgColor before conditionals
  to fix conditional remember rule violation
2026-03-05 14:53:23 +01:00
skoruppa
16b144f9f0 reduce per-recomposition allocations in EpisodeCard
- cache all TextStyle.copy() calls in remember (episodeBadgeStyle, titleStyle,
  descriptionStyle, metaLabelStyle, ratingStyle) - was allocating new TextStyle
  on every recomposition per card
- cache Color.copy() calls in remember (badgeBgColor, progressBgColor)
- remove redundant .clip(shape) from inner Box - Card already clips via CardDefaults.shape
- replace 2x Box+clip progress bar with single drawWithCache drawRoundRect - eliminates
  two saveLayer calls per card per frame
2026-03-05 14:42:53 +01:00
danilopagotto82
f921aed3c8
Update strings.xml
Update Corretion addon to Plugins (hotfix)
2026-03-05 09:59:42 -03:00
skoruppa
bdd66c61af improve rendering performance in meta detail view
EpisodesSection:
- replace animateFloatAsState (overlay alpha) with direct value
- hoist imdbLogoRequest from per-card to EpisodesRow level
- wrap getOrPut in remember(episode.id) to prevent map mutation during composition
- wrap dashPathEffect in remember to avoid per-draw allocation

CastSection / MoreLikeThisSection / CollectionSection:
- wrap getOrPut in remember(key) to prevent map mutation during composition

MetaDetailsScreen:
- extract BackdropLayer composable to isolate animateFloatAsState from LazyColumn scope
- replace 3x Box+graphicsLayer gradient overlays with single drawWithCache pass
- read gradient alphas as State.value inside onDrawBehind to skip recomposition on alpha change
2026-03-05 13:49:20 +01:00
Omavel
6ae9b58726
New Changes - Add Latin American Spanish localization 2026-03-05 09:33:54 -03:00
JFeverish
ea22ba84f8 Add recent search history in Search screen 2026-03-05 12:29:20 +00:00
aicon
fea02b6dcb
Update strings.xml with latest Hungarian localization-05.03
Added missing strings from the last translation update
2026-03-05 10:36:01 +01:00
tapframe
66a02f306f Revert PR #507: Move addons into Settings and enforce profile-based access UI
This reverts only the changes introduced by PR #507 (commits 5b24848, aa4aa45, db7a37b) while preserving all other commits from the dev merge.
2026-03-05 06:24:24 +05:30
saimuelbr
f2fee2cb86 fix: resolve hyphen-based text clipping in subtitle dialog tabs + add the forget string 2026-03-04 18:33:16 -03:00
tapframe
7b55ac41cf Merge branch 'dev' 2026-03-05 02:49:56 +05:30
tapframe
d611fa2dcb bump version 2026-03-05 02:49:19 +05:30
Nayif
816c55e83d
Merge pull request #447 from ddgarciaf02/patch-1
Update Spanish translations
2026-03-05 02:37:56 +05:30
Nayif
2c44372146
Merge pull request #551 from halibiram/tr-strings-update
Add missing Turkish translations from #546 and fix incorrect translations
2026-03-05 02:37:19 +05:30
tapframe
be8c66e703 fix regex pattern regression. 2026-03-05 02:23:50 +05:30
saimuelbr
7164ec6d25 fix(ui): adjust subtitle tabs to be elastic and center aligned 2026-03-04 17:40:11 -03:00
tapframe
804b6e1bf2 watch progress sync and add functionality to retrieve watched episodes with timestamps 2026-03-05 01:24:22 +05:30
tapframe
c639bb2f0a fix for moern hero clipping 2026-03-04 22:40:54 +05:30
tapframe
ea6c320174 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-04 22:12:23 +05:30
tapframe
ff88c19e98 language code matching with 3-letter subtags. fixes #574 2026-03-04 22:12:14 +05:30
tapframe
6278d1b690 language code matching with 3-letter subtags. fixes #577 2026-03-04 22:09:16 +05:30
tapframe
f454578628 episode section jank optimization with drawWithCache 2026-03-04 22:01:35 +05:30
tapframe
e8ea6e7c30 use NonCancellable context for plugin execution in PluginManager 2026-03-04 21:35:18 +05:30
tapframe
0a3609593a Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-04 21:34:58 +05:30
Nayif
60c5319409
Merge pull request #552 from danilopagotto82/Update-translation-v3
Pull Request: Translation Review and Enhancement (pt-BR)
2026-03-04 21:23:53 +05:30
tapframe
004e9a8bbc fix for trakt scrobble race condition 2026-03-04 21:07:44 +05:30
tapframe
90d2eda161 plugins crash fix while fetching streams 2026-03-04 20:38:38 +05:30
Nayif
af82b68627
Merge pull request #567 from skoruppa/close-player
Smart player close navigation
2026-03-04 19:38:25 +05:30
danilopagotto82
e59dafc1b0
Update strings.xml
update Parental Control
2026-03-04 08:56:52 -03:00
tapframe
eec78dc4c1 Run needs-info triage only for bug issues 2026-03-04 09:51:44 +05:30
Nayif
a4abf2e669
update md 2026-03-04 09:46:54 +05:30
tapframe
a307f27d7c Add PR template and body validation workflow 2026-03-04 09:37:56 +05:30
tapframe
f3daafd432 Remove input method from bug form 2026-03-04 09:33:05 +05:30
HALİL İBRAHİM ÖZDEMİR
7dad7b3ba4 fix: use app-selected locale for NanoHTTPD web config pages instead of device language 2026-03-04 06:58:33 +03:00
tapframe
c92d9f92ca Add issue triage and stale needs-info workflows 2026-03-04 09:04:31 +05:30
tapframe
251ef57ce5 Make issue forms more readable 2026-03-04 08:59:31 +05:30
tapframe
dc310507cf Add GitHub issue forms 2026-03-04 08:51:44 +05:30
Nayif
4c75909ba8
Merge pull request #559 from omavel/dev
Update LatAm Spanish localization for new features
2026-03-04 08:33:11 +05:30
tapframe
5c19877e70 Merge branch 'enhance-seek-test' into dev 2026-03-04 08:31:56 +05:30
tapframe
ddfe629409 fixed not focusing to intro/outro buttons when controls are visible 2026-03-04 08:31:45 +05:30
tapframe
766ed2c7b6 Fix where next episodes keep getting marked as watched even before fully completing. 2026-03-04 08:26:03 +05:30
tapframe
89d4c69cb1 using primary colour when focusing seekbar 2026-03-04 08:17:52 +05:30
Nayif
6b4d384cd1
Merge pull request #560 from albyalex96/dev
Add Italian parental guidance strings
2026-03-04 08:01:07 +05:30
Nayif
3475535815
Merge pull request #562 from saimuelbr/dev
Add pt-BR ParentalGuideOverlay strings
2026-03-04 08:00:02 +05:30
tapframe
848c12debe Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-04 07:59:38 +05:30
Nayif
0a26b4adc8
Merge pull request #546 from skoruppa/main
Add missing strings and translations, validations for api keys and hotfix for trailers overwritten by external meta and subtitles
2026-03-04 07:59:15 +05:30
saimuelbr
96b414dc8b feat(player): implement better style seek and focus management 2026-03-03 22:22:57 -03:00
skoruppa
8316e5812a Missing polish translations 2026-03-04 02:12:09 +01:00
skoruppa
f6063f6a09 Restore build.gradle.kts from upstream 2026-03-04 01:56:50 +01:00
skoruppa
7085feb1cd Fix back stack after playback end, show stream picker on episode error 2026-03-04 01:43:35 +01:00
skoruppa
a216d393e2 go to streams of next episode after playback ended 2026-03-04 01:38:14 +01:00
tapframe
4ca58a573f fix rootsurface to use theme bg 2026-03-04 05:41:50 +05:30
skoruppa
f81bfdfde7 Use system locale.getDisplayLanguage to display subtitles language names 2026-03-04 01:09:33 +01:00
skoruppa
96061bd6b8 Apply text overflow to sub tabs 2026-03-04 00:57:40 +01:00
skoruppa
bc0c7d5ae1 There is no need to normalize anything. Subtitle addons should always receive full currentVideoId 2026-03-04 00:20:38 +01:00
HALİL İBRAHİM ÖZDEMİR
ec03e16e57 fix(i18n): escape single quotes in JS web UI translated strings 2026-03-04 02:18:50 +03:00
skoruppa
9f1afe5c21 Save calculated videohash and videosize as well 2026-03-04 00:16:10 +01:00
skoruppa
c2e51e7b54 Save filename, videohash and videosize, to reuse them for subtitles addons and use currentVideoId 2026-03-04 00:07:52 +01:00
HALİL İBRAHİM ÖZDEMİR
c21e19f519 feat(i18n): add Turkish translations for web config pages 2026-03-04 01:47:26 +03:00
HALİL İBRAHİM ÖZDEMİR
f9c74c02a3 feat(i18n): make NanoHTTP server pages i18n compatible 2026-03-04 01:43:14 +03:00
saimuelbr
5089bfdd64 Add pt-BR ParentalGuideOverlay strings 2026-03-03 19:23:53 -03:00
albyalex96
5bf253969e
Add Italian parental guidance strings 2026-03-03 22:06:25 +01:00
Omavel
8deb539017
Update LatAm Spanish localization for new features
Synced the strings.xml file with the latest English base. Key additions include:

Anime Skip: Added translations for intro/outro skip timestamps.

Trakt Integration: Added strings for the new QR login and approval states.

Subtitles: Translated advanced rendering options, startup modes, and styling preferences (colors, outlines, libass).

Playback: Added missing strings for aspect ratio, playback speed, and OSD clock.

Layout & Library: Translated new UI options like TMDB collections, hiding unreleased content, and episode management markers.

Fixed a missing translation for "Home Content".
2026-03-03 17:11:58 -03:00
tapframe
367f5b1e78 Merge branch 'pagination' into dev 2026-03-03 23:29:24 +05:30
tapframe
820937d35a fix for addon paginations 2026-03-03 23:27:32 +05:30
Nayif
843de0848d
Merge pull request #545 from craitose/regex-regression
Fix: Pixeldrain resolver removed; autoplay now includes it
2026-03-03 23:02:23 +05:30
Nuvio CI
db7a37b7e5 Merge origin/main into upstream-synced main preserving settings/profile toggle updates 2026-03-03 11:01:59 -05:00
Nuvio CI
aa4aa458c9 Unify settings toggles/chips across playback/profile and fix white-theme selected styling
# Conflicts:
#	app/src/main/java/com/nuvio/tv/MainActivity.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/settings/PlaybackSettingsScreen.kt
2026-03-03 10:53:51 -05:00
danilopagotto82
4ebc54a9bc
Update strings.xml
Update New strings pt-br
2026-03-03 12:45:29 -03:00
HALİL İBRAHİM ÖZDEMİR
cc74af4a4c Add missing Turkish strings and fix incorrect translations 2026-03-03 18:24:01 +03:00
Nuvio CI
5b24848820 Unify settings toggles/chips across playback/profile and fix white-theme selected styling 2026-03-03 10:10:47 -05:00
danilopagotto82
7a0c058fe8
Update strings.xml
Update Pt-br
2026-03-03 11:50:41 -03:00
LastpostNL
2de8e695ae
Update strings.xml 2026-03-03 15:34:00 +01:00
LastpostNL
9a3568a94f
Merge pull request #1 from LastpostNL/LastpostNL-dutch-translation
Created strings.xml for Dutch translations
2026-03-03 15:28:11 +01:00
LastpostNL
4c11c871b8
Created strings.xml for Dutch translations
Dutch translations for Nuvio TV app.
2026-03-03 15:25:04 +01:00
skoruppa
fd67a93bdf Update PlayerRuntimeControllerMetadata.kt 2026-03-03 15:20:14 +01:00
skoruppa
3af321c616 Parental Strings 2026-03-03 14:39:50 +01:00
skoruppa
a192189cba Add missing strings and translations, validations for api keys and hotfix for trailers overwritten by external meta 2026-03-03 14:33:21 +01:00
craitose
e91d9e46aa
Update StreamAutoPlaySelector.kt
updated runCatching
2026-03-03 13:23:24 +00:00
craitose
4903717335 Fix: Pixeldrain resolver removed; autoplay no longer skips Pixeldrain streams 2026-03-03 13:10:03 +00:00
tapframe
49e4936d15 bump version 2026-03-03 06:58:01 +05:30
tapframe
203a81051a removal of sentry 2026-03-03 06:55:03 +05:30
tapframe
740b8d7fef player controls to resume playback from pause overlay with single key press 2026-03-03 06:50:31 +05:30
tapframe
c407603df4 added 0 second to expanded poster for instant expansion 2026-03-03 06:44:19 +05:30
tapframe
e712251752 implement search suggestions from search catalogs 2026-03-03 06:41:18 +05:30
tapframe
d5575ced33 fix settingscreen focus issue 2026-03-03 06:01:36 +05:30
tapframe
95b3d50a34 update layout settings to conditionally display sections based on selected layout 2026-03-03 05:54:38 +05:30
tapframe
386948214a properly stops exoplayer before switching to new episode/changing source 2026-03-03 05:35:02 +05:30
Nayif
656ad3f0ed
Merge pull request #518 from JFeverish/ResolutionMatching
AFR: add optional resolution matching toggle and apply resolution-aware mode selection
2026-03-03 05:25:11 +05:30
Nayif
7eed2f4049
Merge pull request #502 from saimuelbr/dev
feat - implement intuitive subtitle delay with custom offset renderer
2026-03-03 05:23:38 +05:30
Nayif
0e99bb8673
Merge pull request #480 from SwoleRonson/smooth-loading-overlay
Fix performance issue with loading overlay
2026-03-03 05:22:24 +05:30
SwoleRonson
2ce4cab6aa Merge branch 'dev' into smooth-loading-overlay 2026-03-02 21:48:42 +00:00
tapframe
3d4e22dfee Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-03 02:08:14 +05:30
tapframe
2e217ccdd5 added logic to stop/pause trailer when sidebar is expanded 2026-03-03 02:08:09 +05:30
Nayif
3b84a1c5cf
Merge pull request #504 from craitose/regex-enhancement
Improved Empty Regex Skip Functionality
2026-03-03 01:44:15 +05:30
tapframe
55ffa02651 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-03 01:43:39 +05:30
Nayif
2c4129b999
Merge pull request #517 from skoruppa/trailers-from-meta
fallback to meta trailers if our search does not return anything
2026-03-03 01:43:31 +05:30
tapframe
fe9e323600 Merge branch 'fonts' into dev 2026-03-03 01:43:11 +05:30
tapframe
ba868ddd4f Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-03 01:41:53 +05:30
Nayif
96eafa2b46
Merge pull request #521 from danilopagotto82/Update-translation-v2
Update translation Pt-br for new string.xml
2026-03-03 01:40:57 +05:30
tapframe
7368d5b934 added new fonts 2026-03-03 01:39:31 +05:30
tapframe
c669438611 some styling changes 2026-03-03 01:13:32 +05:30
tapframe
ad9b03441d init, movie collections 2026-03-03 01:04:07 +05:30
danilopagotto82
64ee916965
Update strings.xml
Update Translate pt-br v2
2026-03-02 15:54:12 -03:00
tapframe
c2e5d4d54e fix for floating catalog row when scrolling all the way down 2026-03-02 23:40:45 +05:30
tapframe
0bcb8b3417 Merge branch 'dev' into fix/skip-intro-button-on-bottom 2026-03-02 23:33:48 +05:30
tapframe
eae7630942 properly enrich modern hero metadata from cw section 2026-03-02 23:32:13 +05:30
danilopagotto82
4d1501c6a3
Delete app/src/main/res/values-pt-rBR/StringV2
Delete file
2026-03-02 14:05:37 -03:00
rubkn
c412d98f13 refactor skip button to start on bottom and get pushed by controls 2026-03-02 15:59:29 +00:00
danilopagotto82
4560c59385
Create StringV2
Compare Strings
2026-03-02 12:30:38 -03:00
JFeverish
ba9e2d2484
Merge branch 'dev' into ResolutionMatching 2026-03-02 15:04:14 +00:00
tapframe
ab2e37f280 decoupled autoplay next episode and prefer binge group from autoplay stream 2026-03-02 20:24:16 +05:30
JFeverish
6fb618099d AFR: add optional resolution matching toggle and apply resolution-aware mode selection 2026-03-02 14:51:04 +00:00
tapframe
69414bfa8e improved progress resumption logic in metadetailmodel 2026-03-02 19:54:28 +05:30
skoruppa
6fffa900f6 Process fallback url 2026-03-02 15:05:39 +01:00
tapframe
959406932c Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-02 19:34:40 +05:30
Nayif
87bcf0763a
Merge pull request #441 from skoruppa/anime-skip
Add anime-skip.com integration with API key setting + Skip Button improvements
2026-03-02 19:34:32 +05:30
tapframe
ec6f7d1942 default value changed to all subs on stream load 2026-03-02 19:34:11 +05:30
skoruppa
a5c79e36a0
Merge branch 'dev' into anime-skip 2026-03-02 14:42:23 +01:00
Nayif
a1a7593cbe
Merge pull request #272 from rubkn/fix/265-subtitles-from-addon-triggers-player-reload
feat[#265]: add addon subtitle startup modes to surpass reloads on subtitle switch
2026-03-02 19:09:39 +05:30
skoruppa
1154066a6b don't close skipButton when controls are shown 2026-03-02 14:18:19 +01:00
skoruppa
9ac6d4681c Check only the first season. anime-skip is using either first season anilist id or the specific one 2026-03-02 14:15:27 +01:00
skoruppa
3c35e0aee4 Fixed more skip button behaviours so it feels more snapy 2026-03-02 14:15:27 +01:00
skoruppa
bbb3e4b1d9 More fixes for the button 2026-03-02 14:15:27 +01:00
skoruppa
a6e389f28a Improve skip button UX and fix anime-skip ID resolution
- Use videoId over contentId for skip interval and subtitle addon lookups
- Fix anime-skip fetching wrong season by preferring season-specific MAL ID
- Add 10s countdown progress bar to skip button with pause when controls open
- Skip button re-appears in controls if dismissed via back; hides on controls close
- Back press dismisses skip button only when controls are hidden
- Title info hides when skip button is actually visible (not just interval active)
- Iterate all AniList IDs from IMDB fallback instead of only first one
2026-03-02 14:15:27 +01:00
skoruppa
084cadc739 Add anime-skip.com integration with API key setting
- Add AnimeSkip GraphQL client with AniList ID resolution via ARM API
- Add AnimeSkipSettingsDataStore for storing client ID and enabled state
- Add AnimeSkipSettingsScreen and ViewModel
- Wire AnimeSkip into skip interval lookup for IMDB, MAL and Kitsu stream types
- Add strings for AnimeSkip settings (EN + PL)
2026-03-02 14:15:27 +01:00
skoruppa
04f7abc64c fallback to meta trailers if our search does not return anything 2026-03-02 14:05:58 +01:00
Nayif
5775af0c5d
Merge pull request #515 from skoruppa/next-episode-focus
change focus to nextToWatch season, and scroll to nextToWatch episode
2026-03-02 18:19:25 +05:30
skoruppa
de2a866369 change focus to nextToWatch season, and scroll to nextToWatch episode 2026-03-02 13:39:16 +01:00
JFeverish
86ab2ea3e3 AFR: Increase timeout probe because of slower streams 2026-03-02 12:28:29 +00:00
JFeverish
3e6fe621d3 AFR: Increase timeout probe because of slower streams 2026-03-02 12:28:29 +00:00
JFeverish
f267d7c509 Improve MP4 AFR triggering 2026-03-02 12:28:29 +00:00
rubkn
626a3cf8bd fix pre attached addon subtitles being excluded 2026-03-02 09:49:47 +00:00
rubkn
264a8812b4 feat: implement addon subtitle startup mode settings, allowing users to switch subtitles without reloading the player 2026-03-02 09:15:17 +00:00
rubkn
51b3c3d9c4 fix[#265]: track selection without reloading media, ensuring smoother addon subtitle switching 2026-03-02 09:14:06 +00:00
SwoleRonson
f38c2f5e00 Merge remote-tracking branch 'origin/dev' into smooth-loading-overlay 2026-03-02 08:21:54 +00:00
tapframe
5c5eae70ac added next episode button 2026-03-02 08:07:49 +05:30
tapframe
5902604f1c Merge branch 'Update-Spanish' into dev 2026-03-02 07:57:45 +05:30
tapframe
df92aaaf6f addition of secondary audio preferance
fixes #464
2026-03-02 07:48:06 +05:30
tapframe
ec754b73bd Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-02 06:19:16 +05:30
tapframe
f2d92d3668 update player exit handling , fixes #493 2026-03-02 06:18:43 +05:30
Nayif
1be31ca6a3
Merge pull request #369 from craitose/regex-autoplay-enhancement
enhanced regex auto-stream select
2026-03-02 06:04:37 +05:30
tapframe
15d9574e49 sanitizes malformed headers before passing to player fixes #408 2026-03-02 06:01:38 +05:30
tapframe
40fb0aac4c Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-02 05:37:27 +05:30
Nayif
cea835f1f6
Merge pull request #488 from aicon/dev
Update strings.xml with latest Hungarian localization
2026-03-02 05:18:59 +05:30
Nayif
6a73233ee7
Merge pull request #476 from JFeverish/AFRMp4NotBeingTriggered
AFR Fix: Mp4 and slow streams  files not triggering AFR.
2026-03-02 05:16:21 +05:30
craitose
24173dd669
Merge branch 'tapframe:dev' into regex-enhancement 2026-03-01 20:31:34 +00:00
craitose
02533553f7 trigger empty regex search skip at lower level 2026-03-01 20:31:01 +00:00
craitose
9e7aa92ca6
Merge branch 'tapframe:dev' into regex-autoplay-enhancement 2026-03-01 20:14:38 +00:00
saimuelbr
62cfb8c3c1 feat - implement intuitive subtitle delay with custom offset renderer 2026-03-01 16:34:29 -03:00
JFeverish
c47c306850 AFR: Increase timeout probe because of slower streams 2026-03-01 15:56:36 +00:00
JFeverish
9272025cd0 AFR: Increase timeout probe because of slower streams 2026-03-01 15:33:40 +00:00
tapframe
b04b609f1f update yt extractor 2026-03-01 21:00:22 +05:30
aicon
2e2ee3d2b8
Update strings.xml with latest Hungarian localization
Added missing strings from the last translation update
2026-03-01 15:18:21 +01:00
tapframe
7a16f487b9 afr to not trigger on trailerplayer 2026-03-01 18:02:11 +05:30
tapframe
eab69228f3 bump version 2026-03-01 17:53:33 +05:30
tapframe
a57832792a Add IMDb, TMDb, and Trakt IDs to LibraryEntry model and update TraktLibraryService 2026-03-01 17:47:03 +05:30
tapframe
13417f4786 changed hydration to 110 2026-03-01 17:37:51 +05:30
tapframe
cad47c5c3e fix trailer player to prevent throttling 2026-03-01 17:21:57 +05:30
SwoleRonson
4e1b2d654e Fix performance issue with loading overlay 2026-03-01 10:57:27 +00:00
JFeverish
ab796f2fa8 Improve MP4 AFR triggering 2026-03-01 10:34:40 +00:00
tapframe
aa2a448685 bump version 2026-03-01 11:01:12 +05:30
tapframe
fd3e793c48 handling false negative on success episde marking 2026-03-01 10:55:45 +05:30
tapframe
1c2e9f19d8 focus restore fix library screen 2026-03-01 10:52:58 +05:30
tapframe
d5feb3ad8e Merge branch 'smooth-loading-overlay' into dev 2026-03-01 10:09:12 +05:30
tapframe
aaf9e63ee9 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-03-01 10:03:06 +05:30
Nayif
9e7694c39a
Merge pull request #457 from JFeverish/AFRImprov
AFRImprove: Improve AFR detection and fix delayed activation
2026-03-01 10:02:58 +05:30
tapframe
5b49ef898f improved plugin runtime stability 2026-03-01 10:00:59 +05:30
Nayif
3a6b7d91e9
Merge pull request #461 from tapframe/trailer
Trailer - Addition of local trailer extractor.
2026-03-01 09:44:52 +05:30
tapframe
83e00c70d9 cleanup 2026-03-01 09:44:02 +05:30
tapframe
23232aaaff code cleanup 2026-03-01 09:35:53 +05:30
tapframe
505798a666 trailers are now localized 2026-03-01 09:27:44 +05:30
tapframe
88cd2f2694 added local extractor 2026-03-01 08:55:06 +05:30
tapframe
732363d66e init 2026-03-01 08:25:43 +05:30
JFeverish
b4c3a22420 AFRImprove: Improve AFR detection and fix delayed activation 2026-02-28 22:41:30 +00:00
JFeverish
7303a321d9 AFRImprove: Improve AFR detection and fix delayed activation 2026-02-28 22:35:00 +00:00
tapframe
df035b6d77 update exo version to 1.10.0-beta01 2026-03-01 02:51:34 +05:30
tapframe
bb0ff40f00 6% reduction for episode card height 2026-03-01 02:16:06 +05:30
tapframe
cb369610e3 prevented edge case where data bleeding after removing a profile 2026-03-01 02:07:03 +05:30
tapframe
f855d7abf8 added profile selector visibility based on profile count in sidebar components 2026-03-01 01:52:40 +05:30
tapframe
a3a9b6bd8e added focus restore to modern home view after opening sidebar 2026-03-01 01:48:50 +05:30
tapframe
74405229b1 pressing back now dismisses intro/outro buttons 2026-03-01 01:38:20 +05:30
tapframe
5e5cab9caa trakt screen reduced content scaling 2026-03-01 01:25:30 +05:30
tapframe
f017f08095 increased meta hydration cap limit 2026-03-01 00:55:16 +05:30
tapframe
229d5f2984 followup focus restore fixes 2026-03-01 00:47:21 +05:30
tapframe
783efb8b7f correcting focus restoration on episodesection 2026-03-01 00:34:20 +05:30
tapframe
b1c2e73a12 update several places to show text fallback incase of no clearlogo 2026-03-01 00:25:47 +05:30
David García Fuentes
9ec2a4768d
Update Spanish translations
Added missing Spanish translations from strings.xml and reordered elements to match the English file structure.
2026-02-28 13:50:12 +01:00
CrissZollo
c585269b69
Fixes #184, changes library to grid view instead 2026-02-28 13:11:00 +01:00
swoleronson
497f9055b8 Smooth loading overlay 2026-02-28 11:29:03 +00:00
craitose
568fbbccc1
Merge branch 'dev' into regex-autoplay-enhancement 2026-02-28 07:10:42 +00:00
craitose
06500f65c4
Update StreamAutoPlaySelector.kt 2026-02-28 07:03:12 +00:00
tapframe
43da9ddd79 string syntax fix 2026-02-28 08:31:21 +05:30
Nayif
d1e386dd44
Merge pull request #388 from craitose/performance
skip regex search if pattern is null to facilitate binge group speed
2026-02-28 08:29:08 +05:30
Nayif
830759cd74
Merge pull request #432 from traidor/dev
Update values-ro/strings.xml
2026-02-28 08:28:28 +05:30
Nayif
a5fd7ace81
Merge pull request #433 from danilopagotto82/dev
Improve app language and fix translation errors (Portuguese Brasil - Pt-Br)
2026-02-28 08:28:11 +05:30
Nayif
ae6792b36b
Merge pull request #440 from Joe00011/dev
Add Japanese (ja) translation
2026-02-28 08:27:13 +05:30
Nayif
6db9389333
Merge pull request #439 from halibiram/feature/updated-turkish-localization
Updated Turkish localization
2026-02-28 08:27:02 +05:30
Nayif
673ca09c8d
Merge pull request #437 from SwoleRonson/tweak-progress-bars
Tweak episode and continue-watching progress bars
2026-02-28 08:26:30 +05:30
Joe00011
957a220aef
Add Japanese language option to ThemeSettingsScreen 2026-02-28 11:42:08 +09:00
tapframe
9e1b736b44 minor modern home layout adjustments 2026-02-28 08:05:25 +05:30
HALİL İBRAHİM ÖZDEMİR
cb02d7e047 refine turkish translations 2026-02-28 04:52:51 +03:00
HALİL İBRAHİM ÖZDEMİR
7dfd7764a4 chore(i18n): restructure turkish translations and add missing keys 2026-02-28 04:32:37 +03:00
tapframe
8df3c4cb9a Merge branch 'dev' of https://github.com/tapframe/NuvioTV into dev 2026-02-28 06:43:56 +05:30
Swole Ronson
72166ad50b Tweak progress bars 2026-02-27 22:47:16 +00:00
traidor
0c7427188d
Update values-ro/strings.xml
Update Romanian translation
2026-02-27 21:26:58 +00:00
CrissZollo
cfc875cf2a
fixes #154, sorting in library 2026-02-27 22:04:13 +01:00
CrissZollo
e550eee240
#132 duplicate items 2026-02-27 21:30:49 +01:00
CrissZollo
565fbe2661
Fixes #84, logic finding forced and other possible formats 2026-02-27 21:19:59 +01:00
CrissZollo
cd5952709c
Fixes #79, added setting to hide none released movies and show 2026-02-27 20:53:15 +01:00
CrissZollo
f1d57a5865
Fixes #114, start from beginning 2026-02-27 20:12:00 +01:00
CrissZollo
666e93b72f
Added debug version to have different package name and app name 2026-02-27 19:49:08 +01:00
CrissZollo
162ef06822
Added Swedish as a local language 2026-02-27 19:20:36 +01:00
Joe00011
81b460434e
Add Japanese language option to ThemeSettingsScreen 2026-02-28 03:09:29 +09:00
Joe00011
295f9402f8
Add Japanese locale to locale_config.xml 2026-02-28 03:01:44 +09:00
Joe00011
21ca97b3a7
Add Japanese (ja) translation 2026-02-28 02:57:24 +09:00
danilopagotto82
2e31cc02f8
Update strings.xml
Improve subtitle layout to fit within available space
2026-02-27 14:54:53 -03:00
tapframe
2acca99a0d trakt error handling 2026-02-27 23:03:37 +05:30
tapframe
8ef216a07c trakt retry logic fixes 2026-02-27 21:11:29 +05:30
tapframe
d2ad167435 Merge branch 'danilopagotto82/dev' into dev 2026-02-27 20:52:54 +05:30
tapframe
9de54fcc00 bump version 2026-02-27 20:51:05 +05:30
tapframe
eb64d72c90 Merge branch 'aicon/dev' into dev 2026-02-27 20:50:33 +05:30
tapframe
e2f6a34234 Merge branch 'pr-357' into dev 2026-02-27 20:48:32 +05:30
tapframe
b0062759ba Merge branch 'refactor' into dev 2026-02-27 20:38:20 +05:30
tapframe
fbef4e6c40 fix catalog type duplicate rendering 2026-02-27 20:35:00 +05:30
Nayif
c4f0be9330
Merge pull request #417 from skoruppa/fixes
Fixes for aniSkip, stream selection and Pause Overlay
2026-02-27 20:30:11 +05:30
tapframe
2978654e6d handle already used device code scenario 2026-02-27 20:18:49 +05:30
Nayif
cdca3caa78
Merge pull request #412 from skoruppa/missing-strinigs
Last batch of new strings for translations
2026-02-27 20:13:52 +05:30
tapframe
a60f0a48ce homeview model refatoring into seperate files 2026-02-27 20:03:24 +05:30
tapframe
dac310a243 reset longPressTriggered state on key event 2026-02-27 19:38:05 +05:30
tapframe
acc54fc530 update cw watchproress to match metascreen 2026-02-27 18:51:56 +05:30
skoruppa
59d1ed1659 show PauseOverlay only after 5s of inactivity and allow to move left/right in streams sections and return focus to correct chip 2026-02-27 13:42:52 +01:00
tapframe
654aa05f8e trakt retry loop fix 2026-02-27 18:08:27 +05:30
skoruppa
79f305d41b handle seasons in aniSkip 2026-02-27 10:27:07 +01:00
skoruppa
02c29418b2 Rounded UI 2026-02-27 10:09:05 +01:00
skoruppa
dcdaa25aad Subtitle Styles strings 2026-02-27 10:02:48 +01:00
skoruppa
1b7a602b1a SkipIntro and DisplayMode strings 2026-02-27 09:58:38 +01:00
skoruppa
25760e5850 NextEpisodeCardOverlay strings 2026-02-27 09:55:37 +01:00
aicon
3f1615627a
Update string.xml, update hungarian translation, add missing values
Updated the Hungarian localization, refined the translation in several points, and added new language variables introduced in version 0.4.5-beta
2026-02-27 09:53:19 +01:00
skoruppa
058985d4d5 Handle user interactions in player actions, so the pause overlay will be displayed 5s after last activity 2026-02-27 09:53:02 +01:00
skoruppa
63ec08841d Calll LocalContext.current outside the remember lambda 2026-02-27 09:29:13 +01:00
aicon
07f4336393
Merge branch 'tapframe:dev' into dev 2026-02-27 05:54:55 +01:00
skoruppa
bcbcede6d4 rest of flash messages 2026-02-27 04:34:20 +01:00
skoruppa
1000dae9b8 adding rest missing strings 2026-02-27 03:57:10 +01:00
skoruppa
d759856fe4 trying to decrease lag
- Cap poster status observers at 60 to prevent spawning 500+ coroutines on load
- Defer reconcilePosterStatusObservers by 500ms so UI renders before observers start
- Increase layout prefs debounce 100ms->300ms to batch DataStore emissions on slow TV hardware
- Guard loadDisabledHomeCatalogPreference against redundant loadAllCatalogs calls on startup
2026-02-27 01:26:45 +01:00
tapframe
8c4d6a21fc Merge branch 'dev' 2026-02-26 20:12:43 +05:30
tapframe
50fb0d66c3 focus management in CatalogRowSection 2026-02-26 20:12:27 +05:30
tapframe
54435ce67b Revert "drawWithCache optmization on multiple pages."
This reverts commit 7d5f818c1a.
2026-02-26 18:25:09 +05:30
craitose
e245cbb02d skip regex search if pattern is null to facilitate binge group performance 2026-02-26 11:50:25 +00:00
craitose
13595b501e
Merge branch 'dev' into regex-autoplay-enhancement 2026-02-26 10:05:29 +00:00
aicon
cb91d15204
Merge branch 'dev' into dev 2026-02-26 06:19:54 +01:00
Jonathan Rojas
83413bebf9
Merge branch 'dev' into Update-Spanish 2026-02-25 20:10:19 -03:00
Jonathan Rojas
909b2f9290
Update locale_config.xml 2026-02-25 19:16:11 -03:00
craitose
1ec6c99e4a added slovak (sk) translation 2026-02-25 20:50:14 +00:00
craitose
85b38c0197 enhanced regex auto-stream select 2026-02-25 17:53:13 +00:00
IberianSoldierPC
ccae126d32 Updated spanish translation 1.1 2026-02-25 11:50:57 +01:00
IberianSoldierPC
26836291c2 Updated spanish translation 2026-02-25 10:51:52 +01:00
IberianSoldierPC
b543513956
Initial commit - Spanish translation 2026-02-25 09:22:22 +01:00
danilopagotto82
e27bd14279
Update strings.xml
Review
2026-02-24 17:07:24 -03:00
aicon
28e7ab8caf
Hungarian translation 2026-02-24 15:27:00 +01:00
aicon
703f7cd70d
Hungarian translation 2026-02-24 15:11:49 +01:00
aicon
0c6b18d1b2
Hungarian translation 2026-02-24 15:08:01 +01:00
Jonathan Rojas
c9bce72d34
Updated supportedLocales in ThemeSettingsScreen.kt 2026-02-24 10:16:09 -03:00
Jonathan Rojas
3d690452f9
Added es-419 to app/src/main/res/xml/locale_config.xml. 2026-02-24 10:11:59 -03:00
danilopagotto82
cdf24eb8dc
Merge branch 'tapframe:dev' into dev 2026-02-24 10:04:41 -03:00
Tukemon84
ffb4fa7606
The Latin American Spanish translation for the strings.xml file is ready. 2026-02-24 09:46:10 -03:00
danilopagotto82
065915fe9e
Update ThemeSettingsScreen.kt
Translate Pt-Br.
2026-02-23 21:23:36 -03:00
danilopagotto82
070e3b712f
Update locale_config.xml
Update Pt-Br
2026-02-23 21:20:44 -03:00
danilopagotto82
42987555c4
Update strings.xml
Update translate Pt-Br
2026-02-23 21:17:40 -03:00
danilopagotto82
1d806c4fe6
Create strings.xml
Translate Pt-Br
2026-02-23 21:01:06 -03:00
kernexshadow
61736980b0 Merge branch 'dev' of https://github.com/tapframe/NuvioTV into syncSub 2026-02-22 19:59:25 +00:00
kernexshadow
96fc9f7748 Add Sync by Line to the delay overlay and extend the timeout to 10 seconds 2026-02-22 19:56:33 +00:00
398 changed files with 130002 additions and 10244 deletions

220
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,220 @@
name: Bug report
description: Report a reproducible bug (one per issue).
title: "[Bug]: short summary here"
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug.
If we can reproduce it, we can usually fix it. This form is just to get the basics in one place.
Please replace the default title with a short summary of the actual problem.
If the app crashes, logs are required. Crash reports without logs may be labeled `needs-info`.
- type: markdown
attributes:
value: |
## Quick checks
- type: checkboxes
id: checks
attributes:
label: Pre-flight checks
options:
- label: I searched existing issues and this is not a duplicate.
required: true
- label: I can reproduce this on the latest release or latest main build.
required: false
- label: This issue is limited to a single bug (not multiple unrelated problems).
required: true
- type: markdown
attributes:
value: |
## Version & device
- type: input
id: app_version
attributes:
label: App version / commit
description: Release version (e.g. 1.2.3) or commit hash. If unsure, say where you got the APK/build from.
placeholder: "e.g. 1.2.3, or main@abc1234"
validations:
required: true
- type: dropdown
id: install_method
attributes:
label: Install method
options:
- GitHub Release APK
- CI build / Nightly
- Built from source
- Other (please describe below)
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
options:
- Android TV
- Google TV
- Fire TV
- Android phone/tablet
- Android emulator
- Other (please describe below)
validations:
required: true
- type: input
id: device_model
attributes:
label: Device model
description: "Example: Chromecast 4K, Shield TV, Mi Box, Fire TV Stick 4K, Pixel 8, etc."
placeholder: "e.g. NVIDIA Shield TV (2019)"
validations:
required: true
- type: input
id: android_version
attributes:
label: Android version
placeholder: "e.g. Android 12"
validations:
required: true
- type: dropdown
id: player_mode
attributes:
label: Player mode
description: If you are using an external player, most playback issues must be reported to that player instead.
options:
- Internal player
- External player
- Ask every time
- Not sure
validations:
required: true
- type: markdown
attributes:
value: |
## What happened?
- type: dropdown
id: area
attributes:
label: Area (tag)
description: Pick the closest match. It helps triage.
options:
- Playback (start/stop/buffering)
- Streams / Sources (selection, loading, errors)
- Next Episode / Auto-play
- Watch Progress (resume, watched state, history)
- Subtitles (download, styling, sync)
- Audio tracks
- Focus / Remote navigation
- UI / Layout
- Settings
- Sync (Trakt / remote)
- Other
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Exact steps. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links.
placeholder: |
1. Open ...
2. Navigate to ...
3. Press ...
4. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
placeholder: "What you expected to happen."
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
placeholder: "What actually happened (include any on-screen error text/codes)."
validations:
required: true
- type: dropdown
id: frequency
attributes:
label: Frequency
options:
- Always
- Often (more than 50%)
- Sometimes
- Rarely
- Once
validations:
required: true
- type: dropdown
id: regression
attributes:
label: Did this work before?
options:
- Not sure
- Yes, it used to work
- No, it never worked
validations:
required: true
- type: markdown
attributes:
value: |
## Extra context (optional)
- type: textarea
id: media_details
attributes:
label: Media details (optional)
description: Only include what you can safely share.
placeholder: |
- Content type: series/movie
- Season/Episode: S1E2
- Stream/source: (addon name / source label)
- Video format: (if known)
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs (required for crash reports)
description: |
Required if the app crashes or force closes.
For other bug reports, logs are optional but still helpful.
Include a short snippet from around the time the bug happens.
render: shell
placeholder: |
adb logcat -d | tail -n 300
validations:
required: false
- type: textarea
id: extra
attributes:
label: Anything else? (optional)
description: Screenshots/recordings, related issues, workarounds, etc.
validations:
required: false

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Downloads / Releases
url: https://github.com/tapframe/NuvioTV/releases
about: Grab the latest GitHub Release APK here.
- name: Documentation
url: https://github.com/tapframe/NuvioTV/blob/main/README.md
about: Read the README for setup and usage details.

View file

@ -0,0 +1,78 @@
name: Feature request
description: Suggest an improvement or new feature.
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
One feature request per issue, please. The more real-world your use case is, the easier it is to evaluate.
Feature requests are reviewed as product proposals first.
Please do not open a pull request for a new feature, major UX change, or broad cosmetic update unless a maintainer has explicitly approved it first.
Unapproved feature PRs will usually be closed.
- type: dropdown
id: area
attributes:
label: Area (tag)
options:
- Playback
- Streams / Sources
- Next Episode / Auto-play
- Watch Progress
- Subtitles
- Audio
- Focus / Remote navigation
- UI / Layout
- Settings
- Sync (Trakt / remote)
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem statement
description: What problem are you trying to solve?
placeholder: "I want to be able to..."
validations:
required: true
- type: textarea
id: proposed
attributes:
label: Proposed solution
description: What would you like the app to do?
validations:
required: true
- type: dropdown
id: contribution_plan
attributes:
label: Are you planning to implement this yourself?
description: Major features are usually implemented in-house unless approved first.
options:
- No, this is only a proposal
- Maybe, but only if approved first
- Yes, but I understand implementation still needs maintainer approval
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered (optional)
description: Any workarounds or other approaches you considered.
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional context (optional)
description: Mockups, examples from other apps, etc.
validations:
required: false

42
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,42 @@
## Summary
<!-- What changed in this PR? -->
## PR type
<!-- Pick one and delete the others -->
- Bug fix
- Small maintenance improvement
- Docs fix
- Translation update
- Approved larger change (link approval below)
## Why
<!-- Why this change is needed. Link bug/issue/context. -->
## Policy check
<!-- Confirm these before requesting review -->
- [ ] This PR is not cosmetic-only, unless it is a translation PR.
- [ ] This PR does not add a new major feature without prior approval.
- [ ] This PR is small in scope and focused on one problem.
- [ ] If this is a larger or directional change, I linked the issue where it was approved.
<!-- PRs that do not match this policy will usually be closed without merge. -->
## Testing
<!-- What you tested and how (manual + automated). -->
## Screenshots / Video (UI changes only)
<!-- If UI changed, add before/after screenshots or a short clip. -->
## Breaking changes
<!-- Any breaking behavior/config/schema changes? If none, write: None -->
## Linked issues
<!-- Example: Fixes #123 -->

182
.github/workflows/beta-release.yml vendored Normal file
View file

@ -0,0 +1,182 @@
name: Beta Release
on:
workflow_dispatch:
inputs:
release_mode:
description: "Choose whether to preview, save as draft, or publish immediately"
required: true
default: dry_run
type: choice
options:
- dry_run
- draft
- publish
manual_release:
description: "Use manual tag/title without changing Gradle version"
required: true
default: false
type: boolean
version:
description: "Target versionName for auto mode, for example 0.4.19-beta"
required: false
type: string
version_code:
description: "Optional versionCode override for auto mode. Defaults to current versionCode + 1."
required: false
type: string
release_tag:
description: "Manual mode tag. Required when manual release is enabled."
required: false
type: string
release_title:
description: "Manual mode GitHub release title. Required when manual release is enabled."
required: false
type: string
downloader_code:
description: "Optional downloader code footer"
required: false
type: string
custom_release_notes:
description: "Optional full Markdown release notes. When set, it replaces generated notes entirely."
required: false
type: string
extra_notes:
description: "Optional extra bullet lines, one per line"
required: false
type: string
permissions:
contents: write
concurrency:
group: beta-release-${{ github.ref }}
cancel-in-progress: false
jobs:
beta-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Prepare local properties
env:
LOCAL_PROPERTIES_BASE64: ${{ secrets.LOCAL_PROPERTIES_BASE64 }}
LOCAL_DEV_PROPERTIES_BASE64: ${{ secrets.LOCAL_DEV_PROPERTIES_BASE64 }}
run: |
if [ -n "$LOCAL_PROPERTIES_BASE64" ]; then
printf '%s' "$LOCAL_PROPERTIES_BASE64" | base64 --decode > local.properties
fi
if [ -n "$LOCAL_DEV_PROPERTIES_BASE64" ]; then
printf '%s' "$LOCAL_DEV_PROPERTIES_BASE64" | base64 --decode > local.dev.properties
fi
- name: Prepare signing config
if: ${{ inputs.release_mode != 'dry_run' }}
env:
RELEASE_KEYSTORE_BASE64: ${{ secrets.NUVIO_RELEASE_KEYSTORE_BASE64 }}
RELEASE_KEY_ALIAS: ${{ secrets.NUVIO_RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.NUVIO_RELEASE_KEY_PASSWORD }}
RELEASE_STORE_PASSWORD: ${{ secrets.NUVIO_RELEASE_STORE_PASSWORD }}
run: |
if [ -n "$RELEASE_KEYSTORE_BASE64" ]; then
echo "$RELEASE_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/nuviotv.jks"
{
echo "NUVIO_RELEASE_STORE_FILE=$RUNNER_TEMP/nuviotv.jks"
echo "NUVIO_RELEASE_KEY_ALIAS=$RELEASE_KEY_ALIAS"
echo "NUVIO_RELEASE_KEY_PASSWORD=$RELEASE_KEY_PASSWORD"
echo "NUVIO_RELEASE_STORE_PASSWORD=$RELEASE_STORE_PASSWORD"
} >> "$GITHUB_ENV"
else
echo "CI_USE_DEBUG_SIGNING=true" >> "$GITHUB_ENV"
fi
- name: Preview beta release
if: ${{ inputs.release_mode == 'dry_run' }}
run: |
args=(python3 scripts/release_beta.py --dry-run)
if [ "${{ inputs.manual_release }}" = "true" ]; then
args+=(--manual-release --release-tag "${{ inputs.release_tag }}" --release-title "${{ inputs.release_title }}")
else
args+=("${{ inputs.version }}")
if [ -n "${{ inputs.version_code }}" ]; then
args+=(--version-code "${{ inputs.version_code }}")
fi
if [ -n "${{ inputs.release_tag }}" ]; then
args+=(--release-tag "${{ inputs.release_tag }}")
fi
if [ -n "${{ inputs.release_title }}" ]; then
args+=(--release-title "${{ inputs.release_title }}")
fi
fi
if [ -n "${{ inputs.custom_release_notes }}" ]; then
printf '%s\n' '${{ inputs.custom_release_notes }}' > "$RUNNER_TEMP/custom-release-notes.md"
args+=(--custom-notes-file "$RUNNER_TEMP/custom-release-notes.md")
fi
if [ -n "${{ inputs.downloader_code }}" ]; then
args+=(--downloader-code "${{ inputs.downloader_code }}")
fi
if [ -n "${{ inputs.extra_notes }}" ]; then
args+=(--extra-notes "${{ inputs.extra_notes }}")
fi
"${args[@]}"
- name: Upload preview notes
if: ${{ inputs.release_mode == 'dry_run' }}
uses: actions/upload-artifact@v4
with:
name: beta-release-preview-${{ inputs.manual_release && inputs.release_tag || inputs.version }}
path: build/release/release-notes-${{ inputs.manual_release && inputs.release_tag || inputs.version }}.md
- name: Build beta release
if: ${{ inputs.release_mode != 'dry_run' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
args=(python3 scripts/release_beta.py)
case "${{ inputs.release_mode }}" in
draft) args+=(--draft) ;;
publish) args+=(--publish) ;;
esac
if [ "${{ inputs.manual_release }}" = "true" ]; then
args+=(--manual-release --release-tag "${{ inputs.release_tag }}" --release-title "${{ inputs.release_title }}")
else
args+=("${{ inputs.version }}")
if [ -n "${{ inputs.version_code }}" ]; then
args+=(--version-code "${{ inputs.version_code }}")
fi
if [ -n "${{ inputs.release_tag }}" ]; then
args+=(--release-tag "${{ inputs.release_tag }}")
fi
if [ -n "${{ inputs.release_title }}" ]; then
args+=(--release-title "${{ inputs.release_title }}")
fi
fi
if [ -n "${{ inputs.custom_release_notes }}" ]; then
printf '%s\n' '${{ inputs.custom_release_notes }}' > "$RUNNER_TEMP/custom-release-notes.md"
args+=(--custom-notes-file "$RUNNER_TEMP/custom-release-notes.md")
fi
if [ -n "${{ inputs.downloader_code }}" ]; then
args+=(--downloader-code "${{ inputs.downloader_code }}")
fi
if [ -n "${{ inputs.extra_notes }}" ]; then
args+=(--extra-notes "${{ inputs.extra_notes }}")
fi
"${args[@]}"

View file

@ -0,0 +1,83 @@
name: Close unlabeled issues
on:
schedule:
- cron: "23 6 * * *" # daily
workflow_dispatch:
inputs:
dry_run:
description: Log matching issues without commenting or closing them
required: false
type: boolean
default: false
permissions:
issues: write
jobs:
close_unlabeled:
runs-on: ubuntu-latest
steps:
- name: Close open issues with no labels
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const inputs = context.payload.inputs || {};
const dryRun = String(inputs.dry_run || "false").toLowerCase() === "true";
const closeMarker = "<!-- nuviotv-bot:close-unlabeled -->";
const items = await github.paginate(github.rest.search.issuesAndPullRequests, {
q: `repo:${owner}/${repo} is:issue is:open no:label`,
per_page: 100,
});
core.info(`Found ${items.length} open unlabeled issues.`);
for (const item of items) {
const issueNumber = item.number;
if (dryRun) {
core.info(`#${issueNumber}: would comment and close.`);
continue;
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
const alreadyCommented = comments.some(comment =>
(comment.body || "").includes(closeMarker)
);
if (!alreadyCommented) {
const body =
`${closeMarker}\n` +
`Sorry about the churn here, and thanks for taking the time to report this.\n\n` +
`Closing this issue because it does not have any labels.\n\n` +
`Issue labels and triage rules were introduced later, so older unlabeled issues are no longer tracked reliably.\n\n` +
`If you are still facing this problem, please open a new issue using the appropriate issue template and label so it can be triaged correctly.`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
}
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
state_reason: "not_planned",
});
core.info(`#${issueNumber}: closed.`);
}

75
.github/workflows/pr-template-check.yml vendored Normal file
View file

@ -0,0 +1,75 @@
name: PR Template Check
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
jobs:
validate_pr_body:
runs-on: ubuntu-latest
steps:
- name: Validate required PR sections
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const body = (pr.body || "").trim();
function sectionContent(title) {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^##\\s+${escaped}\\s*$`, "m");
const match = body.match(re);
if (!match) return null;
const start = match.index + match[0].length;
const rest = body.slice(start);
const next = rest.search(/^##\s+/m);
return (next === -1 ? rest : rest.slice(0, next)).trim();
}
const required = ["Summary", "Why", "Testing", "Breaking changes", "Linked issues"];
const missing = [];
const empty = [];
for (const name of required) {
const content = sectionContent(name);
if (content === null) {
missing.push(name);
continue;
}
const cleaned = content
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/`/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
if (
cleaned.length < 4 ||
cleaned === "none" ||
cleaned.includes("what changed in this pr") ||
cleaned.includes("why this change is needed") ||
cleaned.includes("what you tested") ||
cleaned.includes("example: fixes #123")
) {
empty.push(name);
}
}
if (missing.length || empty.length) {
const lines = [
"PR description is missing required detail.",
"",
];
if (missing.length) lines.push(`Missing sections: ${missing.join(", ")}`);
if (empty.length) lines.push(`Incomplete sections: ${empty.join(", ")}`);
lines.push("");
lines.push("Please fill the PR template before merging.");
core.setFailed(lines.join("\n"));
} else {
core.info("PR template check passed.");
}

75
.github/workflows/stale-needs-info.yml vendored Normal file
View file

@ -0,0 +1,75 @@
name: Close stale needs-info issues
on:
schedule:
- cron: "17 6 * * *" # daily
workflow_dispatch: {}
permissions:
issues: write
jobs:
close_stale:
runs-on: ubuntu-latest
steps:
- name: Warn then close inactive needs-info
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const NEEDS_INFO = "needs-info";
const WARN_AFTER_DAYS = 14;
const CLOSE_AFTER_DAYS = 21;
const warnMarker = "<!-- nuviotv-bot:stale-warning -->";
const closeMarker = "<!-- nuviotv-bot:stale-close -->";
const now = Date.now();
const warnCutoff = now - WARN_AFTER_DAYS * 24 * 60 * 60 * 1000;
const closeCutoff = now - CLOSE_AFTER_DAYS * 24 * 60 * 60 * 1000;
async function listOpenNeedsInfoIssues() {
// Search API is the simplest way to find issues by label.
const q = `repo:${owner}/${repo} is:issue is:open label:"${NEEDS_INFO}"`;
const res = await github.rest.search.issuesAndPullRequests({ q, per_page: 50 });
return res.data.items || [];
}
const items = await listOpenNeedsInfoIssues();
for (const item of items) {
const issue_number = item.number;
// Use updated_at (includes comments/edits) as the inactivity signal.
const updatedAtMs = new Date(item.updated_at).getTime();
// Fetch comments to see if we already warned/closed.
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const hasWarned = comments.some(c => (c.body || "").includes(warnMarker));
const hasClosedComment = comments.some(c => (c.body || "").includes(closeMarker));
if (updatedAtMs <= closeCutoff && hasWarned && !hasClosedComment) {
const body =
`${closeMarker}\n` +
`Closing this for now since we didn't get the requested details.\n\n` +
`If you can share the missing info, reply here and we can reopen.`;
await github.rest.issues.createComment({ owner, repo, issue_number, body });
await github.rest.issues.update({ owner, repo, issue_number, state: "closed" });
continue;
}
if (updatedAtMs <= warnCutoff && !hasWarned) {
const body =
`${warnMarker}\n` +
`Just a quick ping: this issue is labeled \`${NEEDS_INFO}\` and hasn't had any updates in a bit.\n\n` +
`If you can add the missing details, we can keep going. Otherwise it may be closed after a grace period.`;
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
}

164
.github/workflows/triage-needs-info.yml vendored Normal file
View file

@ -0,0 +1,164 @@
name: Triage (needs-info)
on:
issues:
types: [opened, edited, reopened]
permissions:
issues: write
jobs:
needs_info:
runs-on: ubuntu-latest
steps:
- name: Label low-context issues
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.issue.number;
const issue = context.payload.issue;
const title = (issue.title || "").trim();
const body = issue.body || "";
const labels = (issue.labels || []).map(l => (typeof l === "string" ? l : l.name).toLowerCase());
const NEEDS_INFO = "needs-info";
const NEEDS_INFO_COLOR = "d4c5f9"; // light purple (GitHub-ish)
const NEEDS_INFO_DESC = "More details needed to reproduce / triage.";
function hasLabel(name) {
return labels.includes(name.toLowerCase());
}
function extractSection(title) {
// Issue forms render headings like "### Steps to reproduce"
const re = new RegExp(`^###\\s+${title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m");
const match = body.match(re);
if (!match) return "";
const start = match.index + match[0].length;
const rest = body.slice(start);
const next = rest.search(/^###\s+/m);
const section = (next === -1 ? rest : rest.slice(0, next));
return section.trim();
}
function extractFirstSection(titles) {
for (const sectionTitle of titles) {
const value = extractSection(sectionTitle);
if (value) return value;
}
return "";
}
function normalizeText(value) {
return (value || "").replace(/\s+/g, " ").trim();
}
function stripIssuePrefix(value) {
return normalizeText(value).replace(/^\[[^\]]+\]:\s*/i, "").trim();
}
const steps = extractSection("Steps to reproduce");
const expected = extractSection("Expected behavior");
const actual = extractSection("Actual behavior");
const logs = extractFirstSection([
"Logs (required for crash reports)",
"Logs (optional but helpful)",
]);
const extra = extractSection("Anything else? (optional)");
const summaryTitle = stripIssuePrefix(title);
// Only triage bug-style issues.
// Skip feature/enhancement issues so they don't get "missing repro" comments.
const looksLikeBugForm = !!(steps || expected || actual);
const isBugIssue = hasLabel("bug") || looksLikeBugForm;
const isFeatureIssue =
hasLabel("enhancement") ||
hasLabel("feature") ||
hasLabel("feature request");
if (!isBugIssue || isFeatureIssue) {
return;
}
// Keep checks simple and forgiving: required fields can still be low-effort (e.g. "doesn't work").
const problems = [];
const genericTitle = /^(bug|issue|problem|help|question|crash|broken|error|bug report|short summary here|title here)$/i;
const numericOnlyTitle = /^#?\d+$/;
const crashPattern = /\b(crash|crashes|crashed|crashing|force close|force closes|force closed|fatal exception|app closes|app closed unexpectedly)\b/i;
const crashContext = [summaryTitle, steps, actual, extra].map(normalizeText).join("\n");
const isCrashIssue = crashPattern.test(crashContext);
const normalizedLogs = normalizeText(logs);
const hasLogs = normalizedLogs.length >= 20 && !/^(n\/a|na|none|no|not available)$/i.test(normalizedLogs);
if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) {
problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)");
}
if (!steps || steps.length < 30) problems.push("Steps to reproduce (please list exact steps)");
if (!expected || expected.length < 10) problems.push("Expected behavior");
if (!actual || actual.length < 10) problems.push("Actual behavior (include any on-screen error text)");
if (isCrashIssue && !hasLogs) {
problems.push("Logs (required for crash reports; include a short logcat snippet or stack trace)");
}
async function ensureLabel(name, color, description) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
// Create if missing.
try {
await github.rest.issues.createLabel({ owner, repo, name, color, description });
} catch (_) {
// Ignore if it was created concurrently or permissions differ.
}
}
}
const hasNeedsInfo = hasLabel(NEEDS_INFO);
if (problems.length > 0) {
await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC);
if (!hasNeedsInfo) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [NEEDS_INFO],
});
}
const marker = "<!-- nuviotv-bot:needs-info -->";
const commentBody =
`${marker}\n` +
`Thanks for the report. Could you add a bit more detail so we can reproduce it?\n\n` +
`Missing / too short:\n` +
problems.map(p => `- ${p}`).join("\n") +
`\n\n` +
`Use a specific title, for example: \`[Bug]: Continue Watching shows percent instead of time left\`.\n` +
`${isCrashIssue ? `Crash reports must include logs.\n` : `Logcat is optional for most issues, but it helps a lot.\n`}` +
`Example: \`adb logcat -d | tail -n 300\``;
// Avoid spamming duplicate bot comments on every edit.
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const alreadyCommented = comments.some(c => (c.body || "").includes(marker));
if (!alreadyCommented) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: commentBody,
});
}
} else if (hasNeedsInfo) {
// If the user filled things in later, remove needs-info automatically.
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: NEEDS_INFO });
} catch (_) {}
}

8
.gitignore vendored
View file

@ -37,8 +37,12 @@ benchmark
db/
backups/
.supabase_db.env
scripts
supabase
scripts/*
!scripts/release_beta.py
/supabase
tv-samples
HOME_UI_OPTIMIZATION_NOTES.local.md
.kotlin/
bugs/
avatars/
logs

View file

@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-02-03T18:03:33.281364Z">
<DropdownSelection timestamp="2026-03-30T03:43:38.199872Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/nayifnoushad/.android/avd/Television_1080p.avd" />
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/amarjitsingh/.android/avd/Television_1080p.avd" />
</handle>
</Target>
</DropdownSelection>

View file

@ -11,6 +11,7 @@
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/baselineprofile" />
</set>
</option>
</GradleProjectSettings>

8
.idea/markdown.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

View file

@ -2,17 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Player" vcs="Git" />
<mapping directory="$PROJECT_DIR$/android-tv-samples" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/ass" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/expat" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/fontconfig" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/freetype" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/freetype/subprojects/dlg" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/fribidi" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/harfbuzz" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/unibreak" vcs="Git" />
</component>
</project>

81
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,81 @@
# Contributing
Thanks for helping improve NuvioTV.
## PR policy
Pull requests are currently intended for:
- Reproducible bug fixes
- Small stability improvements
- Minor maintenance work
- Small documentation fixes that improve accuracy
- Translation updates
Pull requests are generally **not** accepted for:
- New major features
- Product direction changes
- Large UX / UI redesigns
- Cosmetic-only changes
- Refactors without a clear user-facing or maintenance benefit
For feature ideas and bigger changes, please open an issue first. Feature implementation is usually kept in-house unless it has been discussed and explicitly approved beforehand.
Translation PRs are allowed, as long as they stay focused on translation/localization work and do not bundle unrelated feature or UI changes.
## Where to ask questions
- Use **Issues** for bugs, feature requests, setup help, and general support.
## Bug reports (rules)
To keep issues fixable, bug reports should include:
- A short, specific issue title that describes the bug
- App version (release version or commit hash)
- Platform + device model + Android version
- Install method (release APK / CI / built from source)
- Steps to reproduce (exact steps)
- Expected vs actual behavior
- Frequency (always/sometimes/once)
Do not leave the title as just `[Bug]:` or another generic placeholder.
Logcat is optional for most issues, but it is **required** for crash / force-close reports.
### How to capture logs (optional)
If you can, reproduce the issue once, then attach a short log snippet from around the time it happened:
```sh
adb logcat -d | tail -n 300
```
If the issue is a crash, include a stack trace or log snippet from Android Studio or `adb logcat`.
## Feature requests (rules)
Please include:
- The problem you are solving (use case)
- Your proposed solution
- Alternatives considered (if any)
Opening a feature request does **not** mean a pull request will be accepted for it. If the feature affects product scope, UX direction, or adds a significant new surface area, do not start implementation unless a maintainer explicitly approves it first.
## Before opening a PR
Please make sure your PR is all of the following:
- Small in scope
- Focused on one problem
- Clearly aligned with the current direction of the project
- Not cosmetic-only, unless it is a translation PR
- Not a new major feature unless it was discussed and approved first
PRs that do not fit this policy will usually be closed without merge so review time can stay focused on bugs, regressions, and small improvements.
## One issue per problem
Please open separate issues for separate bugs/features. It makes tracking, fixing, and closing issues much faster.

View file

@ -1,4 +1,4 @@
plugins {
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
@ -6,7 +6,6 @@ plugins {
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization)
id("io.sentry.android.gradle") version "6.0.0"
}
import java.util.Properties
@ -25,16 +24,28 @@ val devProperties = Properties().apply {
}
}
fun env(name: String): String? = providers.environmentVariable(name).orNull
val useDebugReleaseSigning = env("CI_USE_DEBUG_SIGNING").equals("true", ignoreCase = true)
val releaseStoreFilePath = env("NUVIO_RELEASE_STORE_FILE")
?: localProperties.getProperty("NUVIO_RELEASE_STORE_FILE")
val releaseKeyAliasValue = env("NUVIO_RELEASE_KEY_ALIAS")
?: localProperties.getProperty("NUVIO_RELEASE_KEY_ALIAS", "nuviotv")
val releaseKeyPasswordValue = env("NUVIO_RELEASE_KEY_PASSWORD")
?: localProperties.getProperty("NUVIO_RELEASE_KEY_PASSWORD", "815787")
val releaseStorePasswordValue = env("NUVIO_RELEASE_STORE_PASSWORD")
?: localProperties.getProperty("NUVIO_RELEASE_STORE_PASSWORD", "815787")
android {
namespace = "com.nuvio.tv"
compileSdk = 36
defaultConfig {
applicationId = "com.nuvio.tv"
minSdk = 26
minSdk = 24
targetSdk = 36
versionCode = 23
versionName = "0.4.5-beta"
versionCode = 54
versionName = "0.6.3-beta"
buildConfigField("String", "PARENTAL_GUIDE_API_URL", "\"${localProperties.getProperty("PARENTAL_GUIDE_API_URL", "")}\"")
buildConfigField("String", "INTRODB_API_URL", "\"${localProperties.getProperty("INTRODB_API_URL", "")}\"")
@ -44,7 +55,12 @@ android {
buildConfigField("String", "TRAKT_CLIENT_ID", "\"${localProperties.getProperty("TRAKT_CLIENT_ID", "")}\"")
buildConfigField("String", "TRAKT_CLIENT_SECRET", "\"${localProperties.getProperty("TRAKT_CLIENT_SECRET", "")}\"")
buildConfigField("String", "TRAKT_API_URL", "\"${localProperties.getProperty("TRAKT_API_URL", "https://api.trakt.tv/")}\"")
buildConfigField("String", "TRAKT_REDIRECT_URI", "\"${localProperties.getProperty("TRAKT_REDIRECT_URI", "urn:ietf:wg:oauth:2.0:oob")}\"")
buildConfigField("String", "TMDB_API_KEY", "\"${localProperties.getProperty("TMDB_API_KEY", "")}\"")
buildConfigField("String", "TV_LOGIN_WEB_BASE_URL", "\"${localProperties.getProperty("TV_LOGIN_WEB_BASE_URL", "https://app.nuvio.tv/tv-login")}\"")
buildConfigField("String", "DONATIONS_BASE_URL", "\"${localProperties.getProperty("DONATIONS_BASE_URL", "")}\"")
buildConfigField("String", "DONATIONS_DONATE_URL", "\"${localProperties.getProperty("DONATIONS_DONATE_URL", "")}\"")
buildConfigField("String", "AVATAR_PUBLIC_BASE_URL", "\"${localProperties.getProperty("AVATAR_PUBLIC_BASE_URL", "")}\"")
// In-app updater (GitHub Releases)
buildConfigField("String", "GITHUB_OWNER", "\"tapframe\"")
@ -53,10 +69,10 @@ android {
signingConfigs {
create("release") {
keyAlias = "nuviotv"
keyPassword = "815787"
storeFile = file("../nuviotv.jks")
storePassword = "815787"
keyAlias = releaseKeyAliasValue
keyPassword = releaseKeyPasswordValue
storeFile = releaseStoreFilePath?.let(::file) ?: file("../nuviotv.jks")
storePassword = releaseStorePasswordValue
}
}
@ -77,14 +93,22 @@ android {
buildConfigField("String", "TRAILER_API_URL", "\"${devProperties.getProperty("TRAILER_API_URL", "")}\"")
buildConfigField("String", "IMDB_RATINGS_API_BASE_URL", "\"${devProperties.getProperty("IMDB_RATINGS_API_BASE_URL", "")}\"")
buildConfigField("String", "IMDB_TAPFRAME_API_BASE_URL", "\"${devProperties.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}\"")
buildConfigField("String", "DONATIONS_BASE_URL", "\"${devProperties.getProperty("DONATIONS_BASE_URL", localProperties.getProperty("DONATIONS_BASE_URL", ""))}\"")
buildConfigField("String", "DONATIONS_DONATE_URL", "\"${devProperties.getProperty("DONATIONS_DONATE_URL", localProperties.getProperty("DONATIONS_DONATE_URL", ""))}\"")
buildConfigField("String", "AVATAR_PUBLIC_BASE_URL", "\"${devProperties.getProperty("AVATAR_PUBLIC_BASE_URL", localProperties.getProperty("AVATAR_PUBLIC_BASE_URL", ""))}\"")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
signingConfig = if (useDebugReleaseSigning) {
signingConfigs.getByName("debug")
} else {
signingConfigs.getByName("release")
}
buildConfigField("boolean", "IS_DEBUG_BUILD", "false")
@ -97,6 +121,22 @@ android {
buildConfigField("String", "TRAILER_API_URL", "\"${localProperties.getProperty("TRAILER_API_URL", "")}\"")
buildConfigField("String", "IMDB_RATINGS_API_BASE_URL", "\"${localProperties.getProperty("IMDB_RATINGS_API_BASE_URL", "")}\"")
buildConfigField("String", "IMDB_TAPFRAME_API_BASE_URL", "\"${localProperties.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}\"")
buildConfigField("String", "DONATIONS_BASE_URL", "\"${localProperties.getProperty("DONATIONS_BASE_URL", "")}\"")
buildConfigField("String", "DONATIONS_DONATE_URL", "\"${localProperties.getProperty("DONATIONS_DONATE_URL", "")}\"")
buildConfigField("String", "AVATAR_PUBLIC_BASE_URL", "\"${localProperties.getProperty("AVATAR_PUBLIC_BASE_URL", "")}\"")
}
create("benchmark") {
initWith(buildTypes.getByName("release"))
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
applicationIdSuffix = ".debug"
matchingFallbacks += "release"
}
}
@ -112,6 +152,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlin {
compilerOptions {
@ -122,6 +163,35 @@ android {
compose = true
buildConfig = true
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("src/main/jniLibs")
}
}
packaging {
jniLibs {
// Keep one consistent native set across dependencies.
pickFirsts += listOf(
"lib/*/libc++_shared.so",
"lib/*/libavcodec.so",
"lib/*/libavdevice.so",
"lib/*/libavfilter.so",
"lib/*/libavformat.so",
"lib/*/libavutil.so",
"lib/*/libswscale.so",
"lib/*/libswresample.so",
"lib/*/libtorrserver.so"
)
}
}
}
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.applicationId.set("com.nuviodebug.com")
}
}
composeCompiler {
@ -137,10 +207,21 @@ configurations.all {
exclude(group = "androidx.media3", module = "media3-ui")
}
baselineProfile {
automaticGenerationDuringBuild = false
saveInSrc = true
mergeIntoMain = true
baselineProfileOutputDir = "src/main"
filter {
include("com.nuvio.tv.**")
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
val composeBom = platform("androidx.compose:compose-bom:2026.01.01")
// baselineProfile(project(":benchmark")) // TODO: create benchmark module later
baselineProfile(project(":baselineprofile"))
implementation(libs.androidx.core.ktx)
implementation("androidx.core:core-splashscreen:1.0.1")
implementation(libs.androidx.appcompat)
@ -176,6 +257,7 @@ dependencies {
// Image Loading
implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.coil.svg)
// Navigation
@ -214,6 +296,9 @@ dependencies {
// libass-android for ASS/SSA subtitle support (from Maven Central)
implementation("io.github.peerless2012:ass-media:0.4.0-beta01")
// Local nextlib-mediainfo fork (static FFmpeg; no libav*.so in final AAR)
implementation(files("libs/nextlib-mediainfo-local.aar"))
implementation("io.github.abdallahmehiz:mpv-android-lib:0.1.12")
implementation("dev.chrisbanes.haze:haze-android:0.7.3") {
exclude(group = "org.jetbrains.compose.ui")
exclude(group = "org.jetbrains.compose.foundation")
@ -224,6 +309,26 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.gson)
// Jackson — required by CloudStream DEX extensions at runtime
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
// NiceHTTP — HTTP client used by CloudStream extensions via `app.get()`
implementation(libs.nicehttp)
// Conscrypt — TLS provider with browser-compatible fingerprint for Cloudflare bypass
implementation(libs.conscrypt.android)
// CloudStream library — provides core API classes for extension compatibility
implementation(libs.cloudstream.library) {
// Exclude heavy deps we don't need or that conflict
exclude(group = "org.mozilla", module = "rhino")
exclude(group = "com.github.AmarullisVFX", module = "newpipeextractor")
exclude(group = "com.github.AmaryllisVFX", module = "newpipeextractor")
exclude(group = "com.github.AmaryllisVFX.newpipeextractor")
exclude(group = "info.debatty", module = "java-string-similarity")
}
// Markdown rendering
implementation(libs.markdown.renderer.m3)
@ -233,6 +338,7 @@ dependencies {
implementation(libs.nanohttpd)
implementation(libs.zxing.core)
// Supabase
implementation(platform(libs.supabase.bom))
implementation(libs.supabase.auth)
@ -243,8 +349,8 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
// Performance profiling
implementation("androidx.metrics:metrics-performance:1.0.0-beta01") // JankStats
implementation("androidx.compose.runtime:runtime-tracing") // Compose function names in Perfetto
implementation("androidx.metrics:metrics-performance:1.0.0-rc01") // JankStats
debugImplementation("androidx.compose.runtime:runtime-tracing")
// Bundle real crypto-js (JS) for QuickJS plugins
implementation("org.webjars.npm:crypto-js:4.2.0")

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -25,6 +25,8 @@
# Keep Retrofit service interfaces (must preserve generic return types)
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
# NOTE: allowobfuscation here is fine for Retrofit, but superseded by the
# broader kotlin.** keep rule below for DexClassLoader extension compatibility.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# Keep all project API interfaces
-keep class com.nuvio.tv.data.remote.api.** { *; }
@ -53,6 +55,9 @@
# Keep server classes and their inner data classes (serialized with Gson)
-keep class com.nuvio.tv.core.server.** { *; }
# ── Torrent streaming (TorrServer) ─────────────────────────────────────────────
-keep class com.nuvio.tv.core.torrent.** { *; }
#── QuickJS ────────────────────────────────────────────────────────────────────
# Keep quickjs-kt library classes for proper type conversion
-keep class com.dokar.quickjs.** { *; }
@ -83,7 +88,45 @@
kotlinx.serialization.KSerializer serializer(...);
}
# ── External extension compatibility stubs (loaded via DexClassLoader) ────────
-keep class com.lagradost.cloudstream3.** { *; }
-keepclassmembers class com.lagradost.cloudstream3.** { *; }
-keep class com.lagradost.nicehttp.** { *; }
-keepclassmembers class com.lagradost.nicehttp.** { *; }
-keep class com.lagradost.api.** { *; }
-keepclassmembers class com.lagradost.api.** { *; }
# ── General ────────────────────────────────────────────────────────────────────
# Keep line numbers for crash reports
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# MPV (native JNI callbacks)
# Native code reflects into multiple classes/methods under is.xyz.mpv,
# so keep the whole package to avoid JNI lookup crashes after R8.
-keep class is.xyz.mpv.** { *; }
# ── Missing class stubs (referenced by cloudstream3 / jsoup / newpipe) ────────
-dontwarn org.mozilla.javascript.**
-dontwarn com.google.re2j.**
-dontwarn javax.script.**
-dontwarn okhttp3.internal.sse.**
-dontwarn org.jsoup.helper.Re2jRegex
# ── DexClassLoader runtime deps (CloudStream extensions) ─────────────────────
# Extensions are DEX files loaded at runtime via DexClassLoader. They resolve
# dependencies by fully-qualified name from the host classloader. R8 must not
# rename or remove any class that extensions may reference.
-keep class kotlin.** { *; }
-keep class kotlinx.coroutines.** { *; }
-keep class okhttp3.** { *; }
-keepclassmembers class okhttp3.** { *; }
-keep class okio.** { *; }
-keepclassmembers class okio.** { *; }
-keep class org.jsoup.** { *; }
-keepclassmembers class org.jsoup.** { *; }
-keep class com.fasterxml.jackson.** { *; }
-keepclassmembers class com.fasterxml.jackson.** { *; }
-dontwarn java.beans.ConstructorProperties
-dontwarn java.beans.Transient

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Nuvio Debug</string>
</resources>

View file

@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -19,18 +20,13 @@
android:allowBackup="true"
android:banner="@mipmap/banner"
android:icon="@mipmap/ic_launcher"
android:label="Nuvio"
android:label="@string/app_name"
android:largeHeap="true"
android:extractNativeLibs="true"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
android:localeConfig="@xml/locale_config"
android:theme="@style/Theme.MyApplication">
<meta-data
android:name="io.sentry.dsn"
android:value="https://151aefbf907d5fd4a6c8533d9d064441@o4510894222278656.ingest.us.sentry.io/4510894227259392" />
<meta-data
android:name="io.sentry.send-default-pii"
android:value="true" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@ -44,6 +40,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|uiMode|keyboard|keyboardHidden|navigation|layoutDirection|fontScale|locale"
android:theme="@style/Theme.MyApplication.Launcher">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
@file:Suppress("unused")
package com.lagradost.cloudstream3
import android.app.Activity
import android.content.Context
import com.lagradost.api.setContext
import java.lang.ref.WeakReference
/**
* Stub for CloudStream's AcraApplication.
* Extensions (e.g. Yflix) reference AcraApplication.context and the getKey/setKey helpers.
* We provide no-op implementations since we don't need extension settings persistence.
*/
open class AcraApplication {
companion object {
/** Application context stub. Extensions use this for PackageManager etc. */
@JvmStatic
var context: Context? = null
set(value) {
field = value
// Also set the library's context so WebViewResolver and other
// library components can access it
if (value != null) {
setContext(WeakReference(value))
}
}
/**
* Weak reference to the current Activity. CloudStream extensions
* often require a non-null Activity in their load() method to
* register MainAPIs. Set from MainActivity.onCreate().
*/
private var activityRef: WeakReference<Activity>? = null
@JvmStatic
fun getActivity(): Activity? = activityRef?.get()
@JvmStatic
fun setActivity(activity: Activity?) {
activityRef = if (activity != null) WeakReference(activity) else null
// Update the library's context to the Activity (preferred for WebView)
if (activity != null) {
setContext(WeakReference(activity))
}
}
/** Retrieve a stored value. No-op — always returns the default. */
@JvmStatic
fun <T> getKey(path: String, key: String, default: T? = null): T? = default
/** Retrieve a stored value (single-key overload). */
@JvmStatic
fun <T> getKey(key: String, default: T? = null): T? = default
/** Store a value. No-op. */
@JvmStatic
fun setKey(path: String, key: String, value: Any?) {}
/** Store a value (single-key overload). No-op. */
@JvmStatic
fun setKey(key: String, value: Any?) {}
/** Remove stored keys by prefix. No-op. */
@JvmStatic
fun removeKeys(prefix: String) {}
/** Remove a single stored key. No-op. */
@JvmStatic
fun removeKey(path: String, key: String) {}
/** Remove a single stored key (single-key overload). No-op. */
@JvmStatic
fun removeKey(key: String) {}
}
}

View file

@ -0,0 +1,14 @@
@file:Suppress("unused")
package com.lagradost.cloudstream3
import android.app.Activity
/** Stub for CommonActivity referenced by some extensions. */
object CommonActivity {
var activity: Activity? = null
fun showToast(message: String, duration: Int = 0) {
// No-op stub — extensions may call this for user notifications
}
}

View file

@ -0,0 +1,160 @@
package com.lagradost.cloudstream3.network
import android.util.Log
import android.webkit.CookieManager
import androidx.annotation.AnyThread
import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.app
import kotlinx.coroutines.runBlocking
import okhttp3.Headers
import okhttp3.Headers.Companion.toHeaders
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.net.URI
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
fun getHeaders(
headers: Map<String, String>,
cookie: Map<String, String>
): Headers {
val cookieMap =
if (cookie.isNotEmpty()) mapOf(
"Cookie" to cookie.entries.joinToString(" ") {
"${it.key}=${it.value};"
}) else mapOf()
val tempHeaders = (DEFAULT_HEADERS + headers + cookieMap)
return tempHeaders.toHeaders()
}
/**
* CloudflareKiller bypasses Cloudflare challenges using WebView.
* Matches CloudStream's real implementation from RequestsHelper.kt + CloudflareKiller.kt.
*/
@AnyThread
class CloudflareKiller : Interceptor {
companion object {
const val TAG = "CloudflareKiller"
private val ERROR_CODES = listOf(403, 503)
private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare")
fun parseCookieMap(cookie: String): Map<String, String> {
return cookie.split(";").associate {
val split = it.split("=")
(split.getOrNull(0)?.trim() ?: "") to (split.getOrNull(1)?.trim() ?: "")
}.filter { it.key.isNotBlank() && it.value.isNotBlank() }
}
}
init {
try {
CookieManager.getInstance().removeAllCookies(null)
} catch (_: Exception) { }
}
val savedCookies: MutableMap<String, Map<String, String>> = mutableMapOf()
fun getCookieHeaders(url: String): Headers {
val userAgentHeaders = WebViewResolver.webViewUserAgent?.let {
mapOf("user-agent" to it)
} ?: emptyMap()
return getHeaders(userAgentHeaders, savedCookies[URI(url).host] ?: emptyMap())
}
override fun intercept(chain: Interceptor.Chain): Response = runBlocking {
val request = chain.request()
when (val cookies = savedCookies[request.url.host]) {
null -> {
val response = chain.proceed(request)
if (!(response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES)) {
return@runBlocking response
} else {
response.close()
bypassCloudflare(request)?.let {
Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
return@runBlocking it
}
}
}
else -> {
return@runBlocking proceed(request, cookies)
}
}
Log.w(TAG, "Failed cloudflare at: ${request.url}")
return@runBlocking chain.proceed(request)
}
private fun getWebViewCookie(url: String): String? {
return try {
CookieManager.getInstance()?.getCookie(url)
} catch (_: Exception) { null }
}
private fun trySolveWithSavedCookies(request: Request): Boolean {
return getWebViewCookie(request.url.toString())?.let { cookie ->
cookie.contains("cf_clearance").also { solved ->
if (solved) {
Log.d(TAG, "Found cf_clearance cookie for ${request.url.host}")
savedCookies[request.url.host] = parseCookieMap(cookie)
}
}
} ?: false
}
private suspend fun proceed(request: Request, cookies: Map<String, String>): Response {
val userAgentMap = WebViewResolver.getWebViewUserAgent()?.let {
mapOf("user-agent" to it)
} ?: emptyMap()
val requestCookies = request.header("Cookie")?.let { parseCookieMap(it) } ?: emptyMap()
val headers = getHeaders(
request.headers.toMap() + userAgentMap,
cookies + requestCookies
)
return app.baseClient.newCall(
request.newBuilder()
.headers(headers)
.build()
).execute()
}
private suspend fun bypassCloudflare(request: Request): Response? {
val url = request.url.toString()
if (!trySolveWithSavedCookies(request)) {
Log.d(TAG, "Loading webview to solve cloudflare for ${request.url}")
try {
WebViewResolver(
interceptUrl = Regex(".^"),
userAgent = null,
useOkhttp = false,
additionalUrls = listOf(Regex("."))
).resolveUsingWebView(url) {
trySolveWithSavedCookies(request)
}
} catch (e: Exception) {
Log.e(TAG, "WebView bypass failed: ${e.message}")
return null
}
}
val cookies = savedCookies[request.url.host]
if (cookies == null) {
Log.w(TAG, "No cf_clearance obtained for ${request.url.host}")
return null
}
return proceed(request, cookies)
}
private fun Headers.toMap(): Map<String, String> {
return buildMap {
for (i in 0 until size) {
put(name(i), value(i))
}
}
}
}

View file

@ -0,0 +1,66 @@
package com.lagradost.cloudstream3.plugins
import android.app.Activity
import android.content.Context
import android.util.Log
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.extractorApis
/**
* The base class that CloudStream extensions extend in NuvioTV.
* Kept standalone (not extending BasePlugin) because BasePlugin's registration
* methods are final and can't be overridden. Extensions compiled against the
* real CloudStream app reference this class directly.
*/
open class Plugin {
private val _registeredMainAPIs = mutableListOf<MainAPI>()
private val _registeredExtractorAPIs = mutableListOf<ExtractorApi>()
val registeredMainAPIs: List<MainAPI> get() = _registeredMainAPIs
val registeredExtractorAPIs: List<ExtractorApi> get() = _registeredExtractorAPIs
/** Extensions can set this to provide a settings UI callback. No-op in NuvioTV. */
var openSettings: ((Context) -> Unit)? = null
/** Full file path to the plugin (matches BasePlugin's property). */
var filename: String? = null
/**
* No-arg load matching BasePlugin's pattern. Extensions compiled against
* CloudStream may override this instead of load(Activity?).
*/
open fun load() {}
/**
* Called when the plugin is loaded. Override to register APIs.
* The [activity] parameter may be null when loaded outside an Activity context.
* Delegates to no-arg load() so BasePlugin-style extensions get invoked.
*/
@Suppress("UNUSED_PARAMETER")
open fun load(activity: Activity?) {
load()
}
fun registerMainAPI(element: MainAPI) {
Log.d("CS3Plugin", "registerMainAPI called: ${element.name} (${element.javaClass.name})")
_registeredMainAPIs.add(element)
// Also register globally for extensions that access APIHolder directly
element.sourcePlugin = this.filename
try {
com.lagradost.cloudstream3.APIHolder.addPluginMapping(element)
} catch (_: Exception) {}
}
fun registerExtractorAPI(element: ExtractorApi) {
Log.d("CS3Plugin", "registerExtractorAPI called: ${element.name} (${element.javaClass.name})")
_registeredExtractorAPIs.add(element)
element.sourcePlugin = this.filename
extractorApis.add(element)
}
// Some extensions call these overloads
open fun load(context: Context) {
load(context as? Activity)
}
}

View file

@ -0,0 +1,16 @@
@file:Suppress("unused")
package com.lagradost.cloudstream3.plugins
/** Stub for CloudStream PluginManager referenced by some extensions. */
object PluginManager {
data class PluginData(
val name: String = "",
val url: String = "",
val internalName: String = "",
val version: Int = 0
)
fun getPluginsOnline(): Array<PluginData> = emptyArray()
fun unloadPlugin(filePath: String) {}
}

View file

@ -0,0 +1,131 @@
@file:Suppress("unused")
package com.lagradost.cloudstream3.utils
import android.content.Context
import android.content.SharedPreferences
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.lagradost.cloudstream3.mvvm.logError
import androidx.core.content.edit
const val PREFERENCES_NAME = "cs3_plugin_preferences"
/**
* DataStore stub matching real CloudStream3's DataStore object.
* Provides SharedPreferences-based storage for extension settings.
*/
object DataStore {
val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
private fun getPreferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
}
fun Context.getSharedPrefs(): SharedPreferences {
return getPreferences(this)
}
fun Context.getDefaultSharedPrefs(): SharedPreferences {
return getPreferences(this)
}
fun getFolderName(folder: String, path: String): String {
return "${folder}/${path}"
}
fun Context.getKeys(folder: String): List<String> {
val fixedFolder = folder.trimEnd('/') + "/"
return this.getSharedPrefs().all.keys.filter { it.startsWith(fixedFolder) }
}
fun Context.containsKey(path: String): Boolean {
return getSharedPrefs().contains(path)
}
fun Context.containsKey(folder: String, path: String): Boolean {
return containsKey(getFolderName(folder, path))
}
fun Context.removeKey(path: String) {
try {
val prefs = getSharedPrefs()
if (prefs.contains(path)) {
prefs.edit { remove(path) }
}
} catch (e: Exception) {
logError(e)
}
}
fun Context.removeKey(folder: String, path: String) {
removeKey(getFolderName(folder, path))
}
fun Context.removeKeys(folder: String): Int {
val keys = getKeys("$folder/")
try {
getSharedPrefs().edit {
keys.forEach { value -> remove(value) }
}
return keys.size
} catch (e: Exception) {
logError(e)
return 0
}
}
fun <T> Context.setKey(path: String, value: T) {
try {
getSharedPrefs().edit {
putString(path, mapper.writeValueAsString(value))
}
} catch (e: Exception) {
logError(e)
}
}
fun <T> Context.setKey(folder: String, path: String, value: T) {
setKey(getFolderName(folder, path), value)
}
inline fun <reified T : Any> String.toKotlinObject(): T {
return mapper.readValue(this, T::class.java)
}
fun <T> String.toKotlinObject(valueType: Class<T>): T {
return mapper.readValue(this, valueType)
}
fun <T> Context.getKey(path: String, valueType: Class<T>): T? {
try {
val json: String = getSharedPrefs().getString(path, null) ?: return null
return json.toKotlinObject(valueType)
} catch (e: Exception) {
return null
}
}
inline fun <reified T : Any> Context.getKey(path: String, defVal: T?): T? {
try {
val json: String = getSharedPrefs().getString(path, null) ?: return defVal
return json.toKotlinObject()
} catch (e: Exception) {
return null
}
}
inline fun <reified T : Any> Context.getKey(path: String): T? {
return getKey(path, null)
}
inline fun <reified T : Any> Context.getKey(folder: String, path: String): T? {
return getKey(getFolderName(folder, path), null)
}
inline fun <reified T : Any> Context.getKey(folder: String, path: String, defVal: T?): T? {
return getKey(getFolderName(folder, path), defVal) ?: defVal
}
}

View file

@ -3,6 +3,7 @@ package com.nuvio.tv
import android.os.Bundle
import android.content.Context
import android.content.res.Configuration
import androidx.core.os.ConfigurationCompat
import android.util.Log
import androidx.compose.ui.platform.LocalView
import androidx.metrics.performance.JankStats
@ -27,6 +28,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -54,6 +56,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -82,7 +85,9 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -96,6 +101,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.ModalNavigationDrawer
import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults
import androidx.tv.material3.Text
import androidx.tv.material3.rememberDrawerState
import com.nuvio.tv.core.profile.ProfileManager
@ -104,10 +110,13 @@ import com.nuvio.tv.data.local.AppOnboardingDataStore
import com.nuvio.tv.data.local.LayoutPreferenceDataStore
import com.nuvio.tv.data.local.ThemeDataStore
import com.nuvio.tv.data.repository.TraktProgressService
import com.nuvio.tv.domain.model.AppFont
import com.nuvio.tv.domain.model.AppTheme
import com.nuvio.tv.domain.model.AuthState
import com.nuvio.tv.core.sync.ProfileSettingsSyncService
import com.nuvio.tv.core.sync.ProfileSyncService
import com.nuvio.tv.core.sync.StartupSyncService
import com.nuvio.tv.data.remote.supabase.AvatarRepository
import com.nuvio.tv.ui.navigation.NuvioNavHost
import com.nuvio.tv.ui.navigation.Screen
import com.nuvio.tv.ui.components.NuvioScrollDefaults
@ -132,6 +141,9 @@ import coil.request.ImageRequest
import androidx.compose.ui.res.stringResource
import com.nuvio.tv.R
val LocalSidebarExpanded = compositionLocalOf { false }
val LocalContentFocusRequester = compositionLocalOf { FocusRequester.Default }
data class DrawerItem(
val route: String,
val label: String,
@ -141,6 +153,7 @@ data class DrawerItem(
private data class MainUiPrefs(
val theme: AppTheme = AppTheme.WHITE,
val font: AppFont = AppFont.INTER,
val hasChosenLayout: Boolean? = null,
val sidebarCollapsed: Boolean = false,
val modernSidebarEnabled: Boolean = false,
@ -162,6 +175,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var startupSyncService: StartupSyncService
@Inject
lateinit var profileSettingsSyncService: ProfileSettingsSyncService
@Inject
lateinit var profileSyncService: ProfileSyncService
@ -174,6 +190,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var appOnboardingDataStore: AppOnboardingDataStore
@Inject
lateinit var avatarRepository: AvatarRepository
private lateinit var jankStats: JankStats
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@ -187,6 +206,9 @@ class MainActivity : ComponentActivity() {
config.setLocale(locale)
super.attachBaseContext(newBase.createConfigurationContext(config))
} else {
val systemLocale = ConfigurationCompat.getLocales(newBase.resources.configuration)[0]
?: Locale.getDefault(Locale.Category.DISPLAY)
Locale.setDefault(systemLocale)
super.attachBaseContext(newBase)
}
}
@ -195,8 +217,12 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
window?.setBackgroundDrawable(null)
// Store Activity reference for CloudStream extensions that need it in plugin.load()
com.lagradost.cloudstream3.AcraApplication.setActivity(this)
setContent {
var hasSelectedProfileThisSession by remember { mutableStateOf(false) }
var hasSelectedProfileThisSession by rememberSaveable { mutableStateOf(false) }
var onboardingCompletedThisSession by remember { mutableStateOf(false) }
var onboardingProfileSyncInProgress by remember { mutableStateOf(false) }
val hasSeenAuthQrOnFirstLaunch by appOnboardingDataStore
@ -217,33 +243,63 @@ class MainActivity : ComponentActivity() {
val activeProfile = remember(activeProfileId, profiles) {
profiles.firstOrNull { it.id == activeProfileId }
}
var profilePinStates by remember { mutableStateOf<Map<Int, Boolean>>(emptyMap()) }
LaunchedEffect(authState, profiles) {
if (authState is AuthState.FullAccount) {
profileSyncService.pullProfileLockStates()
.onSuccess { profilePinStates = it }
.onFailure { profilePinStates = emptyMap() }
} else {
profilePinStates = emptyMap()
}
}
val activeProfileHasPin = remember(activeProfileId, profilePinStates) {
profilePinStates[activeProfileId] == true
}
var avatarCatalog by remember { mutableStateOf(emptyList<com.nuvio.tv.data.remote.supabase.AvatarCatalogItem>()) }
LaunchedEffect(Unit) {
avatarCatalog = runCatching { avatarRepository.getAvatarCatalog() }
.getOrDefault(emptyList())
}
val activeProfileAvatarImageUrl = remember(activeProfile, avatarCatalog) {
activeProfile?.avatarId?.let { avatarRepository.getAvatarImageUrl(it, avatarCatalog) }
}
val mainUiPrefsFlow = remember(themeDataStore, layoutPreferenceDataStore) {
combine(
themeDataStore.selectedTheme,
themeDataStore.selectedFont,
layoutPreferenceDataStore.hasChosenLayout,
layoutPreferenceDataStore.sidebarCollapsedByDefault,
layoutPreferenceDataStore.modernSidebarEnabled,
layoutPreferenceDataStore.modernSidebarBlurEnabled
) { theme, hasChosenLayout, sidebarCollapsed, modernSidebarEnabled, modernSidebarBlurPref ->
) { theme, font, hasChosenLayout, sidebarCollapsed, modernSidebarEnabled ->
MainUiPrefs(
theme = theme,
font = font,
hasChosenLayout = hasChosenLayout,
sidebarCollapsed = sidebarCollapsed,
modernSidebarEnabled = modernSidebarEnabled,
modernSidebarBlurPref = modernSidebarBlurPref
)
}.combine(layoutPreferenceDataStore.modernSidebarBlurEnabled) { prefs, modernSidebarBlurPref ->
prefs.copy(modernSidebarBlurPref = modernSidebarBlurPref)
}
}
val mainUiPrefs by mainUiPrefsFlow.collectAsState(initial = MainUiPrefs(hasChosenLayout = null))
NuvioTheme(appTheme = mainUiPrefs.theme) {
NuvioTheme(appTheme = mainUiPrefs.theme, appFont = mainUiPrefs.font) {
CompositionLocalProvider(
LocalBringIntoViewSpec provides NuvioScrollDefaults.smoothScrollSpec
) {
Surface(
modifier = Modifier.fillMaxSize(),
shape = RectangleShape
shape = RectangleShape,
colors = SurfaceDefaults.colors(
containerColor = NuvioColors.Background
)
) {
if (hasSeenAuthQrOnFirstLaunch == null) {
Box(
@ -301,7 +357,7 @@ class MainActivity : ComponentActivity() {
}
val shouldShowProfileSelection =
!hasSelectedProfileThisSession && profiles.size > 1
!hasSelectedProfileThisSession && (profiles.size > 1 || activeProfileHasPin)
if (shouldShowProfileSelection) {
ProfileSelectionScreen(
@ -415,6 +471,8 @@ class MainActivity : ComponentActivity() {
hideBuiltInHeaders = hideBuiltInHeadersForFloatingPill,
activeProfileName = activeProfile?.name ?: "",
activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5",
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = profiles.size > 1,
onSwitchProfile = { hasSelectedProfileThisSession = false },
onExitApp = {
finishAffinity()
@ -433,6 +491,8 @@ class MainActivity : ComponentActivity() {
hideBuiltInHeaders = false,
activeProfileName = activeProfile?.name ?: "",
activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5",
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = profiles.size > 1,
onSwitchProfile = { hasSelectedProfileThisSession = false },
onExitApp = {
finishAffinity()
@ -467,6 +527,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
if (::jankStats.isInitialized) jankStats.isTrackingEnabled = true
startupSyncService.requestSyncNow(includeProfileSettings = false)
lifecycleScope.launch {
traktProgressService.refreshNow()
}
}
override fun onPause() {
@ -476,10 +540,12 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
startupSyncService.requestSyncNow()
lifecycleScope.launch {
traktProgressService.refreshNow()
}
profileSettingsSyncService.requestForegroundPull()
}
override fun onDestroy() {
super.onDestroy()
com.lagradost.cloudstream3.AcraApplication.setActivity(null)
}
}
@ -496,6 +562,8 @@ private fun LegacySidebarScaffold(
hideBuiltInHeaders: Boolean,
activeProfileName: String,
activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit,
onExitApp: () -> Unit
) {
@ -510,9 +578,11 @@ private fun LegacySidebarScaffold(
}
val closedDrawerWidth = if (sidebarCollapsed) 0.dp else 72.dp
val openDrawerWidth = 216.dp
val openDrawerWidth = 196.dp
val focusManager = LocalFocusManager.current
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
val contentFocusRequester = remember { FocusRequester() }
var pendingContentFocusTransfer by remember { mutableStateOf(false) }
var pendingSidebarFocusRequest by remember { mutableStateOf(false) }
@ -530,7 +600,7 @@ private fun LegacySidebarScaffold(
return@LaunchedEffect
}
repeat(2) { withFrameNanos { } }
focusManager.moveFocus(FocusDirection.Right)
runCatching { contentFocusRequester.requestFocus() }
pendingContentFocusTransfer = false
}
@ -564,7 +634,8 @@ private fun LegacySidebarScaffold(
.padding(12.dp)
.selectableGroup()
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.key == Key.DirectionRight && keyEvent.type == KeyEventType.KeyDown) {
val closeKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
if (keyEvent.key == closeKey && keyEvent.type == KeyEventType.KeyDown) {
drawerState.setValue(DrawerValue.Closed)
pendingContentFocusTransfer = false
true
@ -574,21 +645,69 @@ private fun LegacySidebarScaffold(
}
) {
val isExpanded = drawerValue == DrawerValue.Open
val itemWidth = if (isExpanded) 156.dp else 48.dp
if (isExpanded) {
Spacer(modifier = Modifier.height(30.dp))
Image(
painter = painterResource(id = R.drawable.app_logo_wordmark),
contentDescription = "NuvioTV",
modifier = Modifier
.fillMaxWidth()
.height(42.dp)
)
if (showProfileSelector && activeProfileName.isNotEmpty()) {
var isProfileFocused by remember { mutableStateOf(false) }
val profileItemShape = RoundedCornerShape(32.dp)
val profileLeadingInset = 18.dp
val profileAvatarSize = 34.dp
val profileLabelStart = 60.dp
val profileGapAfterAvatar =
(profileLabelStart - profileLeadingInset - profileAvatarSize).coerceAtLeast(0.dp)
val profileBgColor by animateColorAsState(
targetValue = if (isProfileFocused) NuvioColors.FocusBackground else Color.Transparent,
label = "legacyProfileItemBg"
)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier
.width(itemWidth)
.height(52.dp)
.background(color = profileBgColor, shape = profileItemShape)
.onFocusChanged { isProfileFocused = it.isFocused }
.clickable {
onSwitchProfile()
drawerState.setValue(DrawerValue.Closed)
},
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(profileLeadingInset))
ProfileAvatarCircle(
name = activeProfileName,
colorHex = activeProfileColorHex,
size = profileAvatarSize,
avatarImageUrl = activeProfileAvatarImageUrl
)
Spacer(modifier = Modifier.width(profileGapAfterAvatar))
Text(
text = activeProfileName,
color = if (isProfileFocused) NuvioColors.TextPrimary else NuvioColors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
fontWeight = FontWeight.SemiBold
)
}
}
} else {
Image(
painter = painterResource(id = R.drawable.app_logo_wordmark),
contentDescription = "NuvioTV",
modifier = Modifier
.fillMaxWidth()
.height(42.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.weight(1f))
val itemWidth = if (isExpanded) 176.dp else 48.dp
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
@ -618,64 +737,28 @@ private fun LegacySidebarScaffold(
}
Spacer(modifier = Modifier.weight(1f))
if (isExpanded && activeProfileName.isNotEmpty()) {
var isProfileFocused by remember { mutableStateOf(false) }
val profileItemShape = RoundedCornerShape(32.dp)
val profileBgColor by animateColorAsState(
targetValue = if (isProfileFocused) NuvioColors.FocusBackground else Color.Transparent,
label = "legacyProfileItemBg"
)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier
.width(itemWidth)
.height(52.dp)
.clip(profileItemShape)
.background(color = profileBgColor, shape = profileItemShape)
.onFocusChanged { isProfileFocused = it.isFocused }
.clickable {
onSwitchProfile()
drawerState.setValue(DrawerValue.Closed)
},
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(10.dp))
ProfileAvatarCircle(
name = activeProfileName,
colorHex = activeProfileColorHex,
size = 34.dp
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = activeProfileName,
color = if (isProfileFocused) NuvioColors.TextPrimary else NuvioColors.TextSecondary,
maxLines = 1,
textAlign = TextAlign.Start
)
}
}
}
}
}
}
) {
val contentStartPadding = if (showSidebar) closedDrawerWidth else 0.dp
val contentStartPadding by animateDpAsState(
targetValue = if (showSidebar) closedDrawerWidth else 0.dp,
animationSpec = tween(350),
label = "contentStartPadding"
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = contentStartPadding)
.onKeyEvent { keyEvent ->
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
if (
showSidebar &&
drawerState.currentValue == DrawerValue.Closed &&
keyEvent.type == KeyEventType.KeyDown &&
keyEvent.key == Key.DirectionLeft
keyEvent.key == openKey
) {
if (focusManager.moveFocus(FocusDirection.Left)) {
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
true
} else {
pendingSidebarFocusRequest = true
@ -687,11 +770,16 @@ private fun LegacySidebarScaffold(
}
}
) {
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
CompositionLocalProvider(
LocalSidebarExpanded provides (drawerState.currentValue == DrawerValue.Open),
LocalContentFocusRequester provides contentFocusRequester
) {
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
}
}
}
}
@ -711,7 +799,7 @@ private fun LegacySidebarButton(
val backgroundColor by animateColorAsState(
targetValue = when {
isFocused -> NuvioColors.FocusBackground
selected -> NuvioColors.BackgroundCard
expanded && selected -> NuvioColors.Secondary
else -> Color.Transparent
},
label = "legacySidebarItemBackground"
@ -719,17 +807,26 @@ private fun LegacySidebarButton(
val contentColor by animateColorAsState(
targetValue = when {
isFocused -> NuvioColors.TextPrimary
selected -> NuvioColors.TextPrimary
expanded && selected -> NuvioColors.OnSecondary
else -> NuvioColors.TextSecondary
},
label = "legacySidebarItemContent"
)
val iconTint by animateColorAsState(
targetValue = when {
isFocused -> NuvioColors.TextPrimary
expanded && selected -> NuvioColors.OnSecondary
selected -> NuvioColors.Secondary
!expanded -> NuvioColors.TextTertiary
else -> NuvioColors.TextSecondary
},
label = "legacySidebarItemIconTint"
)
Box(
modifier = modifier
.height(52.dp)
.focusProperties { canFocus = expanded }
.clip(itemShape)
.background(color = backgroundColor, shape = itemShape)
.onFocusChanged { isFocused = it.isFocused }
.clickable(onClick = onClick),
@ -737,7 +834,7 @@ private fun LegacySidebarButton(
DrawerItemIcon(
iconRes = iconRes,
icon = icon,
tint = contentColor,
tint = iconTint,
modifier = if (expanded) {
Modifier
.size(22.dp)
@ -779,6 +876,8 @@ private fun ModernSidebarScaffold(
hideBuiltInHeaders: Boolean,
activeProfileName: String,
activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit,
onExitApp: () -> Unit
) {
@ -787,6 +886,8 @@ private fun ModernSidebarScaffold(
val openSidebarWidth = 262.dp
val focusManager = LocalFocusManager.current
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
val contentFocusRequester = remember { FocusRequester() }
val drawerItemFocusRequesters = remember(drawerItems) {
drawerItems.associate { item -> item.route to FocusRequester() }
}
@ -800,6 +901,8 @@ private fun ModernSidebarScaffold(
val keepFloatingPillExpanded = selectedDrawerRoute == Screen.Settings.route
val keepSidebarFocusDuringCollapse =
isSidebarExpanded || sidebarCollapsePending || pendingContentFocusTransfer
val hasSidebarProfileItem = showProfileSelector && activeProfileName.isNotEmpty()
val sidebarTopBoundaryIndex = if (hasSidebarProfileItem) drawerItems.size else 0
LaunchedEffect(showSidebar) {
if (!showSidebar) {
@ -954,7 +1057,7 @@ private fun ModernSidebarScaffold(
return@LaunchedEffect
}
repeat(2) { withFrameNanos { } }
focusManager.moveFocus(FocusDirection.Right)
runCatching { contentFocusRequester.requestFocus() }
pendingContentFocusTransfer = false
}
@ -979,13 +1082,6 @@ private fun ModernSidebarScaffold(
Box(
modifier = Modifier
.fillMaxSize()
.then(
if (shouldApplySidebarHaze) {
Modifier.haze(sidebarHazeState)
} else {
Modifier
}
)
.onPreviewKeyEvent { keyEvent ->
if (
isSidebarExpanded &&
@ -1008,8 +1104,9 @@ private fun ModernSidebarScaffold(
else -> Unit
}
}
if (keyEvent.key == Key.DirectionLeft) {
if (focusManager.moveFocus(FocusDirection.Left)) {
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
if (keyEvent.key == openKey) {
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
true
} else {
isSidebarExpanded = true
@ -1025,11 +1122,16 @@ private fun ModernSidebarScaffold(
}
}
) {
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
CompositionLocalProvider(
LocalSidebarExpanded provides isSidebarExpanded,
LocalContentFocusRequester provides contentFocusRequester
) {
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
}
}
if (showSidebar && (sidebarVisible || sidebarWidth > 0.dp)) {
@ -1060,17 +1162,22 @@ private fun ModernSidebarScaffold(
}
when (keyEvent.key) {
Key.DirectionUp -> {
focusedDrawerIndex == 0
focusedDrawerIndex == sidebarTopBoundaryIndex
}
Key.DirectionDown -> {
focusedDrawerIndex > drawerItems.lastIndex
focusedDrawerIndex == drawerItems.lastIndex
}
Key.DirectionRight -> {
pendingContentFocusTransfer = false
sidebarCollapsePending = true
true
Key.DirectionRight, Key.DirectionLeft -> {
val collapseKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
if (keyEvent.key == collapseKey) {
pendingContentFocusTransfer = false
sidebarCollapsePending = true
true
} else {
false
}
}
else -> false
@ -1105,6 +1212,8 @@ private fun ModernSidebarScaffold(
},
activeProfileName = activeProfileName,
activeProfileColorHex = activeProfileColorHex,
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = showProfileSelector,
onSwitchProfile = onSwitchProfile
)
}
@ -1125,7 +1234,7 @@ private fun ModernSidebarScaffold(
.align(Alignment.TopStart)
.offset {
IntOffset(
(40.dp + sidebarSlideX + sidebarDeflateOffsetX).roundToPx(),
14.dp.roundToPx(),
(16.dp + sidebarDeflateOffsetY).roundToPx()
)
}
@ -1199,7 +1308,6 @@ private fun CollapsedSidebarPill(
.graphicsLayer {
shape = pillShape
clip = true
compositingStrategy = CompositingStrategy.Offscreen
}
.clip(pillShape)
.background(brush = pillBackgroundBrush, shape = pillShape)

View file

@ -41,10 +41,10 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
@ -57,6 +57,10 @@ import coil.compose.rememberAsyncImagePainter
import coil.decode.SvgDecoder
import coil.request.ImageRequest
private val SidebarLeadingVisualSize = 34.dp
private val SidebarContentGap = 14.dp
private val SidebarProfileContentGap = 18.dp
@Composable
internal fun ModernSidebarBlurPanel(
drawerItems: List<DrawerItem>,
@ -75,6 +79,8 @@ internal fun ModernSidebarBlurPanel(
onDrawerItemClick: (String) -> Unit,
activeProfileName: String,
activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit
) {
val delayedBlurProgress =
@ -129,19 +135,43 @@ internal fun ModernSidebarBlurPanel(
.border(width = 1.dp, color = panelBorderColor, shape = panelShape)
.padding(horizontal = 12.dp, vertical = 14.dp)
) {
val headerLogoRes = if (isSidebarExpanded) R.drawable.app_logo_wordmark else R.drawable.app_logo_mark
val headerLogoHeight = if (isSidebarExpanded) 42.dp else 34.dp
val headerLogoContentDescription = if (isSidebarExpanded) "NuvioTV" else "Nuvio"
Image(
painter = painterResource(id = headerLogoRes),
contentDescription = headerLogoContentDescription,
modifier = Modifier
.fillMaxWidth()
.height(headerLogoHeight)
.offset(y = 12.dp),
contentScale = ContentScale.Fit
)
if (showProfileSelector && activeProfileName.isNotEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.offset(y = 12.dp),
contentAlignment = Alignment.Center
) {
SidebarProfileItem(
profileName = activeProfileName,
profileColorHex = activeProfileColorHex,
profileAvatarImageUrl = activeProfileAvatarImageUrl,
focusEnabled = keepSidebarFocusDuringCollapse,
labelAlpha = sidebarLabelAlpha,
onFocusChanged = { focused ->
if (focused) onDrawerItemFocused(drawerItems.size)
},
onClick = onSwitchProfile,
modifier = Modifier.fillMaxWidth(0.92f)
)
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.offset(y = 12.dp),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.app_logo_wordmark),
contentDescription = "NuvioTV",
modifier = Modifier
.fillMaxWidth(0.72f)
.height(36.dp),
alpha = sidebarLabelAlpha
)
}
}
Spacer(modifier = Modifier.height(16.dp))
@ -179,25 +209,6 @@ internal fun ModernSidebarBlurPanel(
}
}
if (activeProfileName.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
SidebarProfileItem(
profileName = activeProfileName,
profileColorHex = activeProfileColorHex,
focusEnabled = keepSidebarFocusDuringCollapse,
labelAlpha = sidebarLabelAlpha,
onFocusChanged = { focused ->
if (focused) onDrawerItemFocused(drawerItems.size)
},
onClick = onSwitchProfile,
modifier = Modifier.fillMaxWidth(0.92f)
)
}
}
}
}
@ -233,9 +244,6 @@ private fun SidebarNavigationItem(
val contentColor = if (selected) Color(0xFF10151F) else Color.White
val iconCircleColor = if (selected) Color(0xFFE7E2EF) else Color(0xFF6A6A74)
val iconContainerSize = 34.dp
val contentGap = 14.dp
Row(
modifier = modifier
.clip(shape)
@ -259,7 +267,7 @@ private fun SidebarNavigationItem(
) {
Box(
modifier = Modifier
.size(iconContainerSize)
.size(SidebarLeadingVisualSize)
.clip(CircleShape)
.background(iconCircleColor)
.padding(6.dp)
@ -285,7 +293,7 @@ private fun SidebarNavigationItem(
)
}
}
Spacer(modifier = Modifier.width(contentGap))
Spacer(modifier = Modifier.width(SidebarContentGap))
Text(
text = label,
@ -297,7 +305,6 @@ private fun SidebarNavigationItem(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(iconContainerSize + contentGap))
}
}
@ -305,6 +312,7 @@ private fun SidebarNavigationItem(
private fun SidebarProfileItem(
profileName: String,
profileColorHex: String,
profileAvatarImageUrl: String?,
focusEnabled: Boolean,
labelAlpha: Float,
onFocusChanged: (Boolean) -> Unit,
@ -323,9 +331,6 @@ private fun SidebarProfileItem(
animationSpec = tween(durationMillis = 180),
label = "profileItemBorder"
)
val contentGap = 14.dp
val iconContainerSize = 34.dp
Row(
modifier = modifier
.clip(shape)
@ -347,19 +352,29 @@ private fun SidebarProfileItem(
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
ProfileAvatarCircle(
name = profileName,
colorHex = profileColorHex,
size = iconContainerSize
)
Spacer(modifier = Modifier.width(contentGap))
Box(
modifier = Modifier.size(SidebarLeadingVisualSize),
contentAlignment = Alignment.Center
) {
ProfileAvatarCircle(
name = profileName,
colorHex = profileColorHex,
size = SidebarLeadingVisualSize,
avatarImageUrl = profileAvatarImageUrl
)
}
Spacer(modifier = Modifier.width(SidebarProfileContentGap))
Text(
text = profileName,
color = Color.White,
modifier = Modifier
.weight(1f)
.graphicsLayer { alpha = labelAlpha },
style = androidx.tv.material3.MaterialTheme.typography.titleLarge,
maxLines = 1
style = androidx.tv.material3.MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.SemiBold
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View file

@ -1,13 +1,29 @@
package com.nuvio.tv
import android.app.Application
import android.os.Build
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import android.util.Log
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.app
import com.lagradost.nicehttp.ignoreAllSSLErrors
import com.nuvio.tv.core.sync.StartupSyncService
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import okhttp3.Cache
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import org.conscrypt.Conscrypt
import java.io.File
import java.security.Security
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@HiltAndroidApp
@ -15,15 +31,79 @@ class NuvioApplication : Application(), ImageLoaderFactory {
@Inject lateinit var startupSyncService: StartupSyncService
companion object {
/**
* Shared cookie jar for CloudStream extension HTTP requests.
* Accessible so the player's OkHttpClient can share cookies
* obtained during scraping (e.g., session tokens needed for playback).
*/
val extensionCookieJar: CookieJar = object : CookieJar {
private val store = ConcurrentHashMap<String, MutableList<Cookie>>()
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return store[url.host]?.filter { cookie ->
cookie.expiresAt > System.currentTimeMillis()
} ?: emptyList()
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val hostCookies = store.getOrPut(url.host) { mutableListOf() }
cookies.forEach { newCookie ->
hostCookies.removeAll { it.name == newCookie.name }
hostCookies.add(newCookie)
}
}
}
}
override fun onCreate() {
super.onCreate()
// Install Conscrypt as the primary TLS provider.
// This gives OkHttp a browser-compatible TLS fingerprint (JA3/JA4),
// which is critical for bypassing Cloudflare's bot detection.
// Without it, Cloudflare blocks OkHttp even with valid cf_clearance cookies.
try {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
} catch (e: Exception) {
Log.w("NuvioApplication", "Failed to install Conscrypt: ${e.message}")
}
// Initialize the CloudStream NiceHTTP singleton's OkHttpClient.
// Matches CloudStream's RequestsHelper.initClient() setup.
// Wrapped in try catch because java.lang.BootstrapMethodError
// doesn't exist on API < 26 (e.g. Fire TV 4K Gen 1 running Android 7.1.2)
try {
app.baseClient = OkHttpClient.Builder()
.cookieJar(extensionCookieJar)
.followRedirects(true)
.followSslRedirects(true)
.ignoreAllSSLErrors()
.cache(Cache(
directory = File(cacheDir, "http_cache"),
maxSize = 50L * 1024L * 1024L
))
.build()
} catch (e: Throwable) {
Log.w("NuvioApplication", "Failed to initialize NiceHttp client (API ${Build.VERSION.SDK_INT}): ${e.message}")
}
// Set AcraApplication context early so CS3 stubs can access it
AcraApplication.context = this
}
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.25)
.maxSizePercent(0.33)
.build()
}
.diskCache {

View file

@ -2,6 +2,7 @@ package com.nuvio.tv.core.auth
import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.local.AuthSessionNoticeDataStore
import com.nuvio.tv.data.remote.supabase.TvLoginExchangeResult
import com.nuvio.tv.data.remote.supabase.TvLoginPollResult
import com.nuvio.tv.data.remote.supabase.TvLoginStartResult
@ -34,7 +35,8 @@ private const val TAG = "AuthManager"
class AuthManager @Inject constructor(
private val auth: Auth,
private val postgrest: Postgrest,
private val httpClient: OkHttpClient
private val httpClient: OkHttpClient,
private val authSessionNoticeDataStore: AuthSessionNoticeDataStore
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val json = Json { ignoreUnknownKeys = true }
@ -62,8 +64,10 @@ class AuthManager @Inject constructor(
}
if (user.email.isNullOrBlank()) {
_authState.value = AuthState.SignedOut
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
} else {
_authState.value = AuthState.FullAccount(userId = user.id, email = user.email!!)
authSessionNoticeDataStore.markNuvioAuthenticated()
}
}
}
@ -78,12 +82,14 @@ class AuthManager @Inject constructor(
cachedEffectiveUserId = null
cachedEffectiveUserSourceUserId = null
_authState.value = AuthState.SignedOut
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
}
}
} else {
cachedEffectiveUserId = null
cachedEffectiveUserSourceUserId = null
_authState.value = AuthState.SignedOut
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
}
}
is SessionStatus.Initializing -> {
@ -200,7 +206,12 @@ class AuthManager @Inject constructor(
}
}
suspend fun signOut() {
suspend fun signOut(explicit: Boolean = true) {
if (explicit) {
authSessionNoticeDataStore.markNuvioExplicitLogout()
} else {
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
}
try {
auth.signOut()
} catch (e: Exception) {

View file

@ -1,10 +1,13 @@
package com.nuvio.tv.core.di
import android.content.Context
import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.remote.api.AddonApi
import com.nuvio.tv.data.remote.api.AniSkipApi
import com.nuvio.tv.data.remote.api.AnimeSkipApi
import com.nuvio.tv.data.remote.api.ArmApi
import com.nuvio.tv.data.remote.api.DonationsApi
import com.nuvio.tv.data.remote.api.GitHubReleaseApi
import com.nuvio.tv.data.remote.api.TraktApi
import com.nuvio.tv.data.remote.api.TrailerApi
@ -26,10 +29,22 @@ import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import com.nuvio.tv.core.network.IPv4FirstDns
import java.io.File
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Named
import javax.inject.Singleton
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
private object TraktHttpTrace {
private val requestCounter = AtomicLong(0L)
fun nextRequestId(): Long = requestCounter.incrementAndGet()
}
@Module
@InstallIn(SingletonComponent::class)
@ -43,15 +58,39 @@ object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient = OkHttpClient.Builder()
.cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)) // 50 MB disk cache
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
else HttpLoggingInterceptor.Level.NONE
})
.build()
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient {
val trustAllManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, arrayOf<TrustManager>(trustAllManager), SecureRandom())
}
return OkHttpClient.Builder()
.dns(IPv4FirstDns())
.sslSocketFactory(sslContext.socketFactory, trustAllManager)
.hostnameVerifier { _, _ -> true }
.cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)) // 50 MB disk cache
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
// Prevent OkHttp from caching error responses (4xx/5xx).
.addNetworkInterceptor { chain ->
val response = chain.proceed(chain.request())
if (!response.isSuccessful) {
response.newBuilder()
.header("Cache-Control", "no-store")
.build()
} else {
response
}
}
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
else HttpLoggingInterceptor.Level.NONE
})
.build()
}
@Provides
@Singleton
@ -64,11 +103,53 @@ object NetworkModule {
val version = BuildConfig.VERSION_NAME.ifBlank { "dev" }
val newRequest = request.newBuilder()
.header("Content-Type", "application/json")
.header("User-Agent", "NuvioTV/$version")
.header("User-Agent", "Nuvio/$version")
.header("trakt-api-key", BuildConfig.TRAKT_CLIENT_ID)
.header("trakt-api-version", "2")
.build()
chain.proceed(newRequest)
if (!BuildConfig.DEBUG) {
return@addInterceptor chain.proceed(newRequest)
}
val requestId = TraktHttpTrace.nextRequestId()
val target = buildString {
append(newRequest.url.encodedPath)
newRequest.url.encodedQuery?.let { query ->
append('?')
append(query)
}
}
val startNs = System.nanoTime()
Log.d("TraktHttp", "REQ #$requestId ${newRequest.method} $target")
try {
val response = chain.proceed(newRequest)
val durationMs = (System.nanoTime() - startNs) / 1_000_000L
val retryAfter = response.header("Retry-After")
val rateLimit = response.header("X-Ratelimit")
val page = response.header("X-Pagination-Page")
val pageCount = response.header("X-Pagination-Page-Count")
val pageInfo = if (page != null || pageCount != null) {
" page=${page ?: "-"} pageCount=${pageCount ?: "-"}"
} else {
""
}
val retryInfo = retryAfter?.let { " retryAfter=${it}s" } ?: ""
val rateInfo = rateLimit?.let { " rate=$it" } ?: ""
Log.d(
"TraktHttp",
"RES #$requestId ${response.code} ${newRequest.method} $target ${durationMs}ms$retryInfo$pageInfo$rateInfo"
)
response
} catch (error: Exception) {
val durationMs = (System.nanoTime() - startNs) / 1_000_000L
Log.w(
"TraktHttp",
"ERR #$requestId ${newRequest.method} $target ${durationMs}ms ${error.javaClass.simpleName}: ${error.message}"
)
throw error
}
}
.build()
@ -181,6 +262,21 @@ object NetworkModule {
fun provideArmApi(@Named("arm") retrofit: Retrofit): ArmApi =
retrofit.create(ArmApi::class.java)
@Provides
@Singleton
@Named("animeSkipGql")
fun provideAnimeSkipGqlRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit =
Retrofit.Builder()
.baseUrl("https://api.anime-skip.com/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
@Provides
@Singleton
fun provideAnimeSkipApi(@Named("animeSkipGql") retrofit: Retrofit): AnimeSkipApi =
retrofit.create(AnimeSkipApi::class.java)
// --- GitHub Releases API (in-app updates) ---
@Provides
@ -198,6 +294,26 @@ object NetworkModule {
fun provideGitHubReleaseApi(@Named("github") retrofit: Retrofit): GitHubReleaseApi =
retrofit.create(GitHubReleaseApi::class.java)
@Provides
@Singleton
@Named("donations")
fun provideDonationsRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
val baseUrl = BuildConfig.DONATIONS_BASE_URL
.takeIf { it.isNotBlank() }
?: error("DONATIONS_BASE_URL is missing. Set it in local.properties or local.dev.properties.")
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun provideDonationsApi(@Named("donations") retrofit: Retrofit): DonationsApi =
retrofit.create(DonationsApi::class.java)
// --- Trailer API ---
@Provides

View file

@ -0,0 +1,43 @@
package com.nuvio.tv.core.di
import android.content.Context
import com.nuvio.tv.core.torrent.TorrServerApi
import com.nuvio.tv.core.torrent.TorrServerBinary
import com.nuvio.tv.core.torrent.TorrentService
import com.nuvio.tv.core.torrent.TorrentSettings
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object TorrentModule {
@Provides
@Singleton
fun provideTorrentSettings(
@ApplicationContext context: Context
): TorrentSettings = TorrentSettings(context)
@Provides
@Singleton
fun provideTorrServerBinary(
@ApplicationContext context: Context
): TorrServerBinary = TorrServerBinary(context)
@Provides
@Singleton
fun provideTorrServerApi(
binary: TorrServerBinary
): TorrServerApi = TorrServerApi(binary)
@Provides
@Singleton
fun provideTorrentService(
binary: TorrServerBinary,
api: TorrServerApi
): TorrentService = TorrentService(binary, api)
}

View file

@ -0,0 +1,17 @@
package com.nuvio.tv.core.network
import okhttp3.Dns
import java.net.Inet4Address
import java.net.InetAddress
/**
* Custom DNS that reorders resolved addresses to place IPv4 (Inet4Address)
* before IPv6 (Inet6Address). This avoids 60s timeout delays on networks
* with broken IPv6 routing (issue #651).
*/
class IPv4FirstDns(private val delegate: Dns = Dns.SYSTEM) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
val addresses = delegate.lookup(hostname)
return addresses.sortedBy { if (it is Inet4Address) 0 else 1 }
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import com.nuvio.tv.R
object ExternalPlayerLauncher {
@ -37,7 +38,7 @@ object ExternalPlayerLauncher {
} catch (e: Exception) {
Toast.makeText(
context,
"No external player found",
context.getString(R.string.player_no_external_player),
Toast.LENGTH_LONG
).show()
false

View file

@ -2,18 +2,23 @@ package com.nuvio.tv.core.player
import android.app.Activity
import android.content.Context
import android.hardware.display.DisplayManager
import android.media.MediaExtractor
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Display
import io.github.anilbeesetti.nextlib.mediainfo.MediaInfo
import io.github.anilbeesetti.nextlib.mediainfo.MediaInfoBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.Locale
/**
* Auto frame rate matching utility.
@ -22,28 +27,29 @@ import kotlin.math.roundToInt
object FrameRateUtils {
private const val TAG = "FrameRateUtils"
private const val SWITCH_TIMEOUT_MS = 5000L
private const val SWITCH_TIMEOUT_MS = 4000L
private const val REFRESH_MATCH_MIN_TOLERANCE_HZ = 0.08f
private const val NTSC_FILM_FPS = 24000f / 1001f
private const val CINEMA_24_FPS = 24f
private const val MIN_VALID_VIDEO_FPS = 10f
private const val MAX_VALID_VIDEO_FPS = 120f
private val NEXTLIB_HTTP_SCHEMES = setOf("http", "https")
private val LIVE_STREAM_EXTENSIONS = listOf(".m3u8", ".mpd", ".ism/manifest")
private const val MKV_EXTENSION = ".mkv"
private const val SWITCH_POLL_INTERVAL_MS = 60L
private const val SWITCH_REQUIRED_STABLE_POLLS = 2
private var displayManager: DisplayManager? = null
private var displayListener: DisplayManager.DisplayListener? = null
private var timeoutHandler: Handler? = null
private var timeoutRunnable: Runnable? = null
data class DisplayModeSwitchResult(
val appliedMode: Display.Mode,
val isFallback: Boolean
val appliedMode: Display.Mode
)
private var pendingAfterSwitch: ((DisplayModeSwitchResult) -> Unit)? = null
private var pendingDisplayId: Int? = null
private var pendingMode: Display.Mode? = null
private var originalModeId: Int? = null
data class FrameRateDetection(
val raw: Float,
val snapped: Float
val snapped: Float,
val videoWidth: Int? = null,
val videoHeight: Int? = null
)
private fun matchesTargetRefresh(refreshRate: Float, target: Float): Boolean {
@ -72,35 +78,6 @@ object FrameRateUtils {
return weight
}
private fun completeSwitch(reason: String) {
Log.d(TAG, "Display mode switch completed ($reason)")
val callback = pendingAfterSwitch
val requestedMode = pendingMode
val realMode = runCatching {
val displayId = pendingDisplayId
if (displayId != null) {
displayManager?.getDisplay(displayId)?.mode
} else {
null
}
}.getOrNull()
val appliedMode = realMode ?: requestedMode
val isFallback = requestedMode != null && realMode != null && realMode.modeId != requestedMode.modeId
cleanupDisplayListener()
if (callback != null && appliedMode != null) {
callback(DisplayModeSwitchResult(appliedMode = appliedMode, isFallback = isFallback))
}
}
private fun scheduleSwitchTimeout() {
timeoutHandler = Handler(Looper.getMainLooper())
timeoutRunnable = Runnable {
Log.w(TAG, "Display mode switch timeout after ${SWITCH_TIMEOUT_MS}ms")
completeSwitch("timeout")
}
timeoutHandler?.postDelayed(timeoutRunnable!!, SWITCH_TIMEOUT_MS)
}
private fun recordOriginalMode(display: Display) {
if (originalModeId == null) {
originalModeId = display.mode.modeId
@ -151,109 +128,153 @@ object FrameRateUtils {
}
}
fun matchFrameRate(
private fun chooseBestModeForFrameRate(
activeMode: Display.Mode,
modes: List<Display.Mode>,
frameRate: Float
): Display.Mode {
val modeExact = pickBestForTarget(modes, frameRate)
val modeDouble = pickBestForTarget(modes, frameRate * 2f)
val modePulldown = pickBestForTarget(modes, frameRate * 2.5f)
val modeFallback = modes.minByOrNull { refreshWeight(it.refreshRate, frameRate) }
return modeExact ?: modeDouble ?: modePulldown ?: modeFallback ?: activeMode
}
private fun hasValidVideoSize(videoWidth: Int?, videoHeight: Int?): Boolean {
return (videoWidth ?: 0) > 0 && (videoHeight ?: 0) > 0
}
private fun normalizedSize(width: Int, height: Int): Pair<Int, Int> {
return if (width >= height) width to height else height to width
}
private fun resolutionDistanceSquared(mode: Display.Mode, targetWidth: Int, targetHeight: Int): Long {
val (modeWidth, modeHeight) = normalizedSize(mode.physicalWidth, mode.physicalHeight)
val dw = modeWidth - targetWidth
val dh = modeHeight - targetHeight
return dw.toLong() * dw.toLong() + dh.toLong() * dh.toLong()
}
private fun selectModesForVideoResolution(
modes: List<Display.Mode>,
videoWidth: Int,
videoHeight: Int
): List<Display.Mode> {
if (modes.isEmpty()) return modes
val (targetWidth, targetHeight) = normalizedSize(videoWidth, videoHeight)
val minDistance = modes.minOfOrNull { resolutionDistanceSquared(it, targetWidth, targetHeight) } ?: return modes
return modes.filter { resolutionDistanceSquared(it, targetWidth, targetHeight) == minDistance }
}
suspend fun matchFrameRateAndWait(
activity: Activity,
frameRate: Float,
onBeforeSwitch: (() -> Unit)? = null,
onAfterSwitch: ((DisplayModeSwitchResult) -> Unit)? = null
): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
if (frameRate <= 0f) return false
videoWidth: Int? = null,
videoHeight: Int? = null,
resolutionMatchingEnabled: Boolean = false
): DisplayModeSwitchResult? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return null
if (frameRate <= 0f) return null
return try {
val window = activity.window ?: return false
val display = window.decorView.display ?: return false
val supportedModes = display.supportedModes
val switchPlan = withContext(Dispatchers.Main) {
val window = activity.window ?: return@withContext null
val display = window.decorView.display ?: return@withContext null
val activeMode = display.mode
if (supportedModes.size <= 1) return false
val sameSizeModes = supportedModes.filter {
val sameSizeModes = display.supportedModes.filter {
it.physicalWidth == activeMode.physicalWidth &&
it.physicalHeight == activeMode.physicalHeight
}
if (sameSizeModes.size <= 1) return false
// Kodi-like priority without user whitelist:
// exact -> 2x -> 3:2 pulldown -> weighted fallback.
val modeExact = pickBestForTarget(sameSizeModes, frameRate)
val modeDouble = pickBestForTarget(sameSizeModes, frameRate * 2f)
val modePulldown = pickBestForTarget(sameSizeModes, frameRate * 2.5f)
val modeFallback = sameSizeModes.minByOrNull { refreshWeight(it.refreshRate, frameRate) }
val modeBest = modeExact ?: modeDouble ?: modePulldown ?: modeFallback ?: activeMode
val switchNeeded = modeBest.modeId != activeMode.modeId
if (switchNeeded) {
Log.d(
TAG,
"Switching display mode: ${activeMode.refreshRate}Hz -> ${modeBest.refreshRate}Hz " +
"(video ${frameRate}fps)"
val candidateModes = if (resolutionMatchingEnabled && hasValidVideoSize(videoWidth, videoHeight)) {
selectModesForVideoResolution(
modes = display.supportedModes.toList(),
videoWidth = videoWidth ?: activeMode.physicalWidth,
videoHeight = videoHeight ?: activeMode.physicalHeight
)
} else {
sameSizeModes
}
if (candidateModes.isEmpty()) {
return@withContext Pair<Display.Mode?, DisplayModeSwitchResult?>(
null,
DisplayModeSwitchResult(activeMode)
)
}
if (!resolutionMatchingEnabled && candidateModes.size <= 1) {
return@withContext Pair<Display.Mode?, DisplayModeSwitchResult?>(
null,
DisplayModeSwitchResult(activeMode)
)
}
cleanupDisplayListener()
recordOriginalMode(display)
val modeBest = chooseBestModeForFrameRate(
activeMode = activeMode,
modes = candidateModes,
frameRate = frameRate
)
recordOriginalMode(display)
if (modeBest.modeId == activeMode.modeId) {
Log.d(TAG, "Display already at optimal rate ${activeMode.refreshRate}Hz for ${frameRate}fps")
return@withContext Pair<Display.Mode?, DisplayModeSwitchResult?>(
null,
DisplayModeSwitchResult(activeMode)
)
}
var completeImmediately = false
if (onAfterSwitch != null) {
pendingAfterSwitch = onAfterSwitch
pendingMode = modeBest
pendingDisplayId = display.displayId
displayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
override fun onDisplayRemoved(displayId: Int) = Unit
override fun onDisplayChanged(displayId: Int) {
if (displayId != pendingDisplayId) return
completeSwitch("displayChanged")
}
}
if (displayManager != null) {
displayManager?.registerDisplayListener(
displayListener,
Handler(Looper.getMainLooper())
)
scheduleSwitchTimeout()
} else {
completeImmediately = true
}
}
Log.d(
TAG,
"Switching display mode: ${activeMode.refreshRate}Hz -> ${modeBest.refreshRate}Hz " +
"(video ${frameRate}fps)"
)
onBeforeSwitch?.invoke()
val layoutParams = window.attributes
layoutParams.preferredDisplayModeId = modeBest.modeId
window.attributes = layoutParams
Pair(modeBest, null)
} ?: return null
val layoutParams = window.attributes
layoutParams.preferredDisplayModeId = modeBest.modeId
window.attributes = layoutParams
val immediateResult = switchPlan.second
if (immediateResult != null) return immediateResult
if (completeImmediately) {
completeSwitch("noDisplayManager")
val expectedMode = switchPlan.first ?: return null
var stablePolls = 0
var lastMode: Display.Mode? = null
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < SWITCH_TIMEOUT_MS) {
val mode = withContext(Dispatchers.Main) {
activity.window?.decorView?.display?.mode
} ?: break
lastMode = mode
val modeStable =
mode.modeId == expectedMode.modeId ||
matchesTargetRefresh(mode.refreshRate, expectedMode.refreshRate)
if (modeStable) {
stablePolls += 1
if (stablePolls >= SWITCH_REQUIRED_STABLE_POLLS) {
Log.d(
TAG,
"Display mode switch stabilized at ${mode.refreshRate}Hz (modeId=${mode.modeId})"
)
return DisplayModeSwitchResult(mode)
}
} else {
Log.d(TAG, "Display already at optimal rate ${activeMode.refreshRate}Hz for ${frameRate}fps")
stablePolls = 0
}
switchNeeded
} catch (e: Exception) {
Log.e(TAG, "Failed to match frame rate", e)
if (pendingAfterSwitch != null) {
completeSwitch("error")
}
false
delay(SWITCH_POLL_INTERVAL_MS)
}
val fallbackMode = lastMode ?: expectedMode
Log.w(
TAG,
"Display mode polling timed out after ${SWITCH_TIMEOUT_MS}ms, using ${fallbackMode.refreshRate}Hz"
)
return DisplayModeSwitchResult(fallbackMode)
}
fun cleanupDisplayListener() {
timeoutRunnable?.let { timeoutHandler?.removeCallbacks(it) }
timeoutRunnable = null
timeoutHandler = null
displayListener?.let { displayManager?.unregisterDisplayListener(it) }
displayListener = null
displayManager = null
pendingAfterSwitch = null
pendingDisplayId = null
pendingMode = null
// Kept for API compatibility with existing call sites.
}
fun clearOriginalDisplayMode() {
@ -316,10 +337,93 @@ object FrameRateUtils {
return snapToStandardRate(measuredFps)
}
private fun isValidVideoFrameRate(frameRate: Float): Boolean {
return frameRate.isFinite() && frameRate in MIN_VALID_VIDEO_FPS..MAX_VALID_VIDEO_FPS
}
fun detectFrameRateFromSource(
context: Context,
sourceUrl: String,
headers: Map<String, String> = emptyMap()
): FrameRateDetection? {
detectFrameRateFromNextLib(context, sourceUrl, headers)?.let { return it }
return detectFrameRateFromExtractor(context, sourceUrl, headers)
}
fun detectFrameRateFromNextLib(
context: Context,
sourceUrl: String,
headers: Map<String, String> = emptyMap()
): FrameRateDetection? {
return detectFrameRateWithNextLib(context, sourceUrl, headers)
}
fun detectFrameRateFromExtractor(
context: Context,
sourceUrl: String,
headers: Map<String, String> = emptyMap()
): FrameRateDetection? {
if (isResolveProxyUrl(sourceUrl)) {
val embeddedResolveUrl = extractEmbeddedResolveUrl(sourceUrl)
if (!embeddedResolveUrl.isNullOrBlank()) {
detectFrameRateWithExtractor(context, embeddedResolveUrl, headers)?.let { return it }
}
}
return detectFrameRateWithExtractor(context, sourceUrl, headers)
}
private fun detectFrameRateWithNextLib(
context: Context,
sourceUrl: String,
headers: Map<String, String>
): FrameRateDetection? {
if (!shouldUseNextLibProbe(sourceUrl, headers)) return null
val embeddedResolveUrl = extractEmbeddedResolveUrl(sourceUrl)
val shouldPreferEmbedded = isResolveProxyUrl(sourceUrl)
val candidates = buildList {
if (shouldPreferEmbedded && !embeddedResolveUrl.isNullOrBlank() && embeddedResolveUrl != sourceUrl) {
add(embeddedResolveUrl)
add(sourceUrl)
return@buildList
}
add(sourceUrl)
if (!embeddedResolveUrl.isNullOrBlank() && embeddedResolveUrl != sourceUrl) {
add(embeddedResolveUrl)
}
}
candidates.forEach { candidateUrl ->
var mediaInfo: MediaInfo? = null
try {
val uri = Uri.parse(candidateUrl)
val builder = MediaInfoBuilder().from(context = context, uri = uri)
mediaInfo = builder.build() ?: return@forEach
val video = mediaInfo.videoStream ?: return@forEach
val measured = video.frameRate.toFloat()
if (!isValidVideoFrameRate(measured)) return@forEach
return FrameRateDetection(
raw = measured,
snapped = snapToStandardRate(measured),
videoWidth = video.frameWidth.takeIf { it > 0 },
videoHeight = video.frameHeight.takeIf { it > 0 }
)
} catch (e: Throwable) {
Log.w(TAG, "NextLib frame rate probe failed: ${e.message}")
} finally {
runCatching { mediaInfo?.release() }
}
}
return null
}
private fun detectFrameRateWithExtractor(
context: Context,
sourceUrl: String,
headers: Map<String, String>
): FrameRateDetection? {
val extractor = MediaExtractor()
return try {
@ -330,19 +434,45 @@ object FrameRateUtils {
}
var videoTrackIndex = -1
var videoFormat: android.media.MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(android.media.MediaFormat.KEY_MIME)
if (mime?.startsWith("video/") == true) {
videoTrackIndex = i
videoFormat = format
break
}
}
if (videoTrackIndex < 0) return null
val detectedVideoWidth = videoFormat
?.takeIf { it.containsKey(android.media.MediaFormat.KEY_WIDTH) }
?.runCatching { getInteger(android.media.MediaFormat.KEY_WIDTH) }
?.getOrNull()
?.takeIf { it > 0 }
val detectedVideoHeight = videoFormat
?.takeIf { it.containsKey(android.media.MediaFormat.KEY_HEIGHT) }
?.runCatching { getInteger(android.media.MediaFormat.KEY_HEIGHT) }
?.getOrNull()
?.takeIf { it > 0 }
val declaredFrameRate = videoFormat
?.takeIf { it.containsKey(android.media.MediaFormat.KEY_FRAME_RATE) }
?.runCatching { getFloat(android.media.MediaFormat.KEY_FRAME_RATE) }
?.getOrNull()
if (declaredFrameRate != null && isValidVideoFrameRate(declaredFrameRate)) {
return FrameRateDetection(
raw = declaredFrameRate,
snapped = snapToStandardRate(declaredFrameRate),
videoWidth = detectedVideoWidth,
videoHeight = detectedVideoHeight
)
}
extractor.selectTrack(videoTrackIndex)
val timestamps = ArrayList<Long>(400)
val ignoreSamples = 30
val ignoreSamples = 3
val targetSamples = 350 + ignoreSamples
while (timestamps.size < targetSamples) {
@ -360,17 +490,19 @@ object FrameRateUtils {
}
val sampleCount = (timestamps.size - ignoreSamples - 1).coerceAtLeast(1)
if (sampleCount < 90) return null
if (sampleCount < 30) return null
val averageFrameDurationUs = totalFrameDurationUs.toFloat() / sampleCount.toFloat()
if (averageFrameDurationUs <= 0f) return null
val measured = 1_000_000f / averageFrameDurationUs
if (measured < 10f || measured > 120f) return null
if (!isValidVideoFrameRate(measured)) return null
FrameRateDetection(
raw = measured,
snapped = snapProbeRateByFrameDuration(measured, averageFrameDurationUs)
snapped = snapProbeRateByFrameDuration(measured, averageFrameDurationUs),
videoWidth = detectedVideoWidth,
videoHeight = detectedVideoHeight
)
} catch (e: Exception) {
Log.w(TAG, "Frame rate probe failed: ${e.message}")
@ -382,4 +514,48 @@ object FrameRateUtils {
}
}
}
private fun shouldUseNextLibProbe(sourceUrl: String, headers: Map<String, String>): Boolean {
if (sourceUrl.isBlank()) return false
if (isLiveStreamUrl(sourceUrl)) return false
if (isMkvSource(sourceUrl)) return true
val scheme = Uri.parse(sourceUrl).scheme?.lowercase(Locale.ROOT)
return when (scheme) {
in NEXTLIB_HTTP_SCHEMES -> true
"file", "content" -> true
null -> true
else -> false
}
}
private fun isLiveStreamUrl(sourceUrl: String): Boolean {
val normalized = sourceUrl.substringBefore('?').lowercase(Locale.ROOT)
return LIVE_STREAM_EXTENSIONS.any { ext -> normalized.endsWith(ext) }
}
private fun isMkvSource(sourceUrl: String): Boolean {
val normalized = sourceUrl.substringBefore('?').lowercase(Locale.ROOT)
return normalized.endsWith(MKV_EXTENSION)
}
private fun isResolveProxyUrl(sourceUrl: String): Boolean {
val normalized = sourceUrl.substringBefore('?').lowercase(Locale.ROOT)
return "/resolve/" in normalized
}
private fun extractEmbeddedResolveUrl(sourceUrl: String): String? {
val marker = "/resolve/"
val markerIndex = sourceUrl.indexOf(marker, ignoreCase = true)
if (markerIndex < 0) return null
val afterResolve = sourceUrl.substring(markerIndex + marker.length)
val nestedEncoded = afterResolve.substringAfter('/', missingDelimiterValue = "")
.substringAfter('/', missingDelimiterValue = "")
if (nestedEncoded.isBlank()) return null
return runCatching {
URLDecoder.decode(nestedEncoded, StandardCharsets.UTF_8.name())
}.getOrNull()
}
}

View file

@ -0,0 +1,22 @@
package com.nuvio.tv.core.player
import com.nuvio.tv.data.local.PlayerSettings
import com.nuvio.tv.data.local.StreamAutoPlayMode
object StreamAutoPlayPolicy {
fun isEffectivelyEnabled(playerSettings: PlayerSettings): Boolean {
if (playerSettings.streamReuseLastLinkEnabled) return true
return when (playerSettings.streamAutoPlayMode) {
StreamAutoPlayMode.MANUAL -> false
StreamAutoPlayMode.FIRST_STREAM -> true
StreamAutoPlayMode.REGEX_MATCH -> isRegexSelectionConfigured(playerSettings.streamAutoPlayRegex)
}
}
fun isRegexSelectionConfigured(regexPattern: String): Boolean {
val pattern = regexPattern.trim()
if (pattern.isEmpty() || !pattern.any { it.isLetterOrDigit() }) return false
return runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.isSuccess
}
}

View file

@ -17,6 +17,11 @@ object StreamAutoPlaySelector {
return orderedAddons + pluginEntries
}
private fun isPlayable(stream: Stream): Boolean =
stream.getStreamUrl() != null || stream.isTorrent()
fun selectAutoPlayStream(
streams: List<Stream>,
mode: StreamAutoPlayMode,
@ -25,7 +30,8 @@ object StreamAutoPlaySelector {
installedAddonNames: Set<String>,
selectedAddons: Set<String>,
selectedPlugins: Set<String>,
preferredBingeGroup: String? = null
preferredBingeGroup: String? = null,
preferBingeGroupInSelection: Boolean = false
): Stream? {
if (streams.isEmpty()) return null
@ -46,22 +52,22 @@ object StreamAutoPlaySelector {
if (mode == StreamAutoPlayMode.MANUAL) return null
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
if (targetBingeGroup.isNotEmpty()) {
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
stream.behaviorHints?.bingeGroup == targetBingeGroup && stream.getStreamUrl() != null
stream.behaviorHints?.bingeGroup == targetBingeGroup && isPlayable(stream)
}
if (bingeGroupMatch != null) return bingeGroupMatch
}
return when (mode) {
StreamAutoPlayMode.MANUAL -> null
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.getStreamUrl() != null }
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { isPlayable(it) }
StreamAutoPlayMode.REGEX_MATCH -> {
val pattern = regexPattern.trim()
if (pattern.isBlank()) return null
// Try to compile the user regex
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull() ?: return null
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull()
if (userRegex == null) return null
// Auto-extract exclusion patterns from negative lookaheads
val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern)
@ -76,31 +82,34 @@ object StreamAutoPlaySelector {
Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE)
} else null
candidateStreams.firstOrNull { stream ->
val url = stream.getStreamUrl() ?: return@firstOrNull false
// 1. Build list of ALL regexmatching streams
val matchingStreams = candidateStreams.filter { stream ->
if (!isPlayable(stream)) return@filter false
val searchableText = buildString {
append(stream.addonName).append(' ')
append(stream.name.orEmpty()).append(' ')
append(stream.title.orEmpty()).append(' ')
append(stream.description.orEmpty()).append(' ')
append(url)
append(stream.getStreamUrl().orEmpty())
if (stream.isTorrent()) append(' ').append(stream.infoHash.orEmpty())
}
// Must match user include pattern
if (!userRegex.containsMatchIn(searchableText)) return@firstOrNull false
// Must NOT match user exclusion pattern (if any)
// Must match include pattern
if (!userRegex.containsMatchIn(searchableText)) return@filter false
// Must NOT match exclusion pattern
if (excludeRegex != null && excludeRegex.containsMatchIn(searchableText)) {
return@firstOrNull false
return@filter false
}
true
}
if (matchingStreams.isEmpty()) return null
matchingStreams.firstOrNull { isPlayable(it) }
}
}
}
}

View file

@ -1,16 +1,27 @@
package com.nuvio.tv.core.plugin
import android.util.Log
import com.lagradost.cloudstream3.TvType
import com.nuvio.tv.core.plugin.cloudstream.toNuvioType
import com.nuvio.tv.core.plugin.cloudstream.tvTypeFromString
import com.nuvio.tv.core.plugin.cloudstream.ExternalExtensionLoader
import com.nuvio.tv.core.plugin.cloudstream.ExternalExtensionRunner
import com.nuvio.tv.core.plugin.cloudstream.ExternalRepoParser
import com.nuvio.tv.data.local.PluginDataStore
import com.nuvio.tv.domain.model.ExternalPluginEntry
import com.nuvio.tv.domain.model.LocalScraperResult
import com.nuvio.tv.domain.model.PluginManifest
import com.nuvio.tv.domain.model.PluginRepository
import com.nuvio.tv.domain.model.RemotePluginInfo
import com.nuvio.tv.domain.model.RepositoryType
import com.nuvio.tv.domain.model.ScraperInfo
import com.nuvio.tv.domain.model.ScraperManifestInfo
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.channels.Channel
@ -22,6 +33,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.security.MessageDigest
@ -35,6 +47,7 @@ private const val TAG = "PluginManager"
private const val MAX_CONCURRENT_SCRAPERS = 5
private const val MAX_RESULT_ITEMS = 150
private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024L
private const val SCRAPER_TIMEOUT_MS = 30_000L
private const val MANIFEST_SUFFIX = "/manifest.json"
@Singleton
@ -42,7 +55,10 @@ class PluginManager @Inject constructor(
private val dataStore: PluginDataStore,
private val runtime: PluginRuntime,
private val pluginSyncService: com.nuvio.tv.core.sync.PluginSyncService,
private val authManager: com.nuvio.tv.core.auth.AuthManager
private val authManager: com.nuvio.tv.core.auth.AuthManager,
private val externalRepoParser: ExternalRepoParser,
private val externalExtensionLoader: ExternalExtensionLoader,
private val externalExtensionRunner: ExternalExtensionRunner
) {
private val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
@ -51,6 +67,7 @@ class PluginManager @Inject constructor(
private val manifestAdapter = moshi.adapter(PluginManifest::class.java)
private val httpClient = OkHttpClient.Builder()
.dns(com.nuvio.tv.core.network.IPv4FirstDns())
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
@ -65,8 +82,96 @@ class PluginManager @Inject constructor(
return sb.toString()
}
/**
* Normalize custom protocol schemes to https://.
* External repos often use schemes like "cloudstreamrepo://" or "stremio://".
*/
private fun sanitizeScheme(url: String): String {
val trimmed = url.trim()
// Replace any non-http(s) scheme with https://
val schemeEnd = trimmed.indexOf("://")
if (schemeEnd > 0) {
val scheme = trimmed.substring(0, schemeEnd).lowercase()
if (scheme != "http" && scheme != "https") {
return "https://${trimmed.substring(schemeEnd + 3)}"
}
}
return trimmed
}
/**
* Check if the input looks like a short code rather than a URL.
* Short codes are alphanumeric strings without slashes, dots (other than in a domain),
* or protocol schemes e.g. "cspr", "0094", "megarepo".
*/
private fun isShortCode(input: String): Boolean {
val trimmed = input.trim()
if (trimmed.isEmpty()) return false
// Has a scheme → not a short code
if (trimmed.contains("://")) return false
// Has path separators or dots → likely a URL or domain
if (trimmed.contains("/") || trimmed.contains(".")) return false
// Only alphanumeric + hyphens + underscores → short code
return trimmed.all { it.isLetterOrDigit() || it == '-' || it == '_' }
}
/**
* Resolve a short code by following the redirect from cutt.ly/{code}.
* Returns the resolved URL or null if resolution fails.
*/
private fun resolveShortCode(code: String): String? {
return try {
// Use a client that does NOT follow redirects so we can read the Location header
val noRedirectClient = httpClient.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.build()
val request = Request.Builder()
.url("https://cutt.ly/$code")
.header("User-Agent", "NuvioTV/1.0")
.build()
noRedirectClient.newCall(request).execute().use { response ->
if (response.code in 301..302) {
val location = response.header("Location")
if (!location.isNullOrBlank()) {
Log.d(TAG, "Short code '$code' resolved to: $location")
return sanitizeScheme(location)
}
}
// Some shorteners return 200 with a meta refresh or JS redirect
// Try following redirects as fallback
Log.d(TAG, "Short code '$code' returned ${response.code}, trying with redirects")
null
}
} catch (e: Exception) {
Log.e(TAG, "Failed to resolve short code '$code': ${e.message}")
null
} ?: try {
// Fallback: follow redirects and see where we end up
val request = Request.Builder()
.url("https://cutt.ly/$code")
.header("User-Agent", "NuvioTV/1.0")
.build()
httpClient.newCall(request).execute().use { response ->
val finalUrl = response.request.url.toString()
if (finalUrl != "https://cutt.ly/$code" && response.isSuccessful) {
Log.d(TAG, "Short code '$code' resolved via redirect chain to: $finalUrl")
sanitizeScheme(finalUrl)
} else {
null
}
}
} catch (e: Exception) {
Log.e(TAG, "Fallback resolve for short code '$code' failed: ${e.message}")
null
}
}
private fun canonicalizeManifestUrl(url: String): String {
val trimmed = url.trim().trimEnd('/')
val trimmed = sanitizeScheme(url).trimEnd('/')
return if (trimmed.endsWith(MANIFEST_SUFFIX, ignoreCase = true)) {
trimmed
} else {
@ -74,7 +179,21 @@ class PluginManager @Inject constructor(
}
}
private fun normalizeUrl(url: String): String = canonicalizeManifestUrl(url).lowercase()
/**
* Canonicalize a URL for deduplication. For NuvioTV-style URLs (that don't end in .json),
* appends /manifest.json. For URLs already ending in .json (external repos), keeps them as-is.
*/
private fun canonicalizeRepoUrl(url: String): String {
val trimmed = sanitizeScheme(url).trimEnd('/')
// If URL already ends with a .json file, it's likely an external repo URL — keep as-is
if (trimmed.substringAfterLast("/").endsWith(".json", ignoreCase = true)) {
return trimmed
}
// Otherwise canonicalize as NuvioTV manifest
return canonicalizeManifestUrl(trimmed)
}
private fun normalizeUrl(url: String): String = canonicalizeRepoUrl(url).lowercase()
// Single-flight map to prevent duplicate scraper executions
private val inFlightScrapers = ConcurrentHashMap<String, kotlinx.coroutines.Deferred<List<LocalScraperResult>>>()
@ -97,11 +216,30 @@ class PluginManager @Inject constructor(
var isSyncingFromRemote = false
/** Prevents concurrent reconciliation from StartupSyncService and AccountViewModel */
private val reconcileMutex = Mutex()
@Volatile
private var pendingPushAfterSync = false
/**
* Call after setting isSyncingFromRemote = false to push any changes
* that were made during reconciliation (e.g. repo removals).
*/
fun flushPendingSync() {
if (pendingPushAfterSync) {
pendingPushAfterSync = false
Log.d(TAG, "flushPendingSync: firing deferred push")
triggerRemoteSync()
}
}
private var syncJob: kotlinx.coroutines.Job? = null
private fun triggerRemoteSync() {
if (isSyncingFromRemote) {
Log.d(TAG, "triggerRemoteSync: skipped (syncing from remote)")
Log.d(TAG, "triggerRemoteSync: skipped (syncing from remote), will push after sync")
pendingPushAfterSync = true
return
}
if (!authManager.isAuthenticated) {
@ -126,73 +264,205 @@ class PluginManager @Inject constructor(
}
/**
* Add a new repository from manifest URL
* Add a new repository from manifest URL.
* Auto-detects format: tries NuvioTV manifest first, then external repo format.
*/
suspend fun addRepository(manifestUrl: String): Result<PluginRepository> = withContext(Dispatchers.IO) {
try {
val canonicalManifestUrl = canonicalizeManifestUrl(manifestUrl)
Log.d(TAG, "Adding repository from: $canonicalManifestUrl")
// Fetch manifest
val manifest = fetchManifest(canonicalManifestUrl)
?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
// Create repository
val repo = PluginRepository(
id = UUID.randomUUID().toString(),
name = manifest.name,
url = canonicalManifestUrl,
enabled = true,
lastUpdated = System.currentTimeMillis(),
scraperCount = manifest.scrapers.size
)
// Save repository
dataStore.addRepository(repo)
// Download and save scrapers
downloadScrapers(repo.id, canonicalManifestUrl, manifest.scrapers)
Log.d(TAG, "Repository added: ${repo.name} with ${manifest.scrapers.size} scrapers")
triggerRemoteSync()
Result.success(repo)
// Resolve short codes (e.g. "cspr", "0094") via cutt.ly redirect
val resolvedUrl = if (isShortCode(manifestUrl)) {
Log.d(TAG, "Input looks like a short code: '$manifestUrl'")
resolveShortCode(manifestUrl.trim())
?: return@withContext Result.failure(
Exception("Failed to resolve short code: $manifestUrl")
)
} else {
sanitizeScheme(manifestUrl).trimEnd('/')
}
val sanitizedUrl = resolvedUrl.trimEnd('/')
val filename = sanitizedUrl.substringAfterLast("/")
val isExplicitJsonFile = filename.endsWith(".json", ignoreCase = true)
&& !filename.equals("manifest.json", ignoreCase = true)
// If the URL points to a specific .json file (not manifest.json),
// try external format first to avoid a wasted 404 on the NuvioTV path.
if (isExplicitJsonFile) {
Log.d(TAG, "URL ends in .json — trying external format first: $sanitizedUrl")
val externalResult = externalRepoParser.tryParse(sanitizedUrl)
if (externalResult != null) {
return@withContext addExternalRepository(sanitizedUrl, externalResult)
}
}
// Try NuvioTV format (with canonicalized /manifest.json URL)
val canonicalManifestUrl = canonicalizeManifestUrl(sanitizedUrl)
Log.d(TAG, "Trying NuvioTV manifest: $canonicalManifestUrl")
val manifest = fetchManifest(canonicalManifestUrl)
if (manifest != null) {
return@withContext addNuvioRepository(canonicalManifestUrl, manifest)
}
// If we haven't tried external format yet, try it now
if (!isExplicitJsonFile) {
Log.d(TAG, "NuvioTV manifest not found, trying external format: $sanitizedUrl")
val externalResult = externalRepoParser.tryParse(sanitizedUrl)
if (externalResult != null) {
return@withContext addExternalRepository(sanitizedUrl, externalResult)
}
}
Result.failure(Exception("Failed to parse repository: unrecognized format"))
} catch (e: Exception) {
Log.e(TAG, "Failed to add repository: ${e.message}", e)
Result.failure(e)
}
}
/**
* Add a repository with a type hint from Supabase sync.
* Skips wrong detection paths when the type is already known,
* making reconciliation faster and more resilient to network issues.
*/
private suspend fun addRepositoryWithTypeHint(
manifestUrl: String,
typeHint: RepositoryType?
): Result<PluginRepository> = withContext(Dispatchers.IO) {
try {
val sanitizedUrl = sanitizeScheme(manifestUrl).trimEnd('/')
when (typeHint) {
RepositoryType.EXTERNAL_DEX -> {
Log.d(TAG, "addRepositoryWithTypeHint: EXTERNAL_DEX hint, trying external format: $sanitizedUrl")
val externalResult = externalRepoParser.tryParse(sanitizedUrl)
if (externalResult != null) {
return@withContext addExternalRepository(sanitizedUrl, externalResult)
}
// Hint was wrong or parse failed — fall through to auto-detect
Log.w(TAG, "addRepositoryWithTypeHint: EXTERNAL_DEX hint failed, falling back to auto-detect")
}
RepositoryType.NUVIO_JS -> {
Log.d(TAG, "addRepositoryWithTypeHint: NUVIO_JS hint, trying manifest: $sanitizedUrl")
val canonicalManifestUrl = canonicalizeManifestUrl(sanitizedUrl)
val manifest = fetchManifest(canonicalManifestUrl)
if (manifest != null) {
return@withContext addNuvioRepository(canonicalManifestUrl, manifest)
}
Log.w(TAG, "addRepositoryWithTypeHint: NUVIO_JS hint failed, falling back to auto-detect")
}
null -> { /* No hint — use auto-detect */ }
}
// Fall back to full auto-detection
addRepository(sanitizedUrl)
} catch (e: Exception) {
Log.e(TAG, "Failed to add repository with hint: ${e.message}", e)
Result.failure(e)
}
}
private suspend fun addNuvioRepository(
canonicalManifestUrl: String,
manifest: PluginManifest
): Result<PluginRepository> {
val repo = PluginRepository(
id = UUID.randomUUID().toString(),
name = manifest.name,
url = canonicalManifestUrl,
enabled = true,
lastUpdated = System.currentTimeMillis(),
scraperCount = manifest.scrapers.size,
type = RepositoryType.NUVIO_JS
)
dataStore.addRepository(repo)
downloadJsScrapers(repo.id, canonicalManifestUrl, manifest.scrapers)
Log.d(TAG, "NuvioTV repository added: ${repo.name} with ${manifest.scrapers.size} scrapers")
triggerRemoteSync()
return Result.success(repo)
}
private suspend fun addExternalRepository(
repoUrl: String,
parseResult: com.nuvio.tv.core.plugin.cloudstream.ExternalRepoParseResult
): Result<PluginRepository> {
// Prevent duplicate repos by URL
val existingRepo = dataStore.repositories.first()
.find { normalizeUrl(it.url) == normalizeUrl(repoUrl) }
if (existingRepo != null) {
Log.d(TAG, "External repository already exists: ${existingRepo.name} (${existingRepo.url})")
return Result.success(existingRepo)
}
val repo = PluginRepository(
id = UUID.randomUUID().toString(),
name = parseResult.name,
url = repoUrl,
description = parseResult.description,
enabled = true,
lastUpdated = System.currentTimeMillis(),
scraperCount = parseResult.plugins.size,
type = RepositoryType.EXTERNAL_DEX
)
dataStore.addRepository(repo)
downloadDexExtensions(repo.id, parseResult.plugins)
Log.d(TAG, "External repository added: ${repo.name} with ${parseResult.plugins.size} extensions")
triggerRemoteSync()
return Result.success(repo)
}
/**
* Remove a repository and its scrapers
*/
suspend fun removeRepository(repoId: String) {
val scraperList = dataStore.scrapers.first()
val repo = dataStore.repositories.first().find { it.id == repoId }
// Remove all scrapers from this repo
scraperList.filter { it.repositoryId == repoId }.forEach { scraper ->
dataStore.deleteScraperCode(scraper.id)
if (scraper.type == RepositoryType.EXTERNAL_DEX || repo?.type == RepositoryType.EXTERNAL_DEX) {
externalExtensionLoader.deleteExtension(scraper.id)
} else {
dataStore.deleteScraperCode(scraper.id)
}
}
// Remove scrapers from list
val updatedScrapers = scraperList.filter { it.repositoryId != repoId }
dataStore.saveScrapers(updatedScrapers)
// Remove repository
dataStore.removeRepository(repoId)
triggerRemoteSync()
// Push synchronously when user-initiated (not during reconciliation)
// to prevent the next sync pull from re-adding the removed repo
if (!isSyncingFromRemote && authManager.isAuthenticated) {
Log.d(TAG, "removeRepository: pushing removal to remote synchronously")
pluginSyncService.pushToRemote()
} else if (isSyncingFromRemote) {
pendingPushAfterSync = true
}
}
/**
* Reconcile local plugin repos with the remote list from Supabase.
* @param remotePlugins list of remote plugin info (URL + optional type hint)
* @param removeMissingLocal if true, remove local repos not in the remote list
*/
suspend fun reconcileWithRemoteRepoUrls(
remoteUrls: List<String>,
remotePlugins: List<RemotePluginInfo>,
removeMissingLocal: Boolean = true
) {
val normalizedRemote = remoteUrls
.map { canonicalizeManifestUrl(it) }
.filter { it.isNotEmpty() }
.distinctBy { normalizeUrl(it) }
val remoteUrlSet = normalizedRemote.map { normalizeUrl(it) }.toSet()
) = reconcileMutex.withLock {
val normalizedRemote = remotePlugins
.map { it.copy(url = canonicalizeRepoUrl(it.url)) }
.filter { it.url.isNotEmpty() }
.distinctBy { normalizeUrl(it.url) }
val remoteUrlSet = normalizedRemote.map { normalizeUrl(it.url) }.toSet()
val initialLocalRepos = dataStore.repositories.first()
val initialLocalByNormalizedUrl = initialLocalRepos.associateBy { normalizeUrl(it.url) }
@ -209,19 +479,28 @@ class PluginManager @Inject constructor(
if (shouldRemoveMissingLocal) {
initialLocalRepos
.filter { normalizeUrl(it.url) !in remoteUrlSet }
.forEach { repo -> removeRepository(repo.id) }
.forEach { repo ->
Log.d(TAG, "reconcile: removing local repo not in remote: ${repo.name} (${repo.url})")
removeRepository(repo.id)
}
}
normalizedRemote.forEach { remoteUrl ->
if (initialLocalByNormalizedUrl[normalizeUrl(remoteUrl)] == null) {
addRepository(remoteUrl)
normalizedRemote.forEach { remotePlugin ->
if (initialLocalByNormalizedUrl[normalizeUrl(remotePlugin.url)] == null) {
val typeHint = remotePlugin.repoType?.let {
try { RepositoryType.valueOf(it) } catch (_: Exception) { null }
}
val result = addRepositoryWithTypeHint(remotePlugin.url, typeHint)
if (result.isFailure) {
Log.e(TAG, "reconcile: failed to add repo ${remotePlugin.url}: ${result.exceptionOrNull()?.message}")
}
}
}
val currentRepos = dataStore.repositories.first()
val currentByNormalizedUrl = currentRepos.associateBy { normalizeUrl(it.url) }
val remoteOrderedRepos = normalizedRemote
.mapNotNull { currentByNormalizedUrl[normalizeUrl(it)] }
.mapNotNull { currentByNormalizedUrl[normalizeUrl(it.url)] }
val extras = currentRepos
.filter { normalizeUrl(it.url) !in remoteUrlSet }
@ -230,6 +509,18 @@ class PluginManager @Inject constructor(
dataStore.saveRepositories(reordered)
}
}
/** Convenience overload for plain URL lists (no type hints) */
@JvmName("reconcileWithRemoteRepoUrlStrings")
suspend fun reconcileWithRemoteRepoUrls(
remoteUrls: List<String>,
removeMissingLocal: Boolean = true
) {
reconcileWithRemoteRepoUrls(
remotePlugins = remoteUrls.map { RemotePluginInfo(url = it) },
removeMissingLocal = removeMissingLocal
)
}
/**
* Refresh a repository - re-download manifest and scrapers
@ -238,10 +529,14 @@ class PluginManager @Inject constructor(
try {
val repo = dataStore.repositories.first().find { it.id == repoId }
?: return@withContext Result.failure(Exception("Repository not found"))
if (repo.type == RepositoryType.EXTERNAL_DEX) {
return@withContext refreshExternalRepository(repo)
}
val manifest = fetchManifest(repo.url)
?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
// Update repository
val updatedRepo = repo.copy(
name = manifest.name,
@ -249,16 +544,36 @@ class PluginManager @Inject constructor(
scraperCount = manifest.scrapers.size
)
dataStore.updateRepository(updatedRepo)
// Re-download scrapers
downloadScrapers(repo.id, repo.url, manifest.scrapers)
downloadJsScrapers(repo.id, repo.url, manifest.scrapers)
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to refresh repository: ${e.message}", e)
Result.failure(e)
}
}
private suspend fun refreshExternalRepository(repo: PluginRepository): Result<Unit> {
val parseResult = externalRepoParser.tryParse(repo.url)
?: return Result.failure(Exception("Failed to parse external repository"))
// Evict stale class loaders for old scrapers
val oldScrapers = dataStore.scrapers.first().filter { it.repositoryId == repo.id }
oldScrapers.forEach { externalExtensionLoader.evictCache(it.id) }
val updatedRepo = repo.copy(
name = parseResult.name,
lastUpdated = System.currentTimeMillis(),
scraperCount = parseResult.plugins.size
)
dataStore.updateRepository(updatedRepo)
downloadDexExtensions(repo.id, parseResult.plugins)
return Result.success(Unit)
}
/**
* Toggle scraper enabled state
@ -270,7 +585,18 @@ class PluginManager @Inject constructor(
}
dataStore.saveScrapers(updatedScrapers)
}
/**
* Toggle all scrapers belonging to a repository
*/
suspend fun toggleAllScrapersForRepo(repoId: String, enabled: Boolean) {
val scraperList = dataStore.scrapers.first()
val updatedScrapers = scraperList.map { scraper ->
if (scraper.repositoryId == repoId) scraper.copy(enabled = enabled) else scraper
}
dataStore.saveScrapers(updatedScrapers)
}
/**
* Toggle plugins globally enabled
*/
@ -299,7 +625,20 @@ class PluginManager @Inject constructor(
}
Log.d(TAG, "Executing ${enabledScraperList.size} scrapers for $mediaType:$tmdbId")
// Preload all extractors from EXTERNAL_DEX repos before any scraper runs
val dexScraperIds = enabledScraperList
.filter { it.type == RepositoryType.EXTERNAL_DEX }
.map { it.id }
if (dexScraperIds.isNotEmpty()) {
// Also load ALL dex scrapers from the same repos (not just enabled ones)
// since extractors can live in any .cs3 file
val allDexIds = dataStore.scrapers.first()
.filter { it.type == RepositoryType.EXTERNAL_DEX }
.map { it.id }
externalExtensionLoader.ensureExtractorsLoaded(allDexIds)
}
val results = enabledScraperList.map { scraper ->
async {
executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode)
@ -329,7 +668,16 @@ class PluginManager @Inject constructor(
}
Log.d(TAG, "Streaming execution of ${enabledList.size} scrapers for $mediaType:$tmdbId")
// Preload all extractors from EXTERNAL_DEX repos before any scraper runs
val dexScraperIds = enabledList.filter { it.type == RepositoryType.EXTERNAL_DEX }.map { it.id }
if (dexScraperIds.isNotEmpty()) {
val allDexIds = dataStore.scrapers.first()
.filter { it.type == RepositoryType.EXTERNAL_DEX }
.map { it.id }
externalExtensionLoader.ensureExtractorsLoaded(allDexIds)
}
// Launch all scrapers concurrently within the channelFlow scope
enabledList.forEach { scraper ->
launch {
@ -389,7 +737,7 @@ class PluginManager @Inject constructor(
}
/**
* Execute a single scraper
* Execute a single scraper, dispatching by type.
*/
suspend fun executeScraper(
scraper: ScraperInfo,
@ -397,6 +745,19 @@ class PluginManager @Inject constructor(
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
return when (scraper.type) {
RepositoryType.EXTERNAL_DEX -> executeExternalDexScraper(scraper, tmdbId, mediaType, season, episode)
RepositoryType.NUVIO_JS -> executeJsScraper(scraper, tmdbId, mediaType, season, episode)
}
}
private suspend fun executeJsScraper(
scraper: ScraperInfo,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
return try {
val code = dataStore.getScraperCode(scraper.id)
@ -418,45 +779,108 @@ class PluginManager @Inject constructor(
} catch (_: Exception) {
// ignore
}
val settings = dataStore.getScraperSettings(scraper.id)
Log.d(TAG, "Executing scraper: ${scraper.name}")
val results = runtime.executePlugin(
code = code,
tmdbId = tmdbId,
mediaType = mediaType,
season = season,
episode = episode,
scraperId = scraper.id,
scraperSettings = settings
)
val results = withTimeoutOrNull(SCRAPER_TIMEOUT_MS) {
withContext(Dispatchers.IO) {
runtime.executePlugin(
code = code,
tmdbId = tmdbId,
mediaType = mediaType,
season = season,
episode = episode,
scraperId = scraper.id,
scraperSettings = settings
)
}
}
if (results == null) {
Log.w(TAG, "Scraper ${scraper.name} timed out after ${SCRAPER_TIMEOUT_MS}ms")
return emptyList()
}
Log.d(TAG, "Scraper ${scraper.name} returned ${results.size} results")
results.map { it.copy(provider = scraper.name) }
} catch (e: Exception) {
Log.e(TAG, "Failed to execute scraper ${scraper.name}: ${e.message}", e)
emptyList()
}
}
private suspend fun executeExternalDexScraper(
scraper: ScraperInfo,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
return try {
Log.d(TAG, "Executing DEX scraper: ${scraper.name}")
val results = withTimeoutOrNull(SCRAPER_TIMEOUT_MS) {
externalExtensionRunner.execute(scraper.id, tmdbId, mediaType, season, episode)
}
if (results == null) {
Log.w(TAG, "DEX scraper ${scraper.name} timed out after ${SCRAPER_TIMEOUT_MS}ms")
return emptyList()
}
Log.d(TAG, "DEX scraper ${scraper.name} returned ${results.size} results")
results.map { it.copy(provider = scraper.name) }
} catch (e: Exception) {
Log.e(TAG, "Failed to execute DEX scraper ${scraper.name}: ${e.message}", e)
emptyList()
}
}
/**
* Test a scraper with sample data
* Test a scraper with sample data, returning results along with diagnostic steps.
*/
suspend fun testScraper(scraperId: String): Result<List<LocalScraperResult>> {
suspend fun testScraper(scraperId: String): Result<Pair<List<LocalScraperResult>, TestDiagnostics>> {
val diagnostics = TestDiagnostics()
val scraper = dataStore.scrapers.first().find { it.id == scraperId }
?: return Result.failure(Exception("Scraper not found"))
if (scraper == null) {
diagnostics.addStep("Scraper '$scraperId' not found in datastore")
return Result.failure(Exception("Scraper not found"))
}
diagnostics.addStep("Scraper: ${scraper.name} (type=${scraper.type})")
// Use a popular movie for testing (The Matrix - 603)
val testTmdbId = "603"
val testMediaType = if (scraper.supportsType("movie")) "movie" else "series"
diagnostics.addStep("Test: TMDB $testTmdbId ($testMediaType)")
// Preload extractors from ALL .cs3 files in the same repo(s)
if (scraper.type == RepositoryType.EXTERNAL_DEX) {
val allDexIds = dataStore.scrapers.first()
.filter { it.type == RepositoryType.EXTERNAL_DEX }
.map { it.id }
externalExtensionLoader.ensureExtractorsLoaded(allDexIds, diagnostics)
}
val testSeason = if (testMediaType == "movie") null else 1
val testEpisode = if (testMediaType == "movie") null else 1
return try {
val results = executeScraper(scraper, testTmdbId, testMediaType, 1, 1)
Result.success(results)
val results = when (scraper.type) {
RepositoryType.EXTERNAL_DEX -> {
externalExtensionRunner.executeWithDiagnostics(
scraper.id, testTmdbId, testMediaType, testSeason, testEpisode, diagnostics
)
}
RepositoryType.NUVIO_JS -> {
diagnostics.addStep("Executing JS scraper...")
executeScraper(scraper, testTmdbId, testMediaType, testSeason, testEpisode)
}
}
diagnostics.addStep("Result: ${results.size} streams")
Result.success(results to diagnostics)
} catch (e: Exception) {
Result.failure(e)
diagnostics.addStep("Exception: ${e.javaClass.simpleName}: ${e.message}")
Result.success(emptyList<LocalScraperResult>() to diagnostics)
}
}
@ -483,7 +907,7 @@ class PluginManager @Inject constructor(
}
}
private suspend fun downloadScrapers(
private suspend fun downloadJsScrapers(
repoId: String,
manifestUrl: String,
scraperInfos: List<ScraperManifestInfo>
@ -543,6 +967,13 @@ class PluginManager @Inject constructor(
// Create scraper info
val scraperId = "$repoId:${info.id}"
val existingScraper = existingScrapers.firstOrNull { it.id == scraperId }
val defaultEnabled = info.enabled &&
!PluginSafety.isVideoEasyScraper(
scraperId = info.id,
scraperName = info.name,
filename = info.filename
)
val scraper = ScraperInfo(
id = scraperId,
repositoryId = repoId,
@ -551,7 +982,7 @@ class PluginManager @Inject constructor(
version = info.version,
filename = info.filename,
supportedTypes = info.supportedTypes,
enabled = true,
enabled = existingScraper?.enabled ?: defaultEnabled,
manifestEnabled = info.enabled,
logo = info.logo,
contentLanguage = info.contentLanguage ?: emptyList(),
@ -574,4 +1005,77 @@ class PluginManager @Inject constructor(
dataStore.saveScrapers(existingScrapers)
}
/**
* Download .cs3 DEX extensions in parallel and register them as scrapers.
* Uses a semaphore to limit concurrent downloads and avoid overwhelming
* the network. Scrapers are saved incrementally in batches.
*/
private suspend fun downloadDexExtensions(
repoId: String,
plugins: List<ExternalPluginEntry>
) = withContext(Dispatchers.IO) {
val existingScrapers = dataStore.scrapers.first().toMutableList()
val downloadSemaphore = Semaphore(MAX_PARALLEL_DOWNLOADS)
val newScrapers = java.util.Collections.synchronizedList(mutableListOf<ScraperInfo>())
// Download all extensions in parallel with limited concurrency
val jobs = plugins.map { plugin ->
async {
downloadSemaphore.withPermit {
try {
val scraperId = "$repoId:${plugin.internalName}"
val file = externalExtensionLoader.downloadExtension(scraperId, plugin.url)
if (file == null) {
Log.e(TAG, "Failed to download extension: ${plugin.name}")
return@withPermit
}
val supportedTypes = plugin.tvTypes
?.mapNotNull { tvTypeFromString(it) }
?.map { it.toNuvioType() }
?.distinct()
?.ifEmpty { listOf("movie", "tv") }
?: listOf("movie", "tv")
val scraper = ScraperInfo(
id = scraperId,
repositoryId = repoId,
name = plugin.name,
description = plugin.description ?: "",
version = plugin.version.toString(),
filename = plugin.url,
supportedTypes = supportedTypes,
enabled = true,
manifestEnabled = plugin.status == 1,
logo = plugin.iconUrl,
contentLanguage = emptyList(),
formats = null,
type = RepositoryType.EXTERNAL_DEX
)
newScrapers.add(scraper)
Log.d(TAG, "Downloaded DEX extension: ${plugin.name} (${file.length()} bytes)")
} catch (e: Exception) {
Log.e(TAG, "Error downloading extension ${plugin.name}: ${e.message}", e)
}
}
}
}
jobs.awaitAll()
// Merge new scrapers into existing list
val newScraperIds = newScrapers.map { it.id }.toSet()
existingScrapers.removeAll { it.id in newScraperIds }
existingScrapers.addAll(newScrapers)
dataStore.saveScrapers(existingScrapers)
Log.d(TAG, "Downloaded ${newScrapers.size}/${plugins.size} extensions for repo $repoId")
}
companion object {
private const val MAX_PARALLEL_DOWNLOADS = 10
}
}

View file

@ -1,12 +1,12 @@
package com.nuvio.tv.core.plugin
import android.util.Log
import com.dokar.quickjs.binding.asyncFunction
import com.dokar.quickjs.binding.define
import com.dokar.quickjs.binding.function
import com.dokar.quickjs.quickJs
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.domain.model.LocalScraperResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -39,7 +39,10 @@ import javax.inject.Singleton
private const val TAG = "PluginRuntime"
private const val PLUGIN_TIMEOUT_MS = 60_000L
private const val MAX_FETCH_RESPONSE_BYTES = 2 * 1024 * 1024
private const val MAX_FETCH_RESPONSE_BYTES = 256 * 1024
private const val MAX_FETCH_BODY_CHARS = 256 * 1024
private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024
private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]"
@Singleton
class PluginRuntime @Inject constructor() {
@ -47,6 +50,7 @@ class PluginRuntime @Inject constructor() {
private val gson: Gson = GsonBuilder().create()
private val httpClient = OkHttpClient.Builder()
.dns(com.nuvio.tv.core.network.IPv4FirstDns())
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
@ -55,10 +59,6 @@ class PluginRuntime @Inject constructor() {
.proxy(java.net.Proxy.NO_PROXY)
.build()
// Store parsed documents for cheerio
private val documentCache = ConcurrentHashMap<String, Document>()
private val elementCache = ConcurrentHashMap<String, Element>()
// Pre-compiled regex for :contains() selector conversion
private val containsRegex = Regex(""":contains\(["']([^"']+)["']\)""")
@ -144,87 +144,99 @@ class PluginRuntime @Inject constructor() {
scraperId: String,
scraperSettings: Map<String, Any>
): List<LocalScraperResult> {
// Clear caches before execution
documentCache.clear()
elementCache.clear()
val documentCache = ConcurrentHashMap<String, Document>()
val elementCache = ConcurrentHashMap<String, Element>()
val inFlightCalls = ConcurrentHashMap.newKeySet<Call>()
var resultJson = "[]"
try {
quickJs(Dispatchers.IO) {
// Define console object - must return null to avoid quickjs conversion issues
define("console") {
function("log") { args ->
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("error") { args ->
Log.e("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("warn") { args ->
Log.w("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("info") { args ->
Log.i("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("debug") { args ->
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
}
// Define native fetch function (async)
asyncFunction("__native_fetch") { args ->
val url = args.getOrNull(0)?.toString() ?: ""
val method = args.getOrNull(1)?.toString() ?: "GET"
val headersJson = args.getOrNull(2)?.toString() ?: "{}"
val body = args.getOrNull(3)?.toString() ?: ""
performNativeFetch(url, method, headersJson, body, inFlightCalls)
}
// Define URL parser
function("__parse_url") { args ->
val urlString = args.getOrNull(0)?.toString() ?: ""
parseUrl(urlString)
}
// Define cheerio load function
function("__cheerio_load") { args ->
val html = args.getOrNull(0)?.toString() ?: ""
val docId = UUID.randomUUID().toString()
val doc = Jsoup.parse(html)
documentCache[docId] = doc
docId
}
// Define cheerio select function
function("__cheerio_select") { args ->
val docId = args.getOrNull(0)?.toString() ?: ""
var selector = args.getOrNull(1)?.toString() ?: ""
val doc = documentCache[docId] ?: return@function "[]"
try {
// Convert cheerio :contains("text") to jsoup :contains(text)
selector = selector.replace(containsRegex, ":contains($1)")
val elements = if (selector.isEmpty()) {
Elements()
} else {
doc.select(selector)
// Define console object - must return null to avoid quickjs conversion issues
define("console") {
function("log") { args ->
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
val ids = elements.mapIndexed { index, el ->
val elId = "$docId:$index:${el.hashCode()}"
elementCache[elId] = el
elId
function("error") { args ->
Log.e("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("warn") { args ->
Log.w("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("info") { args ->
Log.i("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("debug") { args ->
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
}
function("__native_fetch") { args ->
val url = args.getOrNull(0)?.toString() ?: ""
val method = args.getOrNull(1)?.toString() ?: "GET"
val headersJson = args.getOrNull(2)?.toString() ?: "{}"
val body = args.getOrNull(3)?.toString() ?: ""
try {
performNativeFetch(url, method, headersJson, body, inFlightCalls)
} catch (t: Throwable) {
Log.e(TAG, "Async fetch bridge error for $method $url: ${t.message}")
gson.toJson(
mapOf(
"ok" to false,
"status" to 0,
"statusText" to (t.message ?: "Fetch failed"),
"url" to url,
"body" to "",
"headers" to emptyMap<String, String>()
)
)
}
}
// Define URL parser
function("__parse_url") { args ->
val urlString = args.getOrNull(0)?.toString() ?: ""
parseUrl(urlString)
}
// Define cheerio load function
function("__cheerio_load") { args ->
val html = args.getOrNull(0)?.toString() ?: ""
val docId = UUID.randomUUID().toString()
val doc = Jsoup.parse(html)
documentCache[docId] = doc
docId
}
// Define cheerio select function
function("__cheerio_select") { args ->
val docId = args.getOrNull(0)?.toString() ?: ""
var selector = args.getOrNull(1)?.toString() ?: ""
val doc = documentCache[docId] ?: return@function "[]"
try {
// Convert cheerio :contains("text") to jsoup :contains(text)
selector = selector.replace(containsRegex, ":contains($1)")
val elements = if (selector.isEmpty()) {
Elements()
} else {
doc.select(selector)
}
val ids = elements.mapIndexed { index, el ->
val elId = "$docId:$index:${el.hashCode()}"
elementCache[elId] = el
elId
}
// Use simple JSON array construction to avoid Gson issues
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
} catch (e: Exception) {
"[]"
}
// Use simple JSON array construction to avoid Gson issues
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
} catch (e: Exception) {
"[]"
}
}
// Define cheerio find function
function("__cheerio_find") { args ->
@ -360,7 +372,7 @@ class PluginRuntime @Inject constructor() {
})();
""".trimIndent()
evaluate<Any?>(callCode)
evaluate<Any?>(callCode)
}
return parseJsonResults(resultJson)
@ -439,10 +451,10 @@ class PluginRuntime @Inject constructor() {
response.use { httpResponse ->
val bodyContentType = httpResponse.body?.contentType()
val contentEncoding = httpResponse.header("Content-Encoding")?.lowercase()?.trim()
val decodedBytes = try {
val decodedRead = try {
val stream = httpResponse.body?.byteStream()
if (stream == null) {
ByteArray(0)
BoundedReadResult(ByteArray(0), false)
} else {
val decodeStream: InputStream = when (contentEncoding) {
"gzip" -> GZIPInputStream(stream)
@ -455,18 +467,14 @@ class PluginRuntime @Inject constructor() {
}
} catch (e: Exception) {
Log.w(TAG, "Failed to read/decode response body for $url: ${e.message}")
ByteArray(0)
BoundedReadResult(ByteArray(0), false)
}
val charset = bodyContentType?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
val responseBody = try {
String(decodedBytes, charset)
} catch (e: Exception) {
String(decodedBytes, Charsets.UTF_8)
}
val responseBody = decodeBodyToSafeString(decodedRead.bytes, charset)
val responseHeaders = mutableMapOf<String, String>()
httpResponse.headers.forEach { (name, value) ->
responseHeaders[name.lowercase()] = value
responseHeaders[name.lowercase()] = truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS)
}
val result = mapOf(
@ -475,7 +483,8 @@ class PluginRuntime @Inject constructor() {
"statusText" to httpResponse.message,
"url" to httpResponse.request.url.toString(),
"body" to responseBody,
"headers" to responseHeaders
"headers" to responseHeaders,
"truncated" to decodedRead.truncated
)
Log.d(TAG, "Fetch result: ${httpResponse.code} ${httpResponse.message} url=$url bodyLen=${responseBody.length} bodyPreview=${responseBody.take(300)}")
@ -497,10 +506,32 @@ class PluginRuntime @Inject constructor() {
}
}
private fun readAtMostBytes(stream: InputStream, maxBytes: Int): ByteArray {
private data class BoundedReadResult(
val bytes: ByteArray,
val truncated: Boolean
)
private fun truncateString(value: String, maxChars: Int): String {
if (value.length <= maxChars) return value
val end = maxChars - FETCH_TRUNCATION_SUFFIX.length
if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars)
return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX
}
private fun decodeBodyToSafeString(bytes: ByteArray, charset: java.nio.charset.Charset): String {
val decoded = try {
String(bytes, charset)
} catch (e: Exception) {
String(bytes, Charsets.UTF_8)
}
return truncateString(decoded, MAX_FETCH_BODY_CHARS)
}
private fun readAtMostBytes(stream: InputStream, maxBytes: Int): BoundedReadResult {
val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024))
val buffer = ByteArray(8 * 1024)
var remaining = maxBytes
var truncated = false
while (remaining > 0) {
val read = stream.read(buffer, 0, minOf(buffer.size, remaining))
@ -508,7 +539,10 @@ class PluginRuntime @Inject constructor() {
out.write(buffer, 0, read)
remaining -= read
}
return out.toByteArray()
if (remaining == 0) {
truncated = stream.read() != -1
}
return BoundedReadResult(out.toByteArray(), truncated)
}
private fun parseUrl(urlString: String): String {
@ -542,7 +576,16 @@ class PluginRuntime @Inject constructor() {
globalThis.SCRAPER_ID = "$scraperId";
globalThis.SCRAPER_SETTINGS = $settingsJson;
if (typeof TMDB_API_KEY === 'undefined') {
globalThis.TMDB_API_KEY = "1865f43a0549ca50d341dd9ab8b29f49";
globalThis.TMDB_API_KEY = "${BuildConfig.TMDB_API_KEY}";
}
if (typeof globalThis.global === 'undefined') {
globalThis.global = globalThis;
}
if (typeof globalThis.window === 'undefined') {
globalThis.window = globalThis;
}
if (typeof globalThis.self === 'undefined') {
globalThis.self = globalThis;
}
// Fetch implementation (async)
@ -564,7 +607,7 @@ class PluginRuntime @Inject constructor() {
headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
}
var result = await __native_fetch(url, method, JSON.stringify(headers), body);
var result = __native_fetch(url, method, JSON.stringify(headers), body);
var parsed = JSON.parse(result);
if (signal && signal.aborted) {
@ -587,10 +630,15 @@ class PluginRuntime @Inject constructor() {
return Promise.resolve(parsed.body);
},
json: function() {
try {
if (parsed.body === null || parsed.body === undefined || parsed.body === '') {
return Promise.resolve(null);
}
return Promise.resolve(JSON.parse(parsed.body));
} catch (e) {
return Promise.reject(new Error('JSON parse error: ' + e.message));
console.error('fetch.json parse error:', e && e.message ? e.message : e);
return Promise.resolve(null);
}
}
};

View file

@ -0,0 +1,13 @@
package com.nuvio.tv.core.plugin
internal object PluginSafety {
fun isVideoEasyScraper(
scraperId: String?,
scraperName: String? = null,
filename: String? = null
): Boolean {
return listOf(scraperId, scraperName, filename).any { value ->
value?.contains("videasy", ignoreCase = true) == true
}
}
}

View file

@ -0,0 +1,18 @@
package com.nuvio.tv.core.plugin
import android.util.Log
private const val TAG = "TestDiagnostics"
/**
* Collects diagnostic steps during a scraper test run.
* Each step is a status line like "DEX loaded: 2 MainAPIs" or "Search: 5 results for 'The Matrix'".
*/
data class TestDiagnostics(
val steps: MutableList<String> = mutableListOf()
) {
fun addStep(step: String) {
steps.add(step)
Log.d(TAG, step)
}
}

View file

@ -0,0 +1,761 @@
package com.nuvio.tv.core.plugin.cloudstream
import android.content.Context
import android.os.Build
import android.util.Log
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.plugins.BasePlugin
import com.lagradost.cloudstream3.plugins.Plugin
import com.lagradost.cloudstream3.plugins.CloudstreamPlugin
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.extractorApis
import com.nuvio.tv.core.plugin.TestDiagnostics
import dalvik.system.DexClassLoader
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "ExtExtensionLoader"
/**
* Checks whether an instance looks like a CloudStream plugin by checking if it has
* plugin-like methods. Covers three cases:
* 1. Our Plugin class: load(Activity?), load(Context), getRegisteredMainAPIs()
* 2. Library's BasePlugin: load() (no-arg), registerMainAPI()
* 3. Foreign plugins with similar signatures
*/
private fun looksLikePlugin(instance: Any): Boolean {
// Fast path: check if it's an instance of the library's BasePlugin
if (instance is BasePlugin) return true
val clazz = instance.javaClass
// Check for any form of load() method
val hasLoad = try {
clazz.getMethod("load", Context::class.java) != null
} catch (_: NoSuchMethodException) {
try {
clazz.getMethod("load", android.app.Activity::class.java) != null
} catch (_: NoSuchMethodException) {
try {
// BasePlugin-style no-arg load()
clazz.getMethod("load") != null
} catch (_: NoSuchMethodException) {
false
}
}
}
val hasRegisteredAPIs = try {
clazz.getMethod("getRegisteredMainAPIs") != null
} catch (_: NoSuchMethodException) {
false
}
return hasLoad || hasRegisteredAPIs
}
/**
* Wraps a plugin instance loaded from a foreign classloader or with a non-standard base class.
* Handles three plugin patterns:
* 1. Our Plugin: load(Activity?), load(Context), getRegisteredMainAPIs()
* 2. Library's BasePlugin: load() no-arg, registers to APIHolder.allProviders + extractorApis
* 3. Foreign plugins with similar signatures
*/
private class ReflectivePluginWrapper(private val foreignInstance: Any) : Plugin() {
override fun load(activity: android.app.Activity?) {
load(activity as? Context ?: AcraApplication.context ?: return)
}
override fun load(context: Context) {
// Snapshot global registries before load() to detect what this plugin adds
val providersBefore = synchronized(APIHolder.allProviders) {
APIHolder.allProviders.toList()
}
val extractorsBefore = extractorApis.toList()
val clazz = foreignInstance.javaClass
var loaded = false
// Try load(Context) first
try {
val m = clazz.getMethod("load", Context::class.java)
m.invoke(foreignInstance, context)
loaded = true
} catch (e: java.lang.reflect.InvocationTargetException) {
val cause = e.cause
if (cause is ClassCastException) {
Log.d(TAG, "ReflectivePluginWrapper: load(Context) got ClassCastException, retrying with null Activity")
try {
val m = clazz.getMethod("load", android.app.Activity::class.java)
m.invoke(foreignInstance, null)
loaded = true
} catch (e2: Exception) {
Log.w(TAG, "ReflectivePluginWrapper: load(Activity) also failed: ${e2.message}")
}
} else {
Log.w(TAG, "ReflectivePluginWrapper: load(Context) threw: ${cause?.message ?: e.message}")
}
} catch (_: NoSuchMethodException) {
// Try load(Activity?) next
try {
val m = clazz.getMethod("load", android.app.Activity::class.java)
m.invoke(foreignInstance, null)
loaded = true
} catch (_: NoSuchMethodException) {
// Try no-arg load() (BasePlugin pattern)
try {
val m = clazz.getMethod("load")
m.invoke(foreignInstance)
loaded = true
Log.d(TAG, "ReflectivePluginWrapper: loaded via no-arg load()")
} catch (e: Exception) {
Log.w(TAG, "ReflectivePluginWrapper: load() (no-arg) failed: ${e.message}")
}
} catch (e: Exception) {
Log.w(TAG, "ReflectivePluginWrapper: load(Activity) failed: ${e.message}")
}
} catch (e: Exception) {
Log.w(TAG, "ReflectivePluginWrapper: load() failed: ${e.message}")
}
// First try reading local registration lists (our Plugin pattern)
try {
val getter = clazz.getMethod("getRegisteredMainAPIs")
@Suppress("UNCHECKED_CAST")
val apis = getter.invoke(foreignInstance) as? List<MainAPI> ?: emptyList()
apis.forEach { registerMainAPI(it) }
} catch (_: Exception) {
// No local list — check global APIHolder for newly registered providers
// (BasePlugin.registerMainAPI adds to APIHolder.allProviders)
val newProviders = synchronized(APIHolder.allProviders) {
APIHolder.allProviders.toList()
} - providersBefore.toSet()
if (newProviders.isNotEmpty()) {
Log.d(TAG, "ReflectivePluginWrapper: found ${newProviders.size} providers via APIHolder")
newProviders.forEach { registerMainAPI(it) }
}
}
try {
val getter = clazz.getMethod("getRegisteredExtractorAPIs")
@Suppress("UNCHECKED_CAST")
val extractors = getter.invoke(foreignInstance) as? List<ExtractorApi> ?: emptyList()
extractors.forEach { registerExtractorAPI(it) }
} catch (_: Exception) {
// Check global extractorApis for newly registered extractors
val newExtractors = extractorApis.toList() - extractorsBefore.toSet()
if (newExtractors.isNotEmpty()) {
Log.d(TAG, "ReflectivePluginWrapper: found ${newExtractors.size} extractors via extractorApis")
newExtractors.forEach { registerExtractorAPI(it) }
}
}
}
}
private const val MAX_DEX_SIZE = 10 * 1024 * 1024L // 10MB max per .cs3 file
/**
* Manages downloading, loading, and caching of DEX-based external extensions (.cs3 files).
*/
@Singleton
class ExternalExtensionLoader @Inject constructor(
@ApplicationContext private val context: Context,
private val extractorRegistry: ExternalExtractorRegistry
) {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.followRedirects(true)
.build()
/** Cache of loaded MainAPI instances by scraper ID */
private val apiCache = ConcurrentHashMap<String, MainAPI>()
/** Cache of loaded class loaders by scraper ID */
private val classLoaderCache = ConcurrentHashMap<String, DexClassLoader>()
/** Tracks which scraper IDs have already been scanned for extractors */
private val extractorPreloadedIds = java.util.Collections.synchronizedSet(mutableSetOf<String>())
private val extensionsDir: File
get() = File(context.filesDir, "cs_extensions").also { it.mkdirs() }
private val codeCacheDir: File
get() = File(context.codeCacheDir, "cs_dex_cache").also { it.mkdirs() }
/** Sanitize scraper ID for use as a filename (colons are not safe on all filesystems). */
private fun safeFileName(scraperId: String): String =
scraperId.replace(':', '_').replace('/', '_')
/**
* Download a .cs3 DEX file for the given scraper.
* Returns the local file path, or null on failure.
*/
suspend fun downloadExtension(scraperId: String, downloadUrl: String): File? = withContext(Dispatchers.IO) {
try {
val targetFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3")
// Remove existing read-only file before writing (DEX files are set
// read-only for API 28+ compat, so overwriting would fail with EACCES)
if (targetFile.exists()) {
targetFile.setWritable(true)
targetFile.delete()
}
val request = Request.Builder()
.url(downloadUrl)
.header("User-Agent", "NuvioTV/1.0")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.e(TAG, "Failed to download extension $scraperId: HTTP ${response.code}")
return@withContext null
}
val contentLength = response.header("Content-Length")?.toLongOrNull() ?: 0
if (contentLength > MAX_DEX_SIZE) {
Log.w(TAG, "Extension $scraperId too large: $contentLength bytes")
return@withContext null
}
val bytes = response.body?.bytes() ?: return@withContext null
if (bytes.size > MAX_DEX_SIZE) {
Log.w(TAG, "Extension $scraperId too large: ${bytes.size} bytes")
return@withContext null
}
targetFile.writeBytes(bytes)
// Fix for Android API 28+: DEX files must be read-only
// Writing writable DEX files is blocked on newer Android versions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
targetFile.setReadOnly()
Log.d(TAG, "Set DEX file read-only for API ${Build.VERSION.SDK_INT}")
}
Log.d(TAG, "Downloaded extension $scraperId: ${bytes.size} bytes -> ${targetFile.absolutePath}")
targetFile
}
} catch (e: Exception) {
Log.e(TAG, "Error downloading extension $scraperId: ${e.message}", e)
null
}
}
/**
* Load a .cs3 DEX file and return the MainAPI instance(s) registered by the plugin.
*/
fun loadExtension(scraperId: String): List<MainAPI> {
// Check cache first
apiCache[scraperId]?.let { return listOf(it) }
val dexFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3")
if (!dexFile.exists()) {
Log.e(TAG, "DEX file not found for $scraperId: ${dexFile.absolutePath}")
return emptyList()
}
// Ensure DEX file is read-only (fix for existing downloads on API 28+)
ensureDexReadOnly(dexFile)
return try {
val classLoader = DexClassLoader(
dexFile.absolutePath,
codeCacheDir.absolutePath,
null,
context.classLoader
)
classLoaderCache[scraperId] = classLoader
// Check for critical class shadowing
try {
@Suppress("DEPRECATION")
val inspectDex = dalvik.system.DexFile(dexFile)
val allEntries = inspectDex.entries().toList()
inspectDex.close()
val criticalShadows = allEntries.filter { className ->
className == "com.lagradost.cloudstream3.MainActivityKt" ||
className == "com.lagradost.cloudstream3.MainAPIKt" ||
className == "com.lagradost.cloudstream3.utils.ExtractorApiKt" ||
className == "com.lagradost.cloudstream3.utils.AppUtilsKt"
}
if (criticalShadows.isNotEmpty()) {
Log.w(TAG, "Extension $scraperId shadows critical classes: $criticalShadows")
}
} catch (_: Exception) {}
// Find and instantiate the plugin class
val plugin = findAndLoadPlugin(classLoader, dexFile)
if (plugin == null) {
Log.e(TAG, "No @CloudstreamPlugin class found in $scraperId")
return emptyList()
}
// Ensure global stubs are initialized for extensions
AcraApplication.context = context
extractorRegistry.installGlobal()
// Call load() to trigger registerMainAPI() calls.
val activity = AcraApplication.getActivity()
try {
if (activity != null) {
plugin.load(activity as android.app.Activity?)
} else {
plugin.load(context)
}
} catch (e: Exception) {
Log.w(TAG, "plugin.load() threw (partial load, ${plugin.registeredMainAPIs.size} APIs so far): ${e.message}", e)
} catch (e: Error) {
val missingClass = extractMissingClassName(e)
if (missingClass != null) {
Log.w(TAG, "plugin.load() MISSING CLASS: $missingClass (${plugin.registeredMainAPIs.size} APIs so far)", e)
} else {
Log.w(TAG, "plugin.load() linkage error (partial load, ${plugin.registeredMainAPIs.size} APIs so far): ${e.message}", e)
}
}
// Register any extractors the plugin provides
extractorRegistry.registerAll(plugin.registeredExtractorAPIs)
var apis = plugin.registeredMainAPIs
Log.d(TAG, "After load(): ${apis.size} MainAPIs, ${plugin.registeredExtractorAPIs.size} extractors")
// FALLBACK: If load() registered 0 APIs or 0 extractors, scan DEX directly.
if (apis.isEmpty() || plugin.registeredExtractorAPIs.isEmpty()) {
Log.d(TAG, "Fallback: scanning DEX for MainAPI/ExtractorApi subclasses in $scraperId")
val fallbackApis = mutableListOf<MainAPI>()
val fallbackExtractors = mutableListOf<ExtractorApi>()
try {
@Suppress("DEPRECATION")
val inspectDex = dalvik.system.DexFile(dexFile)
val allClasses = inspectDex.entries().toList()
inspectDex.close()
val candidates = allClasses.filter { className ->
!className.contains('$') &&
!className.contains("Plugin") &&
!className.contains("Fragment") &&
className.startsWith("com.") &&
!className.startsWith("com.lagradost.cloudstream3.")
}
for (className in candidates) {
try {
val clazz = classLoader.loadClass(className)
if (apis.isEmpty() && MainAPI::class.java.isAssignableFrom(clazz)) {
val instance = clazz.getDeclaredConstructor().newInstance() as MainAPI
fallbackApis.add(instance)
Log.d(TAG, "Fallback found MainAPI: ${instance.name} ($className)")
} else if (ExtractorApi::class.java.isAssignableFrom(clazz)
&& !java.lang.reflect.Modifier.isAbstract(clazz.modifiers)) {
val instance = clazz.getDeclaredConstructor().newInstance() as ExtractorApi
fallbackExtractors.add(instance)
Log.d(TAG, "Fallback found ExtractorApi: ${instance.name} (${instance.mainUrl})")
}
} catch (e: Error) {
val missing = extractMissingClassName(e)
if (missing != null) {
Log.w(TAG, "Fallback skip $className: MISSING $missing")
}
} catch (_: Exception) {
// Skip classes that can't be instantiated
}
}
} catch (e: Exception) {
Log.w(TAG, "Fallback DEX scan failed: ${e.message}")
}
if (fallbackApis.isNotEmpty()) {
Log.d(TAG, "Fallback found ${fallbackApis.size} MainAPIs")
apis = fallbackApis
}
if (fallbackExtractors.isNotEmpty()) {
Log.d(TAG, "Fallback found ${fallbackExtractors.size} ExtractorApis")
extractorRegistry.registerAll(fallbackExtractors)
}
}
apis.forEach { api ->
apiCache["$scraperId:${api.name}"] = api
}
// Also cache the first API under the plain scraper ID
if (apis.isNotEmpty()) {
apiCache[scraperId] = apis.first()
}
Log.d(TAG, "Loaded extension $scraperId: ${apis.size} providers (${apis.joinToString { it.name }})")
apis
} catch (e: Exception) {
Log.e(TAG, "Failed to load extension $scraperId: ${e.message}", e)
emptyList()
} catch (e: Error) {
val missingClass = extractMissingClassName(e)
if (missingClass != null) {
Log.e(TAG, "Failed to load extension $scraperId: MISSING CLASS: $missingClass (${e.javaClass.simpleName})", e)
} else {
Log.e(TAG, "Failed to load extension $scraperId (linkage error): ${e.message}", e)
}
emptyList()
}
}
/**
* Get a cached MainAPI for the given scraper ID, loading if necessary.
*/
fun getApi(scraperId: String): MainAPI? {
return apiCache[scraperId] ?: run {
val apis = loadExtension(scraperId)
apis.firstOrNull()
}
}
/**
* Load extension with diagnostic output.
*/
fun loadExtensionWithDiagnostics(scraperId: String, diagnostics: TestDiagnostics): List<MainAPI> {
apiCache[scraperId]?.let {
diagnostics.addStep("MainAPI cached: ${it.name}")
return listOf(it)
}
val dexFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3")
if (!dexFile.exists()) {
diagnostics.addStep("DEX file NOT FOUND: ${dexFile.name}")
return emptyList()
}
diagnostics.addStep("DEX: ${dexFile.length()} bytes")
// Ensure read-only
ensureDexReadOnly(dexFile)
return try {
val classLoader = DexClassLoader(
dexFile.absolutePath,
codeCacheDir.absolutePath,
null,
context.classLoader
)
classLoaderCache[scraperId] = classLoader
val allClasses: List<String>
try {
@Suppress("DEPRECATION")
val inspectDex = dalvik.system.DexFile(dexFile)
allClasses = inspectDex.entries().toList()
inspectDex.close()
} catch (e: Exception) {
diagnostics.addStep("DEX inspection failed: ${e.message?.take(100)}")
return emptyList()
}
val shadowsPlugin = allClasses.any { it == "com.lagradost.cloudstream3.plugins.Plugin" }
if (shadowsPlugin) {
diagnostics.addStep("WARNING: DEX contains its own Plugin class!")
}
val plugin = findAndLoadPlugin(classLoader, dexFile)
if (plugin == null) {
diagnostics.addStep("No @CloudstreamPlugin found in DEX")
return emptyList()
}
val sameClass = plugin.javaClass.superclass == Plugin::class.java
diagnostics.addStep("Plugin: ${plugin.javaClass.simpleName}, sameBaseClass=$sameClass, isWrapper=${plugin is ReflectivePluginWrapper}")
AcraApplication.context = context
extractorRegistry.installGlobal()
val activity = AcraApplication.getActivity()
try {
if (activity != null) {
plugin.load(activity as android.app.Activity?)
} else {
plugin.load(context)
}
diagnostics.addStep("load(${if (activity != null) "Activity" else "Context"}): OK, ${plugin.registeredMainAPIs.size} APIs")
} catch (e: Exception) {
diagnostics.addStep("load() FAILED: ${e.javaClass.simpleName}: ${e.message?.take(120)}")
} catch (e: Error) {
val missing = extractMissingClassName(e)
diagnostics.addStep("load() ERROR: ${missing ?: e.message?.take(120)}")
}
extractorRegistry.registerAll(plugin.registeredExtractorAPIs)
var apis = plugin.registeredMainAPIs
if (apis.isEmpty() || plugin.registeredExtractorAPIs.isEmpty()) {
diagnostics.addStep("Fallback: scanning DEX for MainAPI + ExtractorApi subclasses...")
val fallbackApis = mutableListOf<MainAPI>()
val fallbackExtractors = mutableListOf<ExtractorApi>()
val candidates = allClasses.filter { className ->
!className.contains('$') &&
!className.contains("Plugin") &&
!className.contains("Fragment") &&
className.startsWith("com.") &&
!className.startsWith("com.lagradost.cloudstream3.")
}
for (className in candidates) {
try {
val clazz = classLoader.loadClass(className)
if (apis.isEmpty() && MainAPI::class.java.isAssignableFrom(clazz)) {
val instance = clazz.getDeclaredConstructor().newInstance() as MainAPI
fallbackApis.add(instance)
diagnostics.addStep("Found API: ${instance.name} (${clazz.simpleName})")
} else if (ExtractorApi::class.java.isAssignableFrom(clazz)
&& !java.lang.reflect.Modifier.isAbstract(clazz.modifiers)) {
val instance = clazz.getDeclaredConstructor().newInstance() as ExtractorApi
fallbackExtractors.add(instance)
diagnostics.addStep("Found Extractor: ${instance.name} (${instance.mainUrl})")
}
} catch (e: Error) {
val missing = extractMissingClassName(e)
if (missing != null) {
diagnostics.addStep("${className.substringAfterLast('.')}: MISSING $missing")
}
} catch (e: Exception) {
val cause = e.cause ?: e
if (cause is Error) {
val missing = extractMissingClassName(cause as Error)
if (missing != null) {
diagnostics.addStep("${className.substringAfterLast('.')}: MISSING $missing")
}
}
}
}
if (fallbackApis.isNotEmpty()) {
diagnostics.addStep("Fallback found ${fallbackApis.size} APIs")
apis = fallbackApis
}
if (fallbackExtractors.isNotEmpty()) {
diagnostics.addStep("Fallback found ${fallbackExtractors.size} extractors")
extractorRegistry.registerAll(fallbackExtractors)
}
}
apis.forEach { api ->
apiCache["$scraperId:${api.name}"] = api
}
if (apis.isNotEmpty()) {
apiCache[scraperId] = apis.first()
}
apis
} catch (e: Exception) {
diagnostics.addStep("FAILED: ${e.javaClass.simpleName}: ${e.message?.take(200)}")
emptyList()
} catch (e: Error) {
val missing = extractMissingClassName(e)
diagnostics.addStep("FAILED: ${missing ?: e.message?.take(200)}")
emptyList()
}
}
/**
* Eagerly load all ExtractorApi subclasses from the given .cs3 files.
*/
fun ensureExtractorsLoaded(scraperIds: List<String>, diagnostics: TestDiagnostics? = null) {
val idsToLoad = scraperIds.filter { it !in extractorPreloadedIds }
if (idsToLoad.isEmpty()) {
diagnostics?.addStep("Extractors: all ${scraperIds.size} already preloaded")
return
}
AcraApplication.context = context
extractorRegistry.installGlobal()
var totalExtractors = 0
var totalScanned = 0
for (scraperId in idsToLoad) {
extractorPreloadedIds.add(scraperId)
val dexFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3")
if (!dexFile.exists()) continue
// Ensure read-only
ensureDexReadOnly(dexFile)
totalScanned++
try {
val classLoader = classLoaderCache.getOrPut(scraperId) {
DexClassLoader(
dexFile.absolutePath,
codeCacheDir.absolutePath,
null,
context.classLoader
)
}
val plugin = findAndLoadPlugin(classLoader, dexFile)
if (plugin != null) {
val activity = AcraApplication.getActivity()
try {
if (activity != null) {
plugin.load(activity as android.app.Activity?)
} else {
plugin.load(context)
}
} catch (_: Exception) {
} catch (_: Error) {
}
if (plugin.registeredExtractorAPIs.isNotEmpty()) {
extractorRegistry.registerAll(plugin.registeredExtractorAPIs)
totalExtractors += plugin.registeredExtractorAPIs.size
continue
}
}
@Suppress("DEPRECATION")
val inspectDex = dalvik.system.DexFile(dexFile)
val allClasses = inspectDex.entries().toList()
inspectDex.close()
for (className in allClasses) {
if (className.contains('$')) continue
try {
val clazz = classLoader.loadClass(className)
if (ExtractorApi::class.java.isAssignableFrom(clazz)
&& !java.lang.reflect.Modifier.isAbstract(clazz.modifiers)
) {
val instance = clazz.getDeclaredConstructor().newInstance() as ExtractorApi
extractorRegistry.registerExtractor(instance)
totalExtractors++
}
} catch (_: Exception) {
} catch (_: Error) {
}
}
} catch (e: Exception) {
Log.w(TAG, "ensureExtractorsLoaded: failed for $scraperId: ${e.message}")
} catch (e: Error) {
Log.w(TAG, "ensureExtractorsLoaded: linkage error for $scraperId: ${e.message}")
}
}
Log.d(TAG, "ensureExtractorsLoaded: scanned $totalScanned .cs3 files, registered $totalExtractors extractors")
diagnostics?.addStep("Preloaded $totalExtractors extractors from $totalScanned .cs3 files")
}
fun deleteExtension(scraperId: String) {
apiCache.keys.filter { it.startsWith(scraperId) }.forEach { apiCache.remove(it) }
classLoaderCache.remove(scraperId)
extractorPreloadedIds.remove(scraperId)
File(extensionsDir, "${safeFileName(scraperId)}.cs3").delete()
Log.d(TAG, "Deleted extension $scraperId")
}
fun evictCache(scraperId: String) {
apiCache.keys.filter { it.startsWith(scraperId) }.forEach { apiCache.remove(it) }
classLoaderCache.remove(scraperId)
extractorPreloadedIds.remove(scraperId)
}
/**
* Ensure a DEX file is read-only. Required for Android API 28+ which blocks
* writable DEX file loading.
*/
private fun ensureDexReadOnly(dexFile: File) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && dexFile.canWrite()) {
dexFile.setReadOnly()
Log.d(TAG, "Fixed DEX permissions (set read-only): ${dexFile.name}")
}
}
private fun extractMissingClassName(e: Error): String? {
val msg = e.message ?: return null
val match = Regex("""(?:L?)([\w/.]+)(?:;)?""").find(msg)
return match?.groupValues?.get(1)?.replace('/', '.')
}
private fun findAndLoadPlugin(classLoader: DexClassLoader, cs3File: File): Plugin? {
val pluginClassName = readPluginClassNameFromZip(cs3File)
if (pluginClassName != null) {
try {
Log.d(TAG, "Loading plugin class from manifest: $pluginClassName")
val clazz = classLoader.loadClass(pluginClassName)
val instance = clazz.getDeclaredConstructor().newInstance()
if (instance is Plugin) {
return instance
}
if (looksLikePlugin(instance)) {
Log.d(TAG, "Using reflective wrapper for $pluginClassName (non-standard base class)")
return ReflectivePluginWrapper(instance)
}
Log.w(TAG, "Class $pluginClassName is not a Plugin and has no plugin methods")
} catch (e: Exception) {
Log.e(TAG, "Failed to load manifest class $pluginClassName: ${e.message}", e)
} catch (e: Error) {
Log.e(TAG, "Linkage error loading manifest class $pluginClassName: ${e.message}", e)
}
}
return scanForPluginClass(classLoader, cs3File)
}
private fun readPluginClassNameFromZip(cs3File: File): String? {
return try {
ZipFile(cs3File).use { zip ->
val manifestEntry = zip.getEntry("manifest.json") ?: return null
val json = zip.getInputStream(manifestEntry).bufferedReader().readText()
val obj = JSONObject(json)
obj.optString("pluginClassName", null)
}
} catch (e: Exception) {
Log.d(TAG, "Could not read manifest.json from ZIP: ${e.message}")
null
}
}
private fun scanForPluginClass(classLoader: DexClassLoader, cs3File: File): Plugin? {
try {
@Suppress("DEPRECATION")
val dex = dalvik.system.DexFile(cs3File)
val entries = dex.entries()
while (entries.hasMoreElements()) {
val className = entries.nextElement()
try {
val clazz = classLoader.loadClass(className)
if (clazz.isAnnotationPresent(CloudstreamPlugin::class.java)) {
Log.d(TAG, "Found plugin class via scan: $className")
val instance = clazz.getDeclaredConstructor().newInstance()
if (instance is Plugin) {
dex.close()
return instance
}
if (looksLikePlugin(instance)) {
Log.d(TAG, "Using reflective wrapper for $className (non-standard base class)")
dex.close()
return ReflectivePluginWrapper(instance)
}
Log.w(TAG, "Annotated class $className has no plugin methods")
}
} catch (_: ClassNotFoundException) {
} catch (_: NoClassDefFoundError) {
} catch (e: Exception) {
Log.w(TAG, "Error inspecting class $className: ${e.message}")
}
}
dex.close()
} catch (e: Exception) {
Log.d(TAG, "DexFile scan fallback failed: ${e.message}")
}
return null
}
}

View file

@ -0,0 +1,715 @@
package com.nuvio.tv.core.plugin.cloudstream
import android.util.Log
import com.lagradost.cloudstream3.AnimeLoadResponse
import com.lagradost.cloudstream3.Episode
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.LiveStreamLoadResponse
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MovieLoadResponse
import com.lagradost.cloudstream3.MovieSearchResponse
import com.lagradost.cloudstream3.TvSeriesSearchResponse
import com.lagradost.cloudstream3.AnimeSearchResponse
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.TvSeriesLoadResponse
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.metaproviders.TmdbLink
import com.lagradost.cloudstream3.metaproviders.TmdbProvider
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.app
import com.nuvio.tv.core.plugin.TestDiagnostics
import com.nuvio.tv.core.tmdb.TmdbMetadataService
import com.nuvio.tv.core.tmdb.TmdbService
import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.model.LocalScraperResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "ExtExtensionRunner"
private const val EXECUTION_TIMEOUT_MS = 120_000L
private const val MIN_TITLE_SIMILARITY = 0.5
/**
* Executes external DEX extensions by bridging between NuvioTV's TMDB ID-based system
* and the extensions' text search-based API.
*
* Flow: TMDB ID title lookup search() match load() loadLinks() LocalScraperResult
*/
@Singleton
class ExternalExtensionRunner @Inject constructor(
private val extensionLoader: ExternalExtensionLoader,
private val extractorRegistry: ExternalExtractorRegistry,
private val tmdbMetadataService: TmdbMetadataService,
private val tmdbService: TmdbService
) {
suspend fun execute(
scraperId: String,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> = withContext(Dispatchers.IO) {
extensionLoader.ensureExtractorsLoaded(listOf(scraperId))
val api = extensionLoader.getApi(scraperId)
if (api == null) {
Log.e(TAG, "No API loaded for scraper: $scraperId")
return@withContext emptyList()
}
try {
executeInternal(api, tmdbId, mediaType, season, episode)
} catch (e: Exception) {
Log.e(TAG, "Extension ${api.name} failed: ${e.javaClass.simpleName}: ${e.message}", e)
emptyList()
} catch (e: Error) {
val missing = extractMissingClass(e)
if (missing != null) {
Log.e(TAG, "Extension ${api.name} MISSING CLASS: $missing", e)
} else {
Log.e(TAG, "Extension ${api.name} linkage error: ${e.javaClass.simpleName}: ${e.message}", e)
}
emptyList()
}
}
suspend fun executeWithDiagnostics(
scraperId: String,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
diagnostics: TestDiagnostics
): List<LocalScraperResult> = withContext(Dispatchers.IO) {
extensionLoader.ensureExtractorsLoaded(listOf(scraperId), diagnostics)
diagnostics.addStep("Loading DEX extension...")
val apis = extensionLoader.loadExtensionWithDiagnostics(scraperId, diagnostics)
val api = apis.firstOrNull()
if (api == null) {
diagnostics.addStep("No MainAPI available after load")
return@withContext emptyList()
}
diagnostics.addStep("Using MainAPI: ${api.name} (${api.javaClass.simpleName})")
val isTmdb = api is TmdbProvider
diagnostics.addStep("Provider type: ${if (isTmdb) "TmdbProvider" else "search-based"}")
withTimeoutOrNull(EXECUTION_TIMEOUT_MS) {
try {
if (isTmdb) {
executeTmdbProviderWithDiagnostics(api, tmdbId, mediaType, season, episode, diagnostics)
} else {
executeSearchBasedWithDiagnostics(api, tmdbId, mediaType, season, episode, diagnostics)
}
} catch (e: Error) {
Log.e(TAG, "Diagnostic ${api.name} error: ${e.javaClass.simpleName}: ${e.message}", e)
diagnostics.addStep("Runtime error: ${e.javaClass.simpleName}")
diagnostics.addStep("Detail: ${e.message?.take(300)}")
emptyList()
} catch (e: Exception) {
Log.e(TAG, "Diagnostic ${api.name} exception: ${e.javaClass.simpleName}: ${e.message}", e)
diagnostics.addStep("Runtime exception: ${e.javaClass.simpleName}: ${e.message?.take(300)}")
emptyList()
}
} ?: run {
diagnostics.addStep("TIMEOUT after ${EXECUTION_TIMEOUT_MS}ms")
emptyList()
}
}
private suspend fun executeTmdbProviderWithDiagnostics(
api: MainAPI,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
diagnostics: TestDiagnostics
): List<LocalScraperResult> {
val tmdbIdInt = tmdbId.toIntOrNull()
val contentType = when (mediaType.lowercase()) {
"movie" -> ContentType.MOVIE
else -> ContentType.SERIES
}
diagnostics.addStep("Fetching TMDB metadata...")
val enrichment = tmdbMetadataService.fetchEnrichment(tmdbId, contentType)
val movieName = enrichment?.localizedTitle
diagnostics.addStep("TMDB title: ${movieName ?: "(null)"}")
val imdbId = if (tmdbIdInt != null) tmdbService.tmdbToImdb(tmdbIdInt, mediaType) else null
diagnostics.addStep("IMDB ID: ${imdbId ?: "(not found)"}")
val tmdbLink = TmdbLink(
imdbID = imdbId,
tmdbID = tmdbIdInt,
episode = episode,
season = season,
movieName = movieName
)
val data = tmdbLink.toJson()
diagnostics.addStep("TmdbLink JSON: ${data.take(120)}")
diagnostics.addStep("Calling loadLinks()...")
val links = mutableListOf<ExtractorLink>()
val subtitles = mutableListOf<SubtitleFile>()
// Instrument loadExtractor to log each call's result
data class ExtractorCall(val url: String, var matched: Boolean = false, var linkCount: Int = 0, var error: String? = null)
val extractorCalls = mutableListOf<ExtractorCall>()
val success = try {
api.loadLinks(
data = data,
isCasting = false,
subtitleCallback = { subtitles.add(it) },
callback = { links.add(it) }
)
} catch (e: Throwable) {
diagnostics.addStep("loadLinks THREW: ${e.javaClass.simpleName}: ${e.message?.take(120)}")
false
}
diagnostics.addStep("loadLinks returned: success=$success, ${links.size} links, ${subtitles.size} subs")
// Show missing extractor domains
val missing = extractorRegistry.getMissingExtractorDomains()
if (missing.isNotEmpty()) {
diagnostics.addStep("Missing extractors: ${missing.take(5).joinToString()}")
}
return links.filterValid().map { it.toLocalScraperResult(api.name) }
}
private suspend fun executeSearchBasedWithDiagnostics(
api: MainAPI,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
diagnostics: TestDiagnostics
): List<LocalScraperResult> {
val contentType = when (mediaType.lowercase()) {
"movie" -> ContentType.MOVIE
else -> ContentType.SERIES
}
diagnostics.addStep("Fetching TMDB metadata...")
val enrichment = tmdbMetadataService.fetchEnrichment(tmdbId, contentType)
if (enrichment == null) {
diagnostics.addStep("TMDB enrichment FAILED")
return emptyList()
}
val title = enrichment.localizedTitle
if (title == null) {
diagnostics.addStep("TMDB returned no title")
return emptyList()
}
val year = enrichment.releaseInfo?.take(4)?.toIntOrNull()
diagnostics.addStep("TMDB: \"$title\" ($year)")
// Check if search() is actually overridden
val searchMethod = try {
api.javaClass.getMethod("search", String::class.java, kotlin.coroutines.Continuation::class.java)
} catch (_: Exception) { null }
val declaringClass = searchMethod?.declaringClass?.name ?: "unknown"
diagnostics.addStep("search() declared in: $declaringClass")
// Install temporary HTTP logging on the app singleton
val httpLog = mutableListOf<String>()
val originalClient = app.baseClient
val loggingClient = originalClient.newBuilder()
.addInterceptor { chain ->
val req = chain.request()
httpLog.add("${req.method} ${req.url}")
try {
val resp = chain.proceed(req)
httpLog.add("${resp.code} (${resp.body?.contentLength() ?: "?"} bytes)")
resp
} catch (e: Exception) {
httpLog.add("← FAILED: ${e.javaClass.simpleName}: ${e.message?.take(80)}")
throw e
}
}
.build()
app.baseClient = loggingClient
diagnostics.addStep("Searching for: \"$title\"")
var searchResults = try {
api.search(title, 1)?.items
} catch (e: Exception) {
diagnostics.addStep("search() THREW: ${e.javaClass.simpleName}: ${e.message?.take(120)}")
null
} catch (e: Error) {
val missingCls = extractMissingClass(e)
diagnostics.addStep("search() ERROR: ${missingCls ?: e.message?.take(120)}")
null
} finally {
app.baseClient = originalClient
}
// Show HTTP activity
if (httpLog.isEmpty()) {
diagnostics.addStep("HTTP: no requests made by search()")
} else {
diagnostics.addStep("HTTP: ${httpLog.size / 2} request(s)")
httpLog.take(6).forEach { diagnostics.addStep(" $it") }
if (httpLog.size > 6) diagnostics.addStep(" ... and ${httpLog.size - 6} more")
}
diagnostics.addStep("Search returned: ${if (searchResults == null) "null" else "${searchResults.size} results"}")
// Fallback: if title has special characters, try simplified version
if (searchResults.isNullOrEmpty() && title.contains(Regex("[:\\-–—]"))) {
val simplified = title.replace(Regex("[:\\-–—]"), " ").replace(Regex("\\s+"), " ").trim()
diagnostics.addStep("Retrying with: \"$simplified\"")
searchResults = try {
api.search(simplified, 1)?.items
} catch (e: Exception) {
diagnostics.addStep("search(simplified) THREW: ${e.javaClass.simpleName}: ${e.message?.take(120)}")
null
} catch (e: Error) {
null
}
diagnostics.addStep("Retry returned: ${if (searchResults == null) "null" else "${searchResults.size} results"}")
}
if (searchResults.isNullOrEmpty()) return emptyList()
val bestMatch = findBestMatch(searchResults, title, year, mediaType)
if (bestMatch == null) {
diagnostics.addStep("No match above similarity threshold ($MIN_TITLE_SIMILARITY)")
searchResults.take(3).forEachIndexed { i, r ->
val sim = calculateSimilarity(r.name, title)
diagnostics.addStep(" [$i] \"${r.name}\" (sim=${String.format("%.2f", sim)})")
}
return emptyList()
}
diagnostics.addStep("Best match: \"${bestMatch.name}\" (${bestMatch.url.take(80)})")
diagnostics.addStep("Loading page...")
val loadResponse = api.load(bestMatch.url)
if (loadResponse == null) {
diagnostics.addStep("load() returned null")
return emptyList()
}
diagnostics.addStep("Loaded: ${loadResponse.javaClass.simpleName}")
val data = extractData(loadResponse, mediaType, season, episode)
if (data == null) {
diagnostics.addStep("No episode data for S${season}E${episode}")
return emptyList()
}
diagnostics.addStep("Calling loadLinks()...")
val links = mutableListOf<ExtractorLink>()
val subtitles = mutableListOf<SubtitleFile>()
val success = api.loadLinks(
data = data,
isCasting = false,
subtitleCallback = { subtitles.add(it) },
callback = { links.add(it) }
)
diagnostics.addStep("loadLinks returned: success=$success, ${links.size} links, ${subtitles.size} subs")
return links.filterValid().map { it.toLocalScraperResult(api.name) }
}
private fun extractMissingClass(e: Error): String? {
val msg = e.message ?: return null
val match = Regex("""(?:L?)([\w/.]+)(?:;)?""").find(msg)
return match?.groupValues?.get(1)?.replace('/', '.')
}
private suspend fun executeInternal(
api: MainAPI,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
if (api is TmdbProvider) {
return executeTmdbProvider(api, tmdbId, mediaType, season, episode)
}
return executeSearchBased(api, tmdbId, mediaType, season, episode)
}
/**
* Execute a TmdbProvider extension using the same flow as CloudStream:
* 1. Construct the JSON that the extension's load() expects
* 2. Call api.load(json) extension fetches metadata, constructs its internal LinkData
* 3. Extract the data string from the LoadResponse
* 4. Call api.loadLinks(data, ...) extension resolves streams
*
* TmdbProvider extensions (StreamPlay, Ultima, etc.) override load() to accept
* JSON like {"id":803796,"type":"movie"}, NOT a TMDB URL. They then construct
* their own internal data classes (LinkData) with fields like "id", "imdbId",
* "title" etc. that differ from TmdbLink's field names.
*/
private suspend fun executeTmdbProvider(
api: MainAPI,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
val tmdbIdInt = tmdbId.toIntOrNull()
val isMovie = mediaType.lowercase() == "movie"
val type = if (isMovie) "movie" else "tv"
// Construct the JSON that TmdbProvider extensions expect in load()
// This matches what their search() returns as URLs
val loadJson = """{"id":$tmdbIdInt,"type":"$type"}"""
Log.d(TAG, "TmdbProvider ${api.name}: load($loadJson)")
val loadResponse = try {
api.load(loadJson)
} catch (e: Exception) {
Log.w(TAG, "TmdbProvider ${api.name} load(json) threw: ${e.javaClass.simpleName}: ${e.message?.take(100)}")
null
} catch (e: Error) {
val missing = extractMissingClass(e)
Log.w(TAG, "TmdbProvider ${api.name} load(json) error: ${missing ?: e.message?.take(100)}")
null
}
if (loadResponse != null) {
Log.d(TAG, "TmdbProvider ${api.name}: loaded ${loadResponse.javaClass.simpleName}")
val data = extractData(loadResponse, mediaType, season, episode)
if (data != null) {
Log.d(TAG, "TmdbProvider ${api.name}: loadLinks data=${data.take(200)}")
return executeTmdbLoadLinks(api, data)
}
Log.w(TAG, "TmdbProvider ${api.name}: no data for S${season}E${episode}")
}
// Fallback: try with TMDB URL format (standard TmdbProvider.load())
val tmdbUrl = if (isMovie) {
"https://www.themoviedb.org/movie/$tmdbId"
} else {
"https://www.themoviedb.org/tv/$tmdbId"
}
Log.d(TAG, "TmdbProvider ${api.name}: fallback load($tmdbUrl)")
val fallbackResponse = try {
api.load(tmdbUrl)
} catch (e: Exception) {
Log.w(TAG, "TmdbProvider ${api.name} fallback load(url) threw: ${e.javaClass.simpleName}: ${e.message?.take(100)}")
null
} catch (e: Error) { null }
if (fallbackResponse != null) {
val data = extractData(fallbackResponse, mediaType, season, episode)
if (data != null) {
Log.d(TAG, "TmdbProvider ${api.name}: fallback loadLinks data=${data.take(200)}")
return executeTmdbLoadLinks(api, data)
}
}
Log.w(TAG, "TmdbProvider ${api.name}: both load() paths failed")
return emptyList()
}
private suspend fun executeTmdbLoadLinks(
api: MainAPI,
data: String
): List<LocalScraperResult> {
// Use thread-safe list so links collected during loadLinks survive timeout
val links = java.util.Collections.synchronizedList(mutableListOf<ExtractorLink>())
val subtitles = java.util.Collections.synchronizedList(mutableListOf<SubtitleFile>())
try {
api.loadLinks(
data = data,
isCasting = false,
subtitleCallback = { subtitles.add(it) },
callback = { links.add(it) }
)
} catch (e: Exception) {
// TimeoutCancellationException or other — links collected so far are still valid
Log.w(TAG, "TmdbProvider ${api.name} loadLinks ended: ${e.javaClass.simpleName} (${links.size} links collected)")
} catch (e: Error) {
val missing = extractMissingClass(e)
Log.w(TAG, "TmdbProvider ${api.name} loadLinks error: ${missing ?: e.message} (${links.size} links collected)")
}
if (links.isEmpty()) {
Log.w(TAG, "TmdbProvider ${api.name}: 0 links collected")
return emptyList()
}
Log.d(TAG, "TmdbProvider ${api.name}: ${links.size} links, ${subtitles.size} subs")
return links.filterValid().map { link -> link.toLocalScraperResult(api.name) }
}
private suspend fun executeSearchBased(
api: MainAPI,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
val contentType = when (mediaType.lowercase()) {
"movie" -> ContentType.MOVIE
else -> ContentType.SERIES
}
val enrichment = tmdbMetadataService.fetchEnrichment(tmdbId, contentType)
if (enrichment == null) {
Log.e(TAG, "Failed to fetch TMDB enrichment for $tmdbId")
return emptyList()
}
val title = enrichment.localizedTitle ?: return emptyList()
val year = enrichment.releaseInfo?.take(4)?.toIntOrNull()
Log.d(TAG, "SearchBased ${api.name}: searching for \"$title\"")
var searchResults = try {
api.search(title, 1)?.items
} catch (e: Exception) {
Log.e(TAG, "SearchBased ${api.name} search() threw: ${e.javaClass.simpleName}: ${e.message}", e)
null
} catch (e: Error) {
val missing = extractMissingClass(e)
Log.e(TAG, "SearchBased ${api.name} search() error: ${missing ?: e.message}", e)
null
}
if (searchResults.isNullOrEmpty() && title.contains(Regex("[:\\-–—]"))) {
val simplified = title.replace(Regex("[:\\-–—]"), " ").replace(Regex("\\s+"), " ").trim()
Log.d(TAG, "SearchBased ${api.name}: retrying with simplified \"$simplified\"")
searchResults = try {
api.search(simplified, 1)?.items
} catch (e: Exception) {
Log.e(TAG, "SearchBased ${api.name} search(simplified) threw: ${e.javaClass.simpleName}: ${e.message}", e)
null
} catch (e: Error) {
null
}
}
if (searchResults.isNullOrEmpty()) {
Log.w(TAG, "SearchBased ${api.name}: 0 search results for \"$title\"")
return emptyList()
}
Log.d(TAG, "SearchBased ${api.name}: ${searchResults.size} results")
val bestMatch = findBestMatch(searchResults, title, year, mediaType)
if (bestMatch == null) {
Log.d(TAG, "No suitable match in ${api.name} results for: $title ($year)")
searchResults.take(5).forEachIndexed { i, r ->
val sim = calculateSimilarity(r.name, title)
Log.d(TAG, " [$i] \"${r.name}\" (sim=${String.format("%.2f", sim)}, type=${r.type})")
}
return emptyList()
}
Log.d(TAG, "Best match from ${api.name}: ${bestMatch.name} (${bestMatch.url})")
val loadResponse = try {
api.load(bestMatch.url)
} catch (e: Exception) {
Log.e(TAG, "SearchBased ${api.name} load() threw: ${e.javaClass.simpleName}: ${e.message}", e)
null
} catch (e: Error) {
val missing = extractMissingClass(e)
Log.e(TAG, "SearchBased ${api.name} load() error: ${missing ?: e.message}", e)
null
}
if (loadResponse == null) {
Log.w(TAG, "SearchBased ${api.name}: load(${bestMatch.url}) returned null")
return emptyList()
}
Log.d(TAG, "SearchBased ${api.name}: loaded ${loadResponse.javaClass.simpleName}")
val data = extractData(loadResponse, mediaType, season, episode)
if (data == null) {
Log.d(TAG, "No data extracted from ${api.name} for S${season}E${episode}")
return emptyList()
}
val links = mutableListOf<ExtractorLink>()
val subtitles = mutableListOf<SubtitleFile>()
val success = try {
api.loadLinks(
data = data,
isCasting = false,
subtitleCallback = { subtitles.add(it) },
callback = { links.add(it) }
)
} catch (e: Exception) {
Log.e(TAG, "SearchBased ${api.name} loadLinks threw: ${e.javaClass.simpleName}: ${e.message}", e)
false
} catch (e: Error) {
val missing = extractMissingClass(e)
Log.e(TAG, "SearchBased ${api.name} loadLinks error: ${missing ?: e.message}", e)
false
}
if (!success && links.isEmpty()) {
Log.w(TAG, "SearchBased ${api.name}: loadLinks returned false, 0 links")
return emptyList()
}
Log.d(TAG, "SearchBased ${api.name}: ${links.size} links, ${subtitles.size} subs")
return links.filterValid().map { link -> link.toLocalScraperResult(api.name) }
}
/** Extract year from SearchResponse concrete types (not in the interface). */
private fun getSearchResponseYear(result: SearchResponse): Int? = when (result) {
is MovieSearchResponse -> result.year
is TvSeriesSearchResponse -> result.year
is AnimeSearchResponse -> result.year
else -> null
}
private fun findBestMatch(
results: List<SearchResponse>,
targetTitle: String,
targetYear: Int?,
mediaType: String
): SearchResponse? {
val isMovie = mediaType.lowercase() == "movie"
return results
.map { result ->
val titleSimilarity = calculateSimilarity(result.name, targetTitle)
val typeBonus = when {
result.type == null -> 0.0
isMovie && result.type in listOf(TvType.Movie, TvType.AnimeMovie, TvType.Documentary) -> 0.1
!isMovie && result.type in listOf(TvType.TvSeries, TvType.Anime, TvType.OVA, TvType.Cartoon, TvType.AsianDrama) -> 0.1
else -> -0.1
}
val resultYear = getSearchResponseYear(result)
val yearBonus = if (targetYear != null && resultYear == targetYear) 0.1 else 0.0
val score = titleSimilarity + typeBonus + yearBonus
result to score
}
.filter { it.second >= MIN_TITLE_SIMILARITY }
.maxByOrNull { it.second }
?.first
}
private fun extractData(
response: LoadResponse,
mediaType: String,
season: Int?,
episode: Int?
): String? = when (response) {
is MovieLoadResponse -> response.dataUrl
is LiveStreamLoadResponse -> response.dataUrl
is TvSeriesLoadResponse -> {
findEpisode(response.episodes, season, episode)?.data
}
is AnimeLoadResponse -> {
val allEpisodes = response.episodes.values.flatten()
findEpisode(allEpisodes, season, episode)?.data
}
else -> null
}
private fun findEpisode(episodes: List<Episode>, season: Int?, episode: Int?): Episode? {
if (episodes.isEmpty()) return null
if (season != null && episode != null) {
episodes.firstOrNull { it.season == season && it.episode == episode }?.let { return it }
}
if (episode != null) {
episodes.firstOrNull { it.episode == episode && (it.season == null || it.season == season) }
?.let { return it }
}
if (season != null && episode != null) {
val absoluteEpisode = episodes.indexOfFirst {
(it.season == season || it.season == null) && it.episode == episode
}
if (absoluteEpisode >= 0) return episodes[absoluteEpisode]
}
return null
}
private fun calculateSimilarity(s1: String, s2: String): Double {
val a = s1.lowercase().trim()
val b = s2.lowercase().trim()
if (a == b) return 1.0
if (a.isEmpty() || b.isEmpty()) return 0.0
val aNorm = a.replace(Regex("\\(\\d{4}\\)"), "").trim()
val bNorm = b.replace(Regex("\\(\\d{4}\\)"), "").trim()
if (aNorm == bNorm) return 0.95
if (aNorm.contains(bNorm) || bNorm.contains(aNorm)) return 0.85
val distance = levenshteinDistance(aNorm, bNorm)
val maxLen = maxOf(aNorm.length, bNorm.length)
return 1.0 - (distance.toDouble() / maxLen)
}
private fun levenshteinDistance(s1: String, s2: String): Int {
val m = s1.length
val n = s2.length
val dp = Array(m + 1) { IntArray(n + 1) }
for (i in 0..m) dp[i][0] = i
for (j in 0..n) dp[0][j] = j
for (i in 1..m) {
for (j in 1..n) {
val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1
dp[i][j] = minOf(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
)
}
}
return dp[m][n]
}
/** Filter out broken ExtractorLinks (invalid URLs, error strings, etc.) */
private fun List<ExtractorLink>.filterValid(): List<ExtractorLink> {
return filter { link ->
val url = link.url
when {
url.isBlank() -> false
url == "error" || url == "null" -> false
!url.startsWith("http://") && !url.startsWith("https://") -> false
else -> true
}.also { valid ->
if (!valid) Log.w(TAG, "Filtered invalid link: source=${link.source}, url=${url.take(60)}")
}
}
}
private fun ExtractorLink.toLocalScraperResult(providerName: String): LocalScraperResult {
val qualityStr = Qualities.getStringByInt(quality).ifEmpty { null }
val streamType = when (type) {
ExtractorLinkType.M3U8 -> "hls"
ExtractorLinkType.DASH -> "dash"
else -> null
}
val allHeaders = buildMap {
putAll(headers)
if (referer.isNotBlank()) put("Referer", referer)
}
return LocalScraperResult(
title = name,
name = source,
url = url,
quality = qualityStr,
type = streamType,
headers = allHeaders.ifEmpty { null },
provider = providerName
)
}
}

View file

@ -0,0 +1,85 @@
package com.nuvio.tv.core.plugin.cloudstream
import android.util.Log
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.loadExtractor
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "ExtExtractorRegistry"
/**
* Registry of loaded extractors from external extensions.
* Bridges NuvioTV's extractor management with the CloudStream library's
* global [extractorApis] list and [loadExtractor] function.
*/
@Singleton
class ExternalExtractorRegistry @Inject constructor() {
private val missingExtractorDomains = mutableSetOf<String>()
private var installed = false
fun registerExtractor(extractor: ExtractorApi) {
// Avoid duplicates by mainUrl
if (extractorApis.any { it.mainUrl == extractor.mainUrl }) return
extractorApis.add(extractor)
Log.d(TAG, "Registered extractor: ${extractor.name} (${extractor.mainUrl})")
}
fun registerAll(extractorList: List<ExtractorApi>) {
extractorList.forEach { registerExtractor(it) }
}
fun clear() {
missingExtractorDomains.clear()
}
/**
* Try to resolve a URL using the library's loadExtractor.
* The library's loadExtractor iterates through the global extractorApis list
* which includes both built-in library extractors and extension-provided ones.
*/
suspend fun resolveExtractor(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
): Boolean {
return try {
val result = loadExtractor(url, referer, subtitleCallback, callback)
if (!result) {
val domain = try {
java.net.URI(url).host ?: url
} catch (_: Exception) {
url
}
if (missingExtractorDomains.add(domain)) {
Log.w(TAG, "No extractor registered for domain: $domain (url: $url)")
}
}
result
} catch (e: Exception) {
Log.e(TAG, "loadExtractor error for ${url.take(80)}: ${e.message}", e)
false
} catch (e: Error) {
Log.e(TAG, "loadExtractor linkage error for ${url.take(80)}: ${e.message}", e)
false
}
}
/**
* Install this registry. The library's loadExtractor function uses the global
* extractorApis list directly, so no delegate setup is needed.
* This method ensures the library's built-in extractors are available.
*/
fun installGlobal() {
if (installed) return
installed = true
Log.d(TAG, "installGlobal: library extractorApis has ${extractorApis.size} built-in extractors")
}
fun getMissingExtractorDomains(): Set<String> = missingExtractorDomains.toSet()
}

View file

@ -0,0 +1,147 @@
package com.nuvio.tv.core.plugin.cloudstream
import android.util.Log
import com.nuvio.tv.domain.model.ExternalPluginEntry
import com.nuvio.tv.domain.model.ExternalRepoManifest
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "ExternalRepoParser"
/**
* Result of parsing an external repository URL.
*/
data class ExternalRepoParseResult(
val name: String,
val description: String?,
val plugins: List<ExternalPluginEntry>
)
/**
* Parses external extension repository formats.
*
* Supports two formats:
* 1. Repo manifest with `pluginLists` URLs pointing to separate plugins.json files
* 2. Direct plugins array (list of [ExternalPluginEntry])
*/
@Singleton
class ExternalRepoParser @Inject constructor(
private val moshi: Moshi
) {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.followRedirects(true)
.build()
private val repoManifestAdapter = moshi.adapter(ExternalRepoManifest::class.java)
private val pluginListType = Types.newParameterizedType(List::class.java, ExternalPluginEntry::class.java)
private val pluginListAdapter = moshi.adapter<List<ExternalPluginEntry>>(pluginListType)
/**
* Try to parse the given URL as an external repository.
* Returns null if the content doesn't match any known external format.
*/
suspend fun tryParse(url: String): ExternalRepoParseResult? = withContext(Dispatchers.IO) {
val body = fetchBody(url) ?: return@withContext null
val trimmed = body.trim()
// Try as repo manifest (has "pluginLists" key)
if (trimmed.contains("\"pluginLists\"")) {
try {
val manifest = repoManifestAdapter.fromJson(trimmed)
if (manifest != null && manifest.pluginLists.isNotEmpty()) {
Log.d(TAG, "Parsed as repo manifest: ${manifest.name}, ${manifest.pluginLists.size} plugin lists")
val allPlugins = coroutineScope {
manifest.pluginLists.map { listUrl ->
async {
val resolvedUrl = resolveUrl(url, listUrl)
fetchPluginList(resolvedUrl) ?: emptyList()
}
}.awaitAll().flatten()
}
return@withContext ExternalRepoParseResult(
name = manifest.name,
description = manifest.description,
plugins = allPlugins
)
}
} catch (e: Exception) {
Log.d(TAG, "Not a repo manifest: ${e.message}")
}
}
// Try as direct plugins array (has "internalName" or "tvTypes")
if (trimmed.startsWith("[")) {
try {
val plugins = pluginListAdapter.fromJson(trimmed)
if (!plugins.isNullOrEmpty() && plugins.first().internalName.isNotBlank()) {
Log.d(TAG, "Parsed as direct plugins list: ${plugins.size} plugins")
val repoName = inferRepoName(url)
return@withContext ExternalRepoParseResult(
name = repoName,
description = null,
plugins = plugins
)
}
} catch (e: Exception) {
Log.d(TAG, "Not a direct plugins list: ${e.message}")
}
}
null
}
private suspend fun fetchPluginList(url: String): List<ExternalPluginEntry>? = withContext(Dispatchers.IO) {
val body = fetchBody(url) ?: return@withContext null
try {
pluginListAdapter.fromJson(body.trim())
} catch (e: Exception) {
Log.e(TAG, "Failed to parse plugin list from $url: ${e.message}")
null
}
}
private fun fetchBody(url: String): String? {
return try {
val request = Request.Builder()
.url(url)
.header("User-Agent", "NuvioTV/1.0")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.e(TAG, "HTTP ${response.code} for $url")
return null
}
response.body?.string()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch $url: ${e.message}")
null
}
}
private fun resolveUrl(baseUrl: String, relativeUrl: String): String {
if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://")) {
return relativeUrl
}
val base = baseUrl.substringBeforeLast("/")
return "$base/$relativeUrl"
}
private fun inferRepoName(url: String): String {
// Try to extract a meaningful name from the URL
val path = url.substringAfter("://").substringBefore("?")
val segments = path.split("/").filter { it.isNotBlank() }
return segments.lastOrNull()?.removeSuffix(".json") ?: "External Repository"
}
}

View file

@ -0,0 +1,14 @@
package com.nuvio.tv.core.plugin.cloudstream
import com.lagradost.cloudstream3.TvType
/** Map CloudStream TvType to NuvioTV content type string ("movie" or "tv"). */
fun TvType.toNuvioType(): String = when (this) {
TvType.Movie, TvType.AnimeMovie, TvType.Documentary, TvType.Torrent -> "movie"
else -> "tv"
}
/** Parse TvType from string name, case-insensitive. */
fun tvTypeFromString(value: String): TvType? = TvType.entries.firstOrNull {
it.name.equals(value, ignoreCase = true)
}

View file

@ -48,7 +48,8 @@ class ProfileManager @Inject constructor(
name: String,
avatarColorHex: String,
usesPrimaryAddons: Boolean = false,
usesPrimaryPlugins: Boolean = false
usesPrimaryPlugins: Boolean = false,
avatarId: String? = null
): Boolean {
val current = profiles.value
if (current.size >= 4) return false
@ -61,8 +62,10 @@ class ProfileManager @Inject constructor(
name = name.trim().ifEmpty { "Profile $nextId" },
avatarColorHex = avatarColorHex,
usesPrimaryAddons = usesPrimaryAddons,
usesPrimaryPlugins = usesPrimaryPlugins
usesPrimaryPlugins = usesPrimaryPlugins,
avatarId = avatarId
)
factory.markProfileCreated(nextId)
profileDataStore.upsertProfile(profile)
return true
}
@ -70,7 +73,7 @@ class ProfileManager @Inject constructor(
suspend fun deleteProfile(id: Int): Boolean {
if (id == 1) return false
if (profiles.value.none { it.id == id }) return false
deleteProfileData(id)
deleteProfileDataAsync(id)
profileDataStore.deleteProfile(id)
return true
}
@ -81,7 +84,7 @@ class ProfileManager @Inject constructor(
return true
}
private fun deleteProfileData(profileId: Int) {
private suspend fun deleteProfileDataAsync(profileId: Int) {
if (profileId == 1) return
factory.clearProfile(profileId)

View file

@ -1,5 +1,6 @@
package com.nuvio.tv.core.server
import android.content.Context
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import fi.iki.elonen.NanoHTTPD
@ -8,12 +9,28 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class AddonConfigServer(
private val context: Context,
private val webConfigMode: WebConfigMode,
private val currentPageStateProvider: () -> PageState,
private val onChangeProposed: (PendingAddonChange) -> Unit,
private val logoProvider: (() -> ByteArray?)? = null,
port: Int = 8080
) : NanoHTTPD(port) {
enum class WebConfigMode(
val allowAddonManagement: Boolean,
val allowCatalogManagement: Boolean
) {
FULL(
allowAddonManagement = true,
allowCatalogManagement = true
),
COLLECTIONS_ONLY(
allowAddonManagement = false,
allowCatalogManagement = false
)
}
data class AddonInfo(
val url: String,
val name: String,
@ -29,9 +46,41 @@ class AddonConfigServer(
val isDisabled: Boolean
)
data class CollectionInfo(
val id: String,
val title: String,
val backdropImageUrl: String? = null,
val pinToTop: Boolean = false,
val focusGlowEnabled: Boolean = true,
val viewMode: String = "TABBED_GRID",
val showAllTab: Boolean = true,
val folders: List<FolderInfo>
)
data class FolderInfo(
val id: String,
val title: String,
val coverImageUrl: String?,
val focusGifUrl: String?,
val focusGifEnabled: Boolean = true,
val coverEmoji: String?,
val tileShape: String,
val hideTitle: Boolean,
val catalogSources: List<CatalogSourceInfo>
)
data class CatalogSourceInfo(
val addonId: String,
val type: String,
val catalogId: String,
val genre: String? = null
)
data class PageState(
val addons: List<AddonInfo>,
val catalogs: List<CatalogInfo>
val catalogs: List<CatalogInfo>,
val collections: List<CollectionInfo> = emptyList(),
val disabledCollectionKeys: List<String> = emptyList()
)
data class PendingAddonChange(
@ -39,6 +88,8 @@ class AddonConfigServer(
val proposedUrls: List<String>,
val proposedCatalogOrderKeys: List<String> = emptyList(),
val proposedDisabledCatalogKeys: List<String> = emptyList(),
val proposedCollectionsJson: String? = null,
val proposedDisabledCollectionKeys: List<String> = emptyList(),
var status: ChangeStatus = ChangeStatus.PENDING
)
@ -65,13 +116,18 @@ class AddonConfigServer(
method == Method.GET && uri == "/api/state" -> servePageState()
method == Method.GET && uri == "/api/addons" -> serveAddonList()
method == Method.POST && uri == "/api/addons" -> handleAddonUpdate(session)
method == Method.GET && uri == "/api/collections" -> serveCollections()
method == Method.GET && uri.startsWith("/api/status/") -> serveChangeStatus(uri)
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not found")
}
}
private fun serveWebPage(): Response {
return newFixedLengthResponse(Response.Status.OK, "text/html", AddonWebPage.getHtml())
return newFixedLengthResponse(
Response.Status.OK,
"text/html; charset=utf-8",
AddonWebPage.getHtml(context, webConfigMode)
)
}
private fun serveLogo(): Response {
@ -88,16 +144,22 @@ class AddonConfigServer(
}
}
private fun serveCollections(): Response {
val collections = currentPageStateProvider().collections
val json = gson.toJson(collections)
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", json)
}
private fun serveAddonList(): Response {
val addons = currentPageStateProvider().addons
val json = gson.toJson(addons)
return newFixedLengthResponse(Response.Status.OK, "application/json", json)
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", json)
}
private fun servePageState(): Response {
val state = currentPageStateProvider()
val json = gson.toJson(state)
return newFixedLengthResponse(Response.Status.OK, "application/json", json)
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", json)
}
private fun handleAddonUpdate(session: IHTTPSession): Response {
@ -116,16 +178,25 @@ class AddonConfigServer(
val urls = parseStringList(parsed["urls"])
val catalogOrderKeys = parseStringList(parsed["catalogOrderKeys"])
val disabledCatalogKeys = parseStringList(parsed["disabledCatalogKeys"])
PendingAddonChange(
proposedUrls = urls,
proposedCatalogOrderKeys = catalogOrderKeys,
proposedDisabledCatalogKeys = disabledCatalogKeys
val collectionsRaw = parsed["collections"]
val collectionsJson = if (collectionsRaw != null) gson.toJson(collectionsRaw) else null
val disabledCollectionKeys = parseStringList(parsed["disabledCollectionKeys"])
sanitizePendingAddonChange(
mode = webConfigMode,
proposedChange = PendingAddonChange(
proposedUrls = urls,
proposedCatalogOrderKeys = catalogOrderKeys,
proposedDisabledCatalogKeys = disabledCatalogKeys,
proposedCollectionsJson = collectionsJson,
proposedDisabledCollectionKeys = disabledCollectionKeys
),
currentState = currentPageStateProvider()
)
} catch (e: Exception) {
val error = mapOf("error" to "Invalid request body")
return newFixedLengthResponse(
Response.Status.BAD_REQUEST,
"application/json",
"application/json; charset=utf-8",
gson.toJson(error)
)
}
@ -134,7 +205,7 @@ class AddonConfigServer(
onChangeProposed(change)
val response = mapOf("status" to "pending_confirmation", "id" to change.id)
return newFixedLengthResponse(Response.Status.OK, "application/json", gson.toJson(response))
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", gson.toJson(response))
}
private fun serveChangeStatus(uri: String): Response {
@ -142,7 +213,7 @@ class AddonConfigServer(
val change = pendingChanges[id]
val status = change?.status?.name?.lowercase() ?: "not_found"
val response = mapOf("status" to status)
return newFixedLengthResponse(Response.Status.OK, "application/json", gson.toJson(response))
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", gson.toJson(response))
}
private fun parseStringList(rawValue: Any?): List<String> {
@ -156,6 +227,8 @@ class AddonConfigServer(
companion object {
fun startOnAvailablePort(
context: Context,
webConfigMode: WebConfigMode = WebConfigMode.FULL,
currentPageStateProvider: () -> PageState,
onChangeProposed: (PendingAddonChange) -> Unit,
logoProvider: (() -> ByteArray?)? = null,
@ -164,7 +237,14 @@ class AddonConfigServer(
): AddonConfigServer? {
for (port in startPort until startPort + maxAttempts) {
try {
val server = AddonConfigServer(currentPageStateProvider, onChangeProposed, logoProvider, port)
val server = AddonConfigServer(
context = context,
webConfigMode = webConfigMode,
currentPageStateProvider = currentPageStateProvider,
onChangeProposed = onChangeProposed,
logoProvider = logoProvider,
port = port
)
server.start(SOCKET_READ_TIMEOUT, false)
return server
} catch (e: Exception) {
@ -175,3 +255,33 @@ class AddonConfigServer(
}
}
}
internal fun sanitizePendingAddonChange(
mode: AddonConfigServer.WebConfigMode,
proposedChange: AddonConfigServer.PendingAddonChange,
currentState: AddonConfigServer.PageState
): AddonConfigServer.PendingAddonChange {
if (mode.allowAddonManagement && mode.allowCatalogManagement) {
return proposedChange
}
return proposedChange.copy(
proposedUrls = if (mode.allowAddonManagement) {
proposedChange.proposedUrls
} else {
currentState.addons.map { it.url }
},
proposedCatalogOrderKeys = if (mode.allowCatalogManagement) {
proposedChange.proposedCatalogOrderKeys
} else {
currentState.catalogs.map { it.key }
},
proposedDisabledCatalogKeys = if (mode.allowCatalogManagement) {
proposedChange.proposedDisabledCatalogKeys
} else {
currentState.catalogs
.filter { it.isDisabled }
.map { it.disableKey }
}
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
package com.nuvio.tv.core.server
import android.content.Context
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import fi.iki.elonen.NanoHTTPD
@ -8,6 +9,7 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class RepositoryConfigServer(
private val context: Context,
private val currentRepositoriesProvider: () -> List<RepositoryInfo>,
private val onChangeProposed: (PendingRepoChange) -> Unit,
private val logoProvider: (() -> ByteArray?)? = null,
@ -54,7 +56,7 @@ class RepositoryConfigServer(
}
private fun serveWebPage(): Response {
return newFixedLengthResponse(Response.Status.OK, "text/html", RepositoryWebPage.getHtml())
return newFixedLengthResponse(Response.Status.OK, "text/html", RepositoryWebPage.getHtml(context))
}
private fun serveLogo(): Response {
@ -118,6 +120,7 @@ class RepositoryConfigServer(
companion object {
fun startOnAvailablePort(
context: Context,
currentRepositoriesProvider: () -> List<RepositoryInfo>,
onChangeProposed: (PendingRepoChange) -> Unit,
logoProvider: (() -> ByteArray?)? = null,
@ -126,7 +129,7 @@ class RepositoryConfigServer(
): RepositoryConfigServer? {
for (port in startPort until startPort + maxAttempts) {
try {
val server = RepositoryConfigServer(currentRepositoriesProvider, onChangeProposed, logoProvider, port)
val server = RepositoryConfigServer(context, currentRepositoriesProvider, onChangeProposed, logoProvider, port)
server.start(SOCKET_READ_TIMEOUT, false)
return server
} catch (e: Exception) {

View file

@ -1,14 +1,27 @@
package com.nuvio.tv.core.server
import android.content.Context
import android.content.res.Configuration
import com.nuvio.tv.R
import java.util.Locale
object RepositoryWebPage {
fun getHtml(): String = """
fun getHtml(baseContext: Context): String {
val tag = baseContext.getSharedPreferences("app_locale", Context.MODE_PRIVATE)
.getString("locale_tag", null)
val context = if (!tag.isNullOrEmpty()) {
val config = Configuration(baseContext.resources.configuration)
config.setLocale(Locale.forLanguageTag(tag))
baseContext.createConfigurationContext(config)
} else baseContext
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>NuvioTV - Manage Repositories</title>
<title>${context.getString(R.string.app_name)} - ${context.getString(R.string.web_manage_repos_title)}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
* {
@ -305,30 +318,30 @@ object RepositoryWebPage {
<div class="page">
<div class="header">
<img src="/logo.png" alt="NuvioTV" class="header-logo">
<p>Manage your repositories</p>
<p>${context.getString(R.string.web_manage_repos_subtitle)}</p>
</div>
<div class="add-section">
<label>Add repository by URL</label>
<label>${context.getString(R.string.web_add_repo_url)}</label>
<div class="add-row">
<input type="url" id="repoUrl" placeholder="https://example.com/manifest.json" autocomplete="off" autocapitalize="off" spellcheck="false">
<button class="btn" id="addBtn" onclick="addRepo()">Add</button>
<input type="url" id="repoUrl" placeholder="${context.getString(R.string.web_placeholder_url)}" autocomplete="off" autocapitalize="off" spellcheck="false">
<button class="btn" id="addBtn" onclick="addRepo()">${context.getString(R.string.web_btn_add)}</button>
</div>
<div class="add-error" id="addError"></div>
</div>
<div class="section-label">Installed</div>
<div class="section-label">${context.getString(R.string.web_installed)}</div>
<ul class="repo-list" id="repoList"></ul>
<div class="empty-state" id="emptyState">No repositories installed</div>
<div class="empty-state" id="emptyState">${context.getString(R.string.web_no_repos)}</div>
<button class="btn btn-save" id="saveBtn" onclick="saveChanges()">Save Changes</button>
<button class="btn btn-save" id="saveBtn" onclick="saveChanges()">${context.getString(R.string.web_btn_save)}</button>
</div>
<div class="status-overlay" id="statusOverlay">
<div class="status-content" id="statusContent"></div>
</div>
<div class="connection-bar" id="connectionBar">Connection to TV lost</div>
<div class="connection-bar" id="connectionBar">${context.getString(R.string.web_connection_lost)}</div>
<script>
var repos = [];
@ -385,13 +398,13 @@ function renderList() {
li.innerHTML =
'<div class="repo-info">' +
'<div class="repo-name">' + escapeHtml(repo.name || repo.url) +
(repo.isNew ? '<span class="badge-new">New</span>' : '') +
(repo.isNew ? '<span class="badge-new">${context.getString(R.string.web_badge_new).replace("'", "\\'")}</span>' : '') +
'</div>' +
(repo.description ? '<div class="repo-desc">' + escapeHtml(repo.description) + '</div>' : '') +
'<div class="repo-url">' + escapeHtml(repo.url) + '</div>' +
'</div>' +
'<div class="repo-actions">' +
'<button class="btn btn-remove" onclick="removeRepo(' + i + ')">Remove</button>' +
'<button class="btn btn-remove" onclick="removeRepo(' + i + ')">${context.getString(R.string.web_btn_remove).replace("'", "\\'")}</button>' +
'</div>';
list.appendChild(li);
@ -413,7 +426,7 @@ async function addRepo() {
url = url.replace(/\/+$/, '');
if (repos.some(function(r) { return r.url === url; })) {
errorEl.textContent = 'This repository is already in the list';
errorEl.textContent = '${context.getString(R.string.web_error_repo_exists).replace("'", "\\'")}';
errorEl.style.display = 'block';
setTimeout(function() { errorEl.style.display = 'none'; }, 3000);
return;
@ -451,7 +464,7 @@ async function saveChanges() {
saveBtn.disabled = false;
}
} catch (e) {
showErrorStatus('Failed to save. Check your connection to the TV.');
showErrorStatus('${context.getString(R.string.web_error_failed_save).replace("'", "\\'")}');
saveBtn.disabled = false;
}
}
@ -461,8 +474,8 @@ function showPendingStatus() {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="spinner"></div></div>' +
'<div class="status-title">Waiting for TV</div>' +
'<div class="status-message">Please confirm the changes on your TV to apply them.</div>';
'<div class="status-title">${context.getString(R.string.web_status_waiting_tv).replace("'", "\\'")}</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_waiting_tv).replace("'", "\\'")}</div>';
content.className = 'status-content';
overlay.classList.add('visible');
}
@ -471,8 +484,8 @@ function showSuccessStatus() {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></div></div>' +
'<div class="status-title">Changes Applied</div>' +
'<div class="status-message">Your repository configuration has been updated on the TV.</div>';
'<div class="status-title">${context.getString(R.string.web_status_changes_applied).replace("'", "\\'")}</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_repo_updated).replace("'", "\\'")}</div>';
content.className = 'status-content status-success';
setTimeout(dismissStatus, 2500);
}
@ -481,8 +494,8 @@ function showRejectedStatus() {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg></div></div>' +
'<div class="status-title">Changes Rejected</div>' +
'<div class="status-message">The changes were declined on the TV. Your list has been reverted.</div>';
'<div class="status-title">${context.getString(R.string.web_status_changes_rejected).replace("'", "\\'")}</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_changes_rejected).replace("'", "\\'")}</div>';
content.className = 'status-content status-rejected';
setTimeout(function() {
repos = JSON.parse(JSON.stringify(originalRepos));
@ -496,9 +509,9 @@ function showErrorStatus(msg) {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg></div></div>' +
'<div class="status-title">Something Went Wrong</div>' +
'<div class="status-title">${context.getString(R.string.web_status_error).replace("'", "\\'")}</div>' +
'<div class="status-message">' + escapeHtml(msg) + '</div>' +
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
content.className = 'status-content status-error';
overlay.classList.add('visible');
}
@ -507,9 +520,9 @@ function showTimeoutStatus() {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></div></div>' +
'<div class="status-title">Timed Out</div>' +
'<div class="status-message">No response from the TV. Please try again.</div>' +
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
'<div class="status-title">${context.getString(R.string.web_status_timeout).replace("'", "\\'")}</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_timeout).replace("'", "\\'")}</div>' +
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
content.className = 'status-content status-error';
}
@ -517,9 +530,9 @@ function showDisconnectedStatus() {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22M16.72 11.06A10.94 10.94 0 0 1 19 12.55M5 12.55a10.94 10.94 0 0 1 5.17-2.39M10.71 5.05A16 16 0 0 1 22.56 9M1.42 9a15.91 15.91 0 0 1 4.7-2.88M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01"/></svg></div></div>' +
'<div class="status-title">Connection Lost</div>' +
'<div class="status-message">The TV server is no longer reachable. The changes may have been applied.</div>' +
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
'<div class="status-title">${context.getString(R.string.web_connection_lost).replace("'", "\\'")}</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_connection_lost).replace("'", "\\'")}</div>' +
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
content.className = 'status-content status-error';
}
@ -591,4 +604,5 @@ loadRepos();
</body>
</html>
""".trimIndent()
}
}

View file

@ -50,6 +50,7 @@ class AddonSyncService @Inject constructor(
}
val localUrls = addonPreferences.installedAddonUrls.first()
val userSetNames = addonPreferences.userSetNames.first()
Log.d(TAG, "pushToRemote: localUrls count=${localUrls.size} for profile $profileId")
val params = buildJsonObject {
@ -58,6 +59,10 @@ class AddonSyncService @Inject constructor(
addJsonObject {
put("url", url)
put("sort_order", index)
val name = userSetNames[url]
if (!name.isNullOrBlank()) {
put("name", name)
}
}
}
})
@ -96,6 +101,14 @@ class AddonSyncService @Inject constructor(
.decodeList<SupabaseAddon>()
}
val nameMap = mutableMapOf<String, String>()
remoteAddons.forEach { addon ->
if (!addon.name.isNullOrBlank()) {
nameMap[canonicalizeUrl(addon.url)] = addon.name
}
}
addonPreferences.setUserSetNames(nameMap)
Result.success(
remoteAddons
.sortedBy { it.sortOrder }
@ -106,4 +119,17 @@ class AddonSyncService @Inject constructor(
Result.failure(e)
}
}
private fun canonicalizeUrl(url: String): String {
val trimmed = url.trim().trimEnd('/')
val queryStart = trimmed.indexOf('?')
val path = if (queryStart >= 0) trimmed.substring(0, queryStart) else trimmed
val query = if (queryStart >= 0) trimmed.substring(queryStart) else ""
val cleanPath = if (path.endsWith("/manifest.json", ignoreCase = true)) {
path.dropLast("/manifest.json".length).trimEnd('/')
} else {
path.trimEnd('/')
}
return cleanPath + query
}
}

View file

@ -0,0 +1,172 @@
package com.nuvio.tv.core.sync
import android.util.Log
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.CollectionsDataStore
import com.nuvio.tv.data.remote.supabase.SupabaseCollectionBlob
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "CollectionSyncService"
private const val PUSH_DEBOUNCE_MS = 1500L
@Singleton
class CollectionSyncService @Inject constructor(
private val postgrest: Postgrest,
private val authManager: AuthManager,
private val collectionsDataStore: CollectionsDataStore,
private val profileManager: ProfileManager
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
var isSyncingFromRemote: Boolean = false
private var pushJob: Job? = null
init {
observeLocalChangesAndPush()
}
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
return try {
block()
} catch (e: Exception) {
if (!authManager.refreshSessionIfJwtExpired(e)) throw e
block()
}
}
/**
* Push local collections JSON to Supabase via RPC.
* Uses a SECURITY DEFINER function to handle RLS for linked devices.
*/
suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
try {
val profileId = profileManager.activeProfileId.value
val json = collectionsDataStore.exportCurrentProfileJson()
val collectionsJsonElement = if (json.isNullOrBlank()) {
JsonArray(emptyList())
} else {
Json.parseToJsonElement(json)
}
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_collections_json", collectionsJsonElement)
}
withJwtRefreshRetry {
postgrest.rpc("sync_push_collections", params)
}
Log.d(TAG, "Pushed collections to remote for profile $profileId")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to push collections to remote", e)
Result.failure(e)
}
}
/**
* Pull remote collections JSON and apply locally.
* Returns true if local state was updated.
*/
suspend fun pullFromRemote(): Result<Boolean> = withContext(Dispatchers.IO) {
try {
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_collections", params)
}
val rows = response.decodeList<SupabaseCollectionBlob>()
val blob = rows.firstOrNull()
if (blob == null) {
Log.d(TAG, "No remote collections for profile $profileId; keeping local")
return@withContext Result.success(false)
}
val remoteJson = blob.collectionsJson.toString()
val remoteCollections = collectionsDataStore.importFromJson(remoteJson)
// Preserve local if remote is empty but local has data
val localCollections = collectionsDataStore.getCurrentCollections()
if (remoteCollections.isEmpty() && localCollections.isNotEmpty()) {
Log.w(TAG, "Remote collections empty while local has ${localCollections.size}; preserving local")
return@withContext Result.success(false)
}
// Check if different
val localJson = collectionsDataStore.exportCurrentProfileJson() ?: ""
if (remoteJson == localJson) {
Log.d(TAG, "Remote collections already match local for profile $profileId")
return@withContext Result.success(false)
}
isSyncingFromRemote = true
try {
collectionsDataStore.setCollections(remoteCollections)
} finally {
isSyncingFromRemote = false
}
Log.d(TAG, "Applied ${remoteCollections.size} remote collections for profile $profileId")
Result.success(true)
} catch (e: Exception) {
Log.e(TAG, "Failed to pull collections from remote", e)
Result.failure(e)
}
}
/**
* Trigger a debounced push to remote after a local change.
*/
fun triggerPush() {
if (isSyncingFromRemote) return
if (!authManager.isAuthenticated) return
pushJob?.cancel()
pushJob = scope.launch {
delay(500)
pushToRemote()
}
}
/**
* Observe local collections DataStore changes and auto-push to remote.
*/
@OptIn(kotlinx.coroutines.FlowPreview::class)
private fun observeLocalChangesAndPush() {
scope.launch {
collectionsDataStore.collections
.drop(1) // skip initial value
.distinctUntilChanged()
.debounce(PUSH_DEBOUNCE_MS)
.collect {
if (!authManager.isAuthenticated) return@collect
if (isSyncingFromRemote) return@collect
pushToRemote()
}
}
}
}

View file

@ -0,0 +1,209 @@
package com.nuvio.tv.core.sync
import android.util.Log
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.CollectionsDataStore
import com.nuvio.tv.data.local.LayoutPreferenceDataStore
import com.nuvio.tv.data.remote.supabase.SupabaseHomeCatalogSettingsBlob
import com.nuvio.tv.domain.repository.AddonRepository
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "HomeCatalogSettingsSyncService"
private const val SETTINGS_SYNC_PLATFORM = "tv"
private const val PAYLOAD_SAMPLE_LIMIT = 5
@Serializable
data class SyncCatalogItem(
@SerialName("addon_id") val addonId: String,
val type: String,
@SerialName("catalog_id") val catalogId: String,
val enabled: Boolean = true,
val order: Int = 0,
@SerialName("custom_title") val customTitle: String = "",
@SerialName("is_collection") val isCollection: Boolean = false,
@SerialName("collection_id") val collectionId: String = "",
)
@Serializable
data class SyncHomeCatalogPayload(
val items: List<SyncCatalogItem> = emptyList(),
)
@Singleton
class HomeCatalogSettingsSyncService @Inject constructor(
private val postgrest: Postgrest,
private val authManager: AuthManager,
private val layoutPreferenceDataStore: LayoutPreferenceDataStore,
private val profileManager: ProfileManager,
private val addonRepository: AddonRepository,
private val collectionsDataStore: CollectionsDataStore
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
@Volatile
var isSyncingFromRemote: Boolean = false
private var pushJob: Job? = null
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
return try {
block()
} catch (e: Exception) {
if (!authManager.refreshSessionIfJwtExpired(e)) throw e
block()
}
}
suspend fun pushToRemote(reason: String = "unspecified"): Result<Unit> = withContext(Dispatchers.IO) {
try {
val profileId = profileManager.activeProfileId.value
val payload = loadLocalPayload()
Log.d(TAG, "Push start profile=$profileId reason=$reason ${payload.summary()}")
pushPayload(profileId, payload)
Log.d(TAG, "Push success profile=$profileId reason=$reason")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Push failed reason=$reason", e)
Result.failure(e)
}
}
suspend fun pullFromRemote(): Result<Boolean> = withContext(Dispatchers.IO) {
try {
val profileId = profileManager.activeProfileId.value
val localState = layoutPreferenceDataStore.getHomeCatalogSettingsState()
Log.d(TAG, "Pull start profile=$profileId ${localState.summary()}")
if (localState.disabledKeys.any(::hasLegacyHomeCatalogDisabledKeyFormat)) {
val localPayload = loadLocalPayload()
if (localPayload.items.isNotEmpty()) {
isSyncingFromRemote = true
try {
layoutPreferenceDataStore.applyCatalogSettingsFromRemote(localPayload)
} finally {
isSyncingFromRemote = false
}
Log.i(TAG, "Migrated legacy local keys profile=$profileId ${localPayload.summary()} (no startup push)")
return@withContext Result.success(true)
}
}
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_platform", SETTINGS_SYNC_PLATFORM)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_home_catalog_settings", params)
}
val rows = response.decodeList<SupabaseHomeCatalogSettingsBlob>()
val blob = rows.firstOrNull()
if (blob == null) {
Log.d(TAG, "No remote row profile=$profileId; preserving local (startup is pull-only)")
return@withContext Result.success(false)
}
val remotePayload = runCatching {
json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), blob.settingsJson)
}.getOrNull()
if (remotePayload == null) {
Log.w(TAG, "Pull parse failure profile=$profileId")
return@withContext Result.success(false)
}
Log.d(TAG, "Pull remote payload profile=$profileId ${remotePayload.summary()}")
if (remotePayload.items.isEmpty()) {
Log.d(TAG, "Remote payload empty profile=$profileId; preserving local (startup is pull-only)")
return@withContext Result.success(false)
}
isSyncingFromRemote = true
try {
layoutPreferenceDataStore.applyCatalogSettingsFromRemote(remotePayload)
} finally {
isSyncingFromRemote = false
}
Log.d(TAG, "Pull apply success profile=$profileId ${remotePayload.summary()}")
Result.success(true)
} catch (e: Exception) {
Log.e(TAG, "Failed to pull home catalog settings", e)
Result.failure(e)
}
}
fun triggerPush() {
if (isSyncingFromRemote) return
if (!authManager.isAuthenticated) return
pushJob?.cancel()
pushJob = scope.launch {
delay(500)
pushToRemote(reason = "triggerPush")
}
}
private suspend fun loadLocalPayload(): SyncHomeCatalogPayload {
val addons = addonRepository.getInstalledAddons().first()
val collections = collectionsDataStore.getCurrentCollections()
return layoutPreferenceDataStore.exportCatalogSettingsToSyncPayload(addons, collections)
}
private suspend fun pushPayload(profileId: Int, payload: SyncHomeCatalogPayload) {
val jsonElement = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload)
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_settings_json", jsonElement)
put("p_platform", SETTINGS_SYNC_PLATFORM)
}
withJwtRefreshRetry {
postgrest.rpc("sync_push_home_catalog_settings", params)
}
}
}
private fun LocalHomeCatalogSettingsState.summary(): String {
val orderSample = orderKeys.take(PAYLOAD_SAMPLE_LIMIT)
val disabledSample = disabledKeys.take(PAYLOAD_SAMPLE_LIMIT)
val titleSample = customTitles.keys.take(PAYLOAD_SAMPLE_LIMIT)
return "localState(order=${orderKeys.size}, disabled=${disabledKeys.size}, titles=${customTitles.size}, orderSample=$orderSample, disabledSample=$disabledSample, titleSample=$titleSample)"
}
private fun SyncHomeCatalogPayload.summary(): String {
val disabledCount = items.count { !it.enabled }
val collectionCount = items.count { it.isCollection }
val sample = items.take(PAYLOAD_SAMPLE_LIMIT).joinToString(separator = " | ") { item ->
if (item.isCollection) {
"collection:${item.collectionId},enabled=${item.enabled},order=${item.order}"
} else {
"catalog:${item.addonId}/${item.type}/${item.catalogId},enabled=${item.enabled},order=${item.order}"
}
}
return "payload(items=${items.size}, disabled=$disabledCount, collections=$collectionCount, sample=[$sample])"
}

View file

@ -0,0 +1,153 @@
package com.nuvio.tv.core.sync
import com.nuvio.tv.domain.model.Addon
import com.nuvio.tv.domain.model.CatalogDescriptor
import com.nuvio.tv.domain.model.Collection
internal data class LocalHomeCatalogSettingsState(
val orderKeys: List<String> = emptyList(),
val disabledKeys: Set<String> = emptySet(),
val customTitles: Map<String, String> = emptyMap()
)
private data class HomeCatalogSyncEntry(
val key: String,
val addonId: String = "",
val addonBaseUrl: String = "",
val type: String = "",
val catalogId: String = "",
val catalogName: String = "",
val isCollection: Boolean = false,
val collectionId: String = ""
)
internal fun homeCatalogKey(addonId: String, type: String, catalogId: String): String {
return "${addonId}_${type}_${catalogId}"
}
internal fun homeCollectionKey(collectionId: String): String {
return "collection_${collectionId}"
}
internal fun homeLegacyDisabledCatalogKey(
addonBaseUrl: String,
type: String,
catalogId: String,
catalogName: String
): String {
return "${addonBaseUrl}_${type}_${catalogId}_${catalogName}"
}
internal fun hasLegacyHomeCatalogDisabledKeyFormat(key: String): Boolean {
return key.contains("://")
}
internal fun isHomeCatalogDisabled(
disabledKeys: Set<String>,
addonId: String,
addonBaseUrl: String,
type: String,
catalogId: String,
catalogName: String
): Boolean {
return homeCatalogKey(addonId, type, catalogId) in disabledKeys ||
homeLegacyDisabledCatalogKey(addonBaseUrl, type, catalogId, catalogName) in disabledKeys
}
internal fun buildHomeCatalogSyncPayload(
addons: List<Addon>,
collections: List<Collection>,
localState: LocalHomeCatalogSettingsState
): SyncHomeCatalogPayload {
val catalogEntries = buildCatalogEntries(addons)
val collectionEntries = collections.map { collection ->
HomeCatalogSyncEntry(
key = homeCollectionKey(collection.id),
isCollection = true,
collectionId = collection.id
)
}
val entryByKey = (catalogEntries + collectionEntries).associateBy { it.key }
val catalogKeys = catalogEntries.map { it.key }
val collectionKeys = collectionEntries.map { it.key }
val savedValid = localState.orderKeys
.asSequence()
.filter { it in entryByKey }
.distinct()
.toList()
val savedSet = savedValid.toSet()
val mergedOrder = savedValid +
catalogKeys.filterNot { it in savedSet } +
collectionKeys.filterNot { it in savedSet }
val items = mergedOrder.mapIndexedNotNull { index, key ->
val entry = entryByKey[key] ?: return@mapIndexedNotNull null
if (entry.isCollection) {
SyncCatalogItem(
addonId = "",
type = "",
catalogId = "",
enabled = key !in localState.disabledKeys,
order = index,
customTitle = localState.customTitles[key].orEmpty(),
isCollection = true,
collectionId = entry.collectionId
)
} else {
SyncCatalogItem(
addonId = entry.addonId,
type = entry.type,
catalogId = entry.catalogId,
enabled = !isHomeCatalogDisabled(
disabledKeys = localState.disabledKeys,
addonId = entry.addonId,
addonBaseUrl = entry.addonBaseUrl,
type = entry.type,
catalogId = entry.catalogId,
catalogName = entry.catalogName
),
order = index,
customTitle = localState.customTitles[key].orEmpty(),
isCollection = false,
collectionId = ""
)
}
}
return SyncHomeCatalogPayload(items = items)
}
private fun buildCatalogEntries(addons: List<Addon>): List<HomeCatalogSyncEntry> {
val entries = mutableListOf<HomeCatalogSyncEntry>()
val seenKeys = mutableSetOf<String>()
addons.forEach { addon ->
addon.catalogs
.filter { it.shouldShowOnHomeForSync() }
.forEach { catalog ->
val key = homeCatalogKey(addon.id, catalog.apiType, catalog.id)
if (seenKeys.add(key)) {
entries.add(
HomeCatalogSyncEntry(
key = key,
addonId = addon.id,
addonBaseUrl = addon.baseUrl,
type = catalog.apiType,
catalogId = catalog.id,
catalogName = catalog.name
)
)
}
}
}
return entries
}
private fun CatalogDescriptor.shouldShowOnHomeForSync(): Boolean {
if (extra.any { it.name.equals("search", ignoreCase = true) && it.isRequired }) {
return false
}
return !hasExplicitShowInHome || showInHome
}

View file

@ -21,6 +21,8 @@ import javax.inject.Singleton
private const val TAG = "LibrarySyncService"
private const val PULL_PAGE_SIZE = 500
@Singleton
class LibrarySyncService @Inject constructor(
private val authManager: AuthManager,
@ -66,6 +68,7 @@ class LibrarySyncService @Inject constructor(
item.genres.forEach { genre -> add(kotlinx.serialization.json.JsonPrimitive(genre)) }
})
put("addon_base_url", item.addonBaseUrl)
put("added_at", item.addedAt)
}
}
})
@ -91,17 +94,29 @@ class LibrarySyncService @Inject constructor(
}
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_library", params)
}
val remote = response.decodeList<SupabaseLibraryItem>()
val allItems = mutableListOf<SupabaseLibraryItem>()
var offset = 0
Log.d(TAG, "pullFromRemote: fetched ${remote.size} library items from Supabase for profile $profileId")
while (true) {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_limit", PULL_PAGE_SIZE)
put("p_offset", offset)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_library", params)
}
val page = response.decodeList<SupabaseLibraryItem>()
allItems.addAll(page)
Log.d(TAG, "pullFromRemote: fetched page at offset=$offset, got ${page.size} items")
Result.success(remote.map { entry ->
if (page.size < PULL_PAGE_SIZE) break
offset += PULL_PAGE_SIZE
}
Log.d(TAG, "pullFromRemote: fetched ${allItems.size} total library items for profile $profileId")
Result.success(allItems.map { entry ->
SavedLibraryItem(
id = entry.contentId,
type = entry.contentType,
@ -113,7 +128,8 @@ class LibrarySyncService @Inject constructor(
releaseInfo = entry.releaseInfo,
imdbRating = entry.imdbRating,
genres = entry.genres,
addonBaseUrl = entry.addonBaseUrl
addonBaseUrl = entry.addonBaseUrl,
addedAt = entry.addedAt
)
})
} catch (e: Exception) {

View file

@ -5,6 +5,7 @@ import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.PluginDataStore
import com.nuvio.tv.data.remote.supabase.SupabasePlugin
import com.nuvio.tv.domain.model.RemotePluginInfo
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
@ -60,6 +61,7 @@ class PluginSyncService @Inject constructor(
put("name", repo.name)
put("enabled", repo.enabled)
put("sort_order", index)
put("repo_type", repo.type.name)
}
}
})
@ -78,7 +80,7 @@ class PluginSyncService @Inject constructor(
}
}
suspend fun getRemoteRepoUrls(): Result<List<String>> = withContext(Dispatchers.IO) {
suspend fun getRemoteRepoUrls(): Result<List<RemotePluginInfo>> = withContext(Dispatchers.IO) {
try {
val effectiveUserId = authManager.getEffectiveUserId(fallbackToOwnIdOnFailure = false)
?: return@withContext Result.failure(
@ -101,7 +103,7 @@ class PluginSyncService @Inject constructor(
Result.success(
remotePlugins
.sortedBy { it.sortOrder }
.map { it.url }
.map { RemotePluginInfo(url = it.url, repoType = it.repoType) }
)
} catch (e: Exception) {
Log.e(TAG, "Failed to get remote repo URLs", e)

View file

@ -0,0 +1,393 @@
package com.nuvio.tv.core.sync
import android.os.SystemClock
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.ProfileDataStoreFactory
import com.nuvio.tv.data.remote.supabase.SupabaseProfileSettingsBlob
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "ProfileSettingsSyncService"
private const val SETTINGS_PUSH_DEBOUNCE_MS = 1500L
private const val FOREGROUND_PULL_DELAY_MS = 2500L
private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 60_000L
private const val SETTINGS_SYNC_PLATFORM = "tv"
@Singleton
class ProfileSettingsSyncService @Inject constructor(
private val authManager: AuthManager,
private val postgrest: Postgrest,
private val profileManager: ProfileManager,
private val profileDataStoreFactory: ProfileDataStoreFactory
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val syncMutex = Mutex()
@Volatile
private var applyingRemoteBlob: Boolean = false
@Volatile
private var skipNextPushSignature: String? = null
private var foregroundPullJob: Job? = null
private var lastForegroundPullAtMs: Long = 0L
private val syncedFeatures = listOf(
"theme_settings",
"layout_settings",
"player_settings",
"trailer_settings",
"tmdb_settings",
"mdblist_settings",
"animeskip_settings",
"track_preference"
)
private val catalogKeysExcludedFromBlob = setOf(
"home_catalog_order_keys",
"disabled_home_catalog_keys",
"custom_catalog_titles"
)
init {
observeLocalSettingsChangesAndSync()
}
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
return try {
block()
} catch (e: Exception) {
if (!authManager.refreshSessionIfJwtExpired(e)) throw e
block()
}
}
suspend fun pushCurrentProfileToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
syncMutex.withLock {
try {
val profileId = profileManager.activeProfileId.value
val settingsJson = exportSettingsBlob(profileId)
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_settings_json", settingsJson)
put("p_platform", SETTINGS_SYNC_PLATFORM)
}
withJwtRefreshRetry {
postgrest.rpc("sync_push_profile_settings_blob", params)
}
Log.d(TAG, "Pushed profile settings blob for profile $profileId")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to push profile settings blob", e)
Result.failure(e)
}
}
}
suspend fun pullCurrentProfileFromRemote(): Result<Boolean> = withContext(Dispatchers.IO) {
syncMutex.withLock {
try {
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_platform", SETTINGS_SYNC_PLATFORM)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_profile_settings_blob", params)
}
val rows = response.decodeList<SupabaseProfileSettingsBlob>()
val blob = rows.firstOrNull()?.settingsJson
if (blob == null) {
Log.d(TAG, "No remote profile settings blob for profile $profileId; keeping local settings")
return@withLock Result.success(false)
}
val featuresJson = blob["features"]?.jsonObject ?: return@withLock Result.success(false)
val remoteSignature = buildSettingsSignature(featuresJson)
val localSignature = buildSettingsSignature(profileId)
if (remoteSignature == localSignature) {
Log.d(TAG, "Remote profile settings already match local for profile $profileId")
return@withLock Result.success(false)
}
importSettingsBlob(profileId, featuresJson)
skipNextPushSignature = remoteSignature
Log.d(TAG, "Applied remote profile settings blob for profile $profileId")
Result.success(true)
} catch (e: Exception) {
Log.e(TAG, "Failed to pull profile settings blob", e)
Result.failure(e)
}
}
}
fun requestForegroundPull(force: Boolean = false) {
if (!authManager.isAuthenticated) return
val now = SystemClock.elapsedRealtime()
if (!force && foregroundPullJob?.isActive == true) return
if (!force && now - lastForegroundPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS) return
foregroundPullJob = scope.launch {
if (!force) {
delay(FOREGROUND_PULL_DELAY_MS)
}
if (!authManager.isAuthenticated) return@launch
lastForegroundPullAtMs = SystemClock.elapsedRealtime()
pullCurrentProfileFromRemote()
}
}
private suspend fun exportSettingsBlob(profileId: Int): JsonObject {
val features = buildJsonObject {
syncedFeatures.forEach { feature ->
val prefs = profileDataStoreFactory.get(profileId, feature).data.first()
val serialized = buildJsonObject {
prefs.asMap().forEach { (key, rawValue) ->
if (feature == "layout_settings" && key.name in catalogKeysExcludedFromBlob) return@forEach
val encoded = encodePreferenceValue(rawValue) ?: return@forEach
put(key.name, encoded)
}
}
put(feature, serialized)
}
}
return buildJsonObject {
put("version", 1)
put("features", features)
}
}
private suspend fun importSettingsBlob(profileId: Int, featuresJson: JsonObject) {
applyingRemoteBlob = true
try {
syncedFeatures.forEach { feature ->
val featureJson = featuresJson[feature]?.jsonObject ?: return@forEach
profileDataStoreFactory.get(profileId, feature).edit { mutablePrefs ->
val preservedEntries = if (feature == "layout_settings") {
val entries = mutableMapOf<Preferences.Key<*>, Any>()
catalogKeysExcludedFromBlob.forEach { keyName ->
val strKey = stringPreferencesKey(keyName)
mutablePrefs[strKey]?.let { entries[strKey] = it }
val boolKey = booleanPreferencesKey(keyName)
mutablePrefs[boolKey]?.let { entries[boolKey] = it }
}
entries
} else {
emptyMap()
}
mutablePrefs.clear()
featureJson.forEach { (keyName, encodedValue) ->
if (feature == "layout_settings" && keyName in catalogKeysExcludedFromBlob) return@forEach
applyEncodedPreference(mutablePrefs, keyName, encodedValue)
}
@Suppress("UNCHECKED_CAST")
preservedEntries.forEach { (key, value) ->
when (value) {
is String -> mutablePrefs[key as Preferences.Key<String>] = value
is Boolean -> mutablePrefs[key as Preferences.Key<Boolean>] = value
is Int -> mutablePrefs[key as Preferences.Key<Int>] = value
is Long -> mutablePrefs[key as Preferences.Key<Long>] = value
is Float -> mutablePrefs[key as Preferences.Key<Float>] = value
is Double -> mutablePrefs[key as Preferences.Key<Double>] = value
}
}
}
}
} finally {
applyingRemoteBlob = false
}
}
private fun observeLocalSettingsChangesAndSync() {
scope.launch {
profileManager.activeProfileId
.flatMapLatest { profileId ->
val featureFlows = syncedFeatures.map { feature ->
profileDataStoreFactory.get(profileId, feature).data
.map { prefs ->
"$feature={${buildFeatureSignature(prefs, feature)}}"
}
}
combine(featureFlows) { signatures ->
signatures.joinToString(separator = "||")
}
}
.drop(1)
.distinctUntilChanged()
.debounce(SETTINGS_PUSH_DEBOUNCE_MS)
.collect { signature ->
if (!authManager.isAuthenticated) return@collect
if (applyingRemoteBlob) return@collect
if (signature == skipNextPushSignature) {
skipNextPushSignature = null
return@collect
}
pushCurrentProfileToRemote()
}
}
}
private suspend fun buildSettingsSignature(profileId: Int): String {
val signatures = ArrayList<String>(syncedFeatures.size)
syncedFeatures.forEach { feature ->
val prefs = profileDataStoreFactory.get(profileId, feature).data.first()
signatures += "$feature={${buildFeatureSignature(prefs, feature)}}"
}
return signatures.joinToString(separator = "||")
}
private fun buildSettingsSignature(featuresJson: JsonObject): String {
return syncedFeatures.joinToString(separator = "||") { feature ->
val featureJson = featuresJson[feature]?.jsonObject ?: JsonObject(emptyMap())
"$feature={${buildFeatureSignature(featureJson)}}"
}
}
private fun buildFeatureSignature(prefs: Preferences, feature: String = ""): String {
return prefs.asMap()
.entries
.mapNotNull { (key, rawValue) ->
if (feature == "layout_settings" && key.name in catalogKeysExcludedFromBlob) return@mapNotNull null
encodePreferenceValue(rawValue)?.let { encoded ->
key.name to encoded.toString()
}
}
.sortedBy { it.first }
.joinToString(separator = "|") { (key, value) -> "$key=$value" }
}
private fun buildFeatureSignature(featureJson: JsonObject): String {
return featureJson.entries
.sortedBy { it.key }
.joinToString(separator = "|") { (key, value) -> "$key=$value" }
}
private fun encodePreferenceValue(rawValue: Any?): JsonObject? {
return when (rawValue) {
is String -> buildJsonObject {
put("type", "string")
put("value", rawValue)
}
is Boolean -> buildJsonObject {
put("type", "boolean")
put("value", rawValue)
}
is Int -> buildJsonObject {
put("type", "int")
put("value", rawValue)
}
is Long -> buildJsonObject {
put("type", "long")
put("value", rawValue)
}
is Float -> buildJsonObject {
put("type", "float")
put("value", rawValue)
}
is Double -> buildJsonObject {
put("type", "double")
put("value", rawValue)
}
is Set<*> -> {
val allStrings = rawValue.all { it is String }
if (!allStrings) return null
buildJsonObject {
put("type", "string_set")
val values = rawValue.map { it as String }.sorted()
put("value", JsonArray(values.map { JsonPrimitive(it) }))
}
}
else -> null
}
}
private fun applyEncodedPreference(
mutablePrefs: androidx.datastore.preferences.core.MutablePreferences,
keyName: String,
encodedValue: JsonElement
) {
val obj = encodedValue as? JsonObject ?: return
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return
val value = obj["value"] ?: JsonNull
when (type) {
"string" -> {
val parsed = value.jsonPrimitive.contentOrNull ?: return
mutablePrefs[stringPreferencesKey(keyName)] = parsed
}
"boolean" -> {
val parsed = value.jsonPrimitive.contentOrNull?.toBooleanStrictOrNull() ?: return
mutablePrefs[booleanPreferencesKey(keyName)] = parsed
}
"int" -> {
val parsed = value.jsonPrimitive.intOrNull ?: return
mutablePrefs[intPreferencesKey(keyName)] = parsed
}
"long" -> {
val parsed = value.jsonPrimitive.longOrNull ?: return
mutablePrefs[longPreferencesKey(keyName)] = parsed
}
"float" -> {
val parsed = value.jsonPrimitive.floatOrNull ?: return
mutablePrefs[floatPreferencesKey(keyName)] = parsed
}
"double" -> {
val parsed = value.jsonPrimitive.doubleOrNull ?: return
mutablePrefs[doublePreferencesKey(keyName)] = parsed
}
"string_set" -> {
val parsed = value.jsonArray.mapNotNull { it.jsonPrimitive.contentOrNull }.toSet()
mutablePrefs[stringSetPreferencesKey(keyName)] = parsed
}
}
}
}

View file

@ -4,7 +4,9 @@ import android.util.Log
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.ProfileDataStore
import com.nuvio.tv.data.remote.supabase.SupabaseProfileLockState
import com.nuvio.tv.data.remote.supabase.SupabaseProfile
import com.nuvio.tv.data.remote.supabase.SupabaseProfilePinVerifyResult
import com.nuvio.tv.domain.model.UserProfile
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.Dispatchers
@ -47,6 +49,7 @@ class ProfileSyncService @Inject constructor(
put("avatar_color_hex", profile.avatarColorHex)
put("uses_primary_addons", profile.usesPrimaryAddons)
put("uses_primary_plugins", profile.usesPrimaryPlugins)
put("avatar_id", profile.avatarId)
}
}
})
@ -78,7 +81,8 @@ class ProfileSyncService @Inject constructor(
name = entry.name,
avatarColorHex = entry.avatarColorHex,
usesPrimaryAddons = entry.usesPrimaryAddons,
usesPrimaryPlugins = entry.usesPrimaryPlugins
usesPrimaryPlugins = entry.usesPrimaryPlugins,
avatarId = entry.avatarId
)
}
@ -110,4 +114,73 @@ class ProfileSyncService @Inject constructor(
Result.failure(e)
}
}
suspend fun pullProfileLockStates(): Result<Map<Int, Boolean>> = withContext(Dispatchers.IO) {
try {
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_profile_locks")
}
val remote = response.decodeList<SupabaseProfileLockState>()
val result = remote.associate { it.profileIndex to it.pinEnabled }
Result.success(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to pull profile lock states", e)
Result.failure(e)
}
}
suspend fun setProfilePin(profileId: Int, pin: String, currentPin: String? = null): Result<Unit> = withContext(Dispatchers.IO) {
try {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_pin", pin)
if (!currentPin.isNullOrBlank()) {
put("p_current_pin", currentPin)
}
}
withJwtRefreshRetry {
postgrest.rpc("set_profile_pin", params)
}
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to set profile PIN", e)
Result.failure(e)
}
}
suspend fun clearProfilePin(profileId: Int, currentPin: String? = null): Result<Unit> = withContext(Dispatchers.IO) {
try {
val params = buildJsonObject {
put("p_profile_id", profileId)
if (!currentPin.isNullOrBlank()) {
put("p_current_pin", currentPin)
}
}
withJwtRefreshRetry {
postgrest.rpc("clear_profile_pin", params)
}
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear profile PIN", e)
Result.failure(e)
}
}
suspend fun verifyProfilePin(profileId: Int, pin: String): Result<SupabaseProfilePinVerifyResult> = withContext(Dispatchers.IO) {
try {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_pin", pin)
}
val response = withJwtRefreshRetry {
postgrest.rpc("verify_profile_pin", params)
}
val decoded = response.decodeList<SupabaseProfilePinVerifyResult>().firstOrNull()
?: SupabaseProfilePinVerifyResult(unlocked = false, retryAfterSeconds = 0)
Result.success(decoded)
} catch (e: Exception) {
Log.e(TAG, "Failed to verify profile PIN", e)
Result.failure(e)
}
}
}

View file

@ -16,6 +16,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@ -29,9 +31,12 @@ class StartupSyncService @Inject constructor(
private val authManager: AuthManager,
private val pluginSyncService: PluginSyncService,
private val addonSyncService: AddonSyncService,
private val collectionSyncService: CollectionSyncService,
private val homeCatalogSettingsSyncService: HomeCatalogSettingsSyncService,
private val watchProgressSyncService: WatchProgressSyncService,
private val librarySyncService: LibrarySyncService,
private val watchedItemsSyncService: WatchedItemsSyncService,
private val profileSettingsSyncService: ProfileSettingsSyncService,
private val profileSyncService: ProfileSyncService,
private val pluginManager: PluginManager,
private val addonRepository: AddonRepositoryImpl,
@ -46,10 +51,15 @@ class StartupSyncService @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var startupPullJob: Job? = null
private var lastPulledKey: String? = null
private var lastPulledIncludedProfileSettings: Boolean = false
@Volatile
private var forceSyncRequested: Boolean = false
@Volatile
private var forceSyncIncludesProfileSettings: Boolean = true
@Volatile
private var pendingResyncKey: String? = null
@Volatile
private var pendingResyncIncludesProfileSettings: Boolean = false
init {
scope.launch {
@ -57,15 +67,23 @@ class StartupSyncService @Inject constructor(
when (state) {
is AuthState.FullAccount -> {
val force = forceSyncRequested
val started = scheduleStartupPull(state.userId, force = force)
val includeProfileSettings = if (force) forceSyncIncludesProfileSettings else true
val started = scheduleStartupPull(
userId = state.userId,
force = force,
includeProfileSettings = includeProfileSettings
)
if (force && started) forceSyncRequested = false
}
is AuthState.SignedOut -> {
startupPullJob?.cancel()
startupPullJob = null
lastPulledKey = null
lastPulledIncludedProfileSettings = false
forceSyncRequested = false
forceSyncIncludesProfileSettings = true
pendingResyncKey = null
pendingResyncIncludesProfileSettings = false
}
is AuthState.Loading -> Unit
}
@ -73,11 +91,16 @@ class StartupSyncService @Inject constructor(
}
}
fun requestSyncNow() {
fun requestSyncNow(includeProfileSettings: Boolean = true) {
forceSyncRequested = true
forceSyncIncludesProfileSettings = forceSyncIncludesProfileSettings || includeProfileSettings
when (val state = authManager.authState.value) {
is AuthState.FullAccount -> {
val started = scheduleStartupPull(state.userId, force = true)
val started = scheduleStartupPull(
userId = state.userId,
force = true,
includeProfileSettings = includeProfileSettings
)
if (started) forceSyncRequested = false
}
else -> Unit
@ -89,28 +112,40 @@ class StartupSyncService @Inject constructor(
return "${userId}_p${profileId}"
}
private fun scheduleStartupPull(userId: String, force: Boolean = false): Boolean {
private fun scheduleStartupPull(
userId: String,
force: Boolean = false,
includeProfileSettings: Boolean = true
): Boolean {
val key = pullKey(userId)
if (!force && lastPulledKey == key) return false
if (
!force &&
lastPulledKey == key &&
(!includeProfileSettings || lastPulledIncludedProfileSettings)
) {
return false
}
// Never cancel an active sync — it may be mid-write to DataStore.
// Instead, schedule a follow-up sync after the current one finishes.
if (startupPullJob?.isActive == true) {
if (force) pendingResyncKey = key
if (force) {
pendingResyncKey = key
pendingResyncIncludesProfileSettings =
pendingResyncIncludesProfileSettings || includeProfileSettings
}
return false
}
startupPullJob = scope.launch {
val maxAttempts = 3
var syncCompleted = false
repeat(maxAttempts) { index ->
val attempt = index + 1
Log.d(TAG, "Startup sync attempt $attempt/$maxAttempts for key=$key")
val result = pullRemoteData()
for (attempt in 1..maxAttempts) {
val result = pullRemoteData(includeProfileSettings = includeProfileSettings)
if (result.isSuccess) {
lastPulledKey = key
Log.d(TAG, "Startup sync completed for key=$key")
lastPulledIncludedProfileSettings = includeProfileSettings
syncCompleted = true
return@repeat
break
}
Log.w(TAG, "Startup sync attempt $attempt failed for key=$key", result.exceptionOrNull())
@ -121,17 +156,26 @@ class StartupSyncService @Inject constructor(
val resyncKey = pendingResyncKey
if (resyncKey != null) {
val resyncIncludesProfileSettings = pendingResyncIncludesProfileSettings
pendingResyncKey = null
if (!syncCompleted || resyncKey != lastPulledKey) {
Log.d(TAG, "Running pending re-sync for key=$resyncKey")
scheduleStartupPull(userId, force = true)
pendingResyncIncludesProfileSettings = false
if (
!syncCompleted ||
resyncKey != lastPulledKey ||
(resyncIncludesProfileSettings && !lastPulledIncludedProfileSettings)
) {
scheduleStartupPull(
userId = userId,
force = true,
includeProfileSettings = resyncIncludesProfileSettings
)
}
}
}
return true
}
private suspend fun pullRemoteData(): Result<Unit> {
private suspend fun pullRemoteData(includeProfileSettings: Boolean): Result<Unit> {
try {
val profileId = profileManager.activeProfileId.value
Log.d(TAG, "Pulling remote data for profile $profileId")
@ -140,37 +184,94 @@ class StartupSyncService @Inject constructor(
profileSyncService.pullFromRemote().getOrElse { throw it }
Log.d(TAG, "Pulled profiles from remote")
pluginManager.isSyncingFromRemote = true
try {
val remotePluginUrls = pluginSyncService.getRemoteRepoUrls().getOrElse { throw it }
pluginManager.reconcileWithRemoteRepoUrls(
remoteUrls = remotePluginUrls,
removeMissingLocal = true
)
Log.d(TAG, "Pulled ${remotePluginUrls.size} plugin repos from remote for profile $profileId")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull plugins from remote, keeping local cache", e)
} finally {
pluginManager.isSyncingFromRemote = false
if (includeProfileSettings) {
// Pull profile-scoped UI/player/settings blob.
// If not present, local settings are preserved.
profileSettingsSyncService.pullCurrentProfileFromRemote()
.onSuccess { applied ->
Log.d(TAG, "Profile settings blob pull completed for profile $profileId (applied=$applied)")
}
.onFailure { e ->
Log.e(TAG, "Failed to pull profile settings blob, keeping local settings", e)
}
}
addonRepository.isSyncingFromRemote = true
try {
val remoteAddonUrls = addonSyncService.getRemoteAddonUrls().getOrElse { throw it }
addonRepository.reconcileWithRemoteAddonUrls(
remoteUrls = remoteAddonUrls,
removeMissingLocal = true
)
Log.d(TAG, "Pulled ${remoteAddonUrls.size} addons from remote for profile $profileId")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull addons from remote, keeping local cache", e)
} finally {
addonRepository.isSyncingFromRemote = false
// Run independent syncs in parallel to reduce total startup time.
// Plugins, addons, collections, and home catalog settings don't depend on each other.
coroutineScope {
val pluginJob = async {
pluginManager.isSyncingFromRemote = true
try {
val remotePlugins = pluginSyncService.getRemoteRepoUrls().getOrElse { throw it }
pluginManager.reconcileWithRemoteRepoUrls(
remotePlugins = remotePlugins,
removeMissingLocal = true
)
Log.d(TAG, "Pulled ${remotePlugins.size} plugin repos from remote for profile $profileId")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull plugins from remote, keeping local cache", e)
} finally {
pluginManager.isSyncingFromRemote = false
pluginManager.flushPendingSync()
}
}
val addonJob = async {
addonRepository.isSyncingFromRemote = true
try {
val remoteAddonUrls = addonSyncService.getRemoteAddonUrls().getOrElse { throw it }
addonRepository.reconcileWithRemoteAddonUrls(
remoteUrls = remoteAddonUrls,
removeMissingLocal = true
)
Log.d(TAG, "Pulled ${remoteAddonUrls.size} addons from remote for profile $profileId")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull addons from remote, keeping local cache", e)
} finally {
addonRepository.isSyncingFromRemote = false
}
}
val collectionJob = async {
try {
collectionSyncService.pullFromRemote()
.onSuccess { applied ->
Log.d(TAG, "Collections pull completed for profile $profileId (applied=$applied)")
}
.onFailure { e ->
Log.e(TAG, "Failed to pull collections from remote, keeping local", e)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to pull collections from remote", e)
}
}
val homeCatalogJob = async {
try {
homeCatalogSettingsSyncService.pullFromRemote()
.onSuccess { applied ->
Log.d(TAG, "Home catalog settings pull completed for profile $profileId (applied=$applied)")
}
.onFailure { e ->
Log.e(TAG, "Failed to pull home catalog settings from remote, keeping local", e)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to pull home catalog settings from remote", e)
}
}
pluginJob.await()
addonJob.await()
collectionJob.await()
homeCatalogJob.await()
}
val isPrimaryProfile = profileManager.activeProfileId.value == 1
val isTraktConnected = isPrimaryProfile && traktAuthDataStore.isAuthenticated.first()
Log.d(TAG, "Watch progress sync: isTraktConnected=$isTraktConnected isPrimaryProfile=$isPrimaryProfile")
val isTraktConnected = traktAuthDataStore.isEffectivelyAuthenticated.first()
val shouldUseSupabaseWatchProgressSync = watchProgressSyncService.shouldUseSupabaseWatchProgressSync()
Log.d(
TAG,
"Watch progress sync: isTraktConnected=$isTraktConnected shouldUseSupabaseWatchProgressSync=$shouldUseSupabaseWatchProgressSync"
)
if (!isTraktConnected) {
// Pull library and watched items first — these are lightweight and critical.
// Watch progress is pulled last because the table is large and may time out;
@ -211,6 +312,29 @@ class StartupSyncService @Inject constructor(
} finally {
watchProgressRepository.isSyncingFromRemote = false
}
} else if (shouldUseSupabaseWatchProgressSync) {
try {
val remoteWatchedItems = watchedItemsSyncService.pullFromRemote().getOrElse { throw it }
Log.d(TAG, "Pulled ${remoteWatchedItems.size} watched items from remote")
watchedItemsPreferences.replaceWithRemoteItems(remoteWatchedItems)
watchProgressRepository.hasCompletedInitialWatchedItemsPull = true
Log.d(TAG, "Reconciled local watched items with ${remoteWatchedItems.size} remote items")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull watched items, continuing with Trakt library mode", e)
}
watchProgressRepository.isSyncingFromRemote = true
try {
val remoteEntries = watchProgressSyncService.pullFromRemote().getOrElse { throw it }
Log.d(TAG, "Pulled ${remoteEntries.size} watch progress entries from remote")
watchProgressPreferences.mergeRemoteEntries(remoteEntries.toMap())
watchProgressRepository.hasCompletedInitialPull = true
Log.d(TAG, "Merged local watch progress with ${remoteEntries.size} remote entries")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull watch progress while Trakt is connected, continuing", e)
} finally {
watchProgressRepository.isSyncingFromRemote = false
}
} else {
Log.d(TAG, "Skipping watch progress & library sync (Trakt connected)")
}

View file

@ -4,6 +4,8 @@ import android.util.Log
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.TraktAuthDataStore
import com.nuvio.tv.data.local.TraktSettingsDataStore
import com.nuvio.tv.data.local.WatchProgressSource
import com.nuvio.tv.data.local.WatchProgressPreferences
import com.nuvio.tv.data.remote.supabase.SupabaseWatchProgress
import com.nuvio.tv.domain.model.WatchProgress
@ -27,6 +29,7 @@ class WatchProgressSyncService @Inject constructor(
private val postgrest: Postgrest,
private val watchProgressPreferences: WatchProgressPreferences,
private val traktAuthDataStore: TraktAuthDataStore,
private val traktSettingsDataStore: TraktSettingsDataStore,
private val profileManager: ProfileManager
) {
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
@ -38,10 +41,15 @@ class WatchProgressSyncService @Inject constructor(
}
}
suspend fun shouldUseSupabaseWatchProgressSync(): Boolean {
val hasEffectiveTraktConnection = traktAuthDataStore.isEffectivelyAuthenticated.first()
val source = traktSettingsDataStore.watchProgressSource.first()
return !(hasEffectiveTraktConnection && source == WatchProgressSource.TRAKT)
}
suspend fun deleteFromRemote(keys: Collection<String>): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watch progress delete")
if (!shouldUseSupabaseWatchProgressSync()) {
return@withContext Result.success(Unit)
}
@ -77,13 +85,15 @@ class WatchProgressSyncService @Inject constructor(
*/
suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watch progress push")
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watch progress push")
return@withContext Result.success(Unit)
}
val rawEntries = watchProgressPreferences.getAllRawEntries()
val entries = canonicalizeForRemote(rawEntries)
val entries = canonicalizeForRemote(rawEntries).filterValues { progress ->
!(progress.position <= 1L && progress.duration <= 1L && progress.duration > 0L)
}
Log.d(TAG, "pushToRemote: ${rawEntries.size} local entries, ${entries.size} canonical entries to push")
entries.forEach { (key, progress) ->
Log.d(TAG, " push entry: key=$key contentId=${progress.contentId} type=${progress.contentType} pos=${progress.position} dur=${progress.duration} lastWatched=${progress.lastWatched}")
@ -123,8 +133,8 @@ class WatchProgressSyncService @Inject constructor(
suspend fun pushSingleToRemote(key: String, progress: WatchProgress): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping single watch progress push")
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping single watch progress push")
return@withContext Result.success(Unit)
}
@ -166,8 +176,8 @@ class WatchProgressSyncService @Inject constructor(
*/
suspend fun pullFromRemote(): Result<List<Pair<String, WatchProgress>>> = withContext(Dispatchers.IO) {
try {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watch progress pull")
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watch progress pull")
return@withContext Result.success(emptyList())
}
@ -276,7 +286,11 @@ class WatchProgressSyncService @Inject constructor(
}
}
.groupBy { it.contentId }
.mapValues { (_, episodes) -> episodes.maxByOrNull { it.lastWatched } }
.mapValues { (_, episodes) -> episodes.maxWithOrNull(
compareBy<WatchProgress> { it.lastWatched }
.thenBy { it.season ?: 0 }
.thenBy { it.episode ?: 0 }
) }
latestEpisodeByContent.forEach { (contentId, latestEpisode) ->
val latest = latestEpisode ?: return@forEach

View file

@ -4,6 +4,8 @@ import android.util.Log
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.TraktAuthDataStore
import com.nuvio.tv.data.local.TraktSettingsDataStore
import com.nuvio.tv.data.local.WatchProgressSource
import com.nuvio.tv.data.local.WatchedItemsPreferences
import com.nuvio.tv.data.remote.supabase.SupabaseWatchedItem
import com.nuvio.tv.domain.model.WatchedItem
@ -20,6 +22,7 @@ import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "WatchedItemsSyncService"
private const val WATCHED_ITEMS_PAGE_SIZE = 900
@Singleton
class WatchedItemsSyncService @Inject constructor(
@ -27,6 +30,7 @@ class WatchedItemsSyncService @Inject constructor(
private val postgrest: Postgrest,
private val watchedItemsPreferences: WatchedItemsPreferences,
private val traktAuthDataStore: TraktAuthDataStore,
private val traktSettingsDataStore: TraktSettingsDataStore,
private val profileManager: ProfileManager
) {
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
@ -38,10 +42,16 @@ class WatchedItemsSyncService @Inject constructor(
}
}
private suspend fun shouldUseSupabaseWatchProgressSync(): Boolean {
val hasEffectiveTraktConnection = traktAuthDataStore.isEffectivelyAuthenticated.first()
val source = traktSettingsDataStore.watchProgressSource.first()
return !(hasEffectiveTraktConnection && source == WatchProgressSource.TRAKT)
}
suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watched items push")
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watched items push")
return@withContext Result.success(Unit)
}
@ -80,35 +90,116 @@ class WatchedItemsSyncService @Inject constructor(
suspend fun pullFromRemote(): Result<List<WatchedItem>> = withContext(Dispatchers.IO) {
try {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watched items pull")
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watched items pull")
return@withContext Result.success(emptyList())
}
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_watched_items", params)
}
val remote = response.decodeList<SupabaseWatchedItem>()
val allItems = mutableListOf<WatchedItem>()
var page = 1
Log.d(TAG, "pullFromRemote: fetched ${remote.size} watched items from Supabase for profile $profileId")
while (true) {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_page", page)
put("p_page_size", WATCHED_ITEMS_PAGE_SIZE)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_watched_items", params)
}
val remote = response.decodeList<SupabaseWatchedItem>()
Result.success(remote.map { entry ->
WatchedItem(
contentId = entry.contentId,
contentType = entry.contentType,
title = entry.title,
season = entry.season,
episode = entry.episode,
watchedAt = entry.watchedAt
)
})
Log.d(TAG, "pullFromRemote: page $page fetched ${remote.size} watched items for profile $profileId")
allItems.addAll(remote.map { entry ->
WatchedItem(
contentId = entry.contentId,
contentType = entry.contentType,
title = entry.title,
season = entry.season,
episode = entry.episode,
watchedAt = entry.watchedAt
)
})
if (remote.size < WATCHED_ITEMS_PAGE_SIZE) break
page++
}
Log.d(TAG, "pullFromRemote: fetched ${allItems.size} total watched items from Supabase for profile $profileId")
Result.success(allItems)
} catch (e: Exception) {
Log.e(TAG, "Failed to pull watched items from remote", e)
Result.failure(e)
}
}
suspend fun deleteFromRemote(
contentId: String,
season: Int?,
episode: Int?
): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (!shouldUseSupabaseWatchProgressSync()) {
return@withContext Result.success(Unit)
}
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_keys", buildJsonArray {
addJsonObject {
put("content_id", contentId)
if (season != null) put("season", season)
if (episode != null) put("episode", episode)
}
})
}
withJwtRefreshRetry {
postgrest.rpc("sync_delete_watched_items", params)
}
Log.d(TAG, "Deleted watched item from remote: $contentId s=$season e=$episode for profile $profileId")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to delete watched item from remote", e)
Result.failure(e)
}
}
suspend fun deleteFromRemoteBatch(
contentId: String,
episodes: List<Pair<Int, Int>>
): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (!shouldUseSupabaseWatchProgressSync()) {
return@withContext Result.success(Unit)
}
if (episodes.isEmpty()) return@withContext Result.success(Unit)
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_keys", buildJsonArray {
episodes.forEach { (season, episode) ->
addJsonObject {
put("content_id", contentId)
put("season", season)
put("episode", episode)
}
}
})
}
withJwtRefreshRetry {
postgrest.rpc("sync_delete_watched_items", params)
}
Log.d(TAG, "Batch deleted ${episodes.size} watched items from remote for $contentId profile $profileId")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to batch delete watched items from remote", e)
Result.failure(e)
}
}
}

View file

@ -1,7 +1,9 @@
package com.nuvio.tv.core.tmdb
import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.remote.api.TmdbApi
import com.nuvio.tv.data.remote.api.TmdbDiscoverResult
import com.nuvio.tv.data.remote.api.TmdbEpisode
import com.nuvio.tv.data.remote.api.TmdbImage
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCast
@ -13,18 +15,21 @@ import com.nuvio.tv.domain.model.MetaCompany
import com.nuvio.tv.domain.model.MetaPreview
import com.nuvio.tv.domain.model.PersonDetail
import com.nuvio.tv.domain.model.PosterShape
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.util.concurrent.ConcurrentHashMap
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "TmdbMetadataService"
private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
private val TMDB_API_KEY = BuildConfig.TMDB_API_KEY
@Singleton
class TmdbMetadataService @Inject constructor(
@ -33,8 +38,13 @@ class TmdbMetadataService @Inject constructor(
// In-memory caches
private val enrichmentCache = ConcurrentHashMap<String, TmdbEnrichment>()
private val episodeCache = ConcurrentHashMap<String, Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
private val enrichmentInFlight = ConcurrentHashMap<String, CompletableDeferred<TmdbEnrichment?>>()
private val episodeInFlight = ConcurrentHashMap<String, CompletableDeferred<Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>>()
private val personCache = ConcurrentHashMap<String, PersonDetail>()
private val moreLikeThisCache = ConcurrentHashMap<String, List<MetaPreview>>()
private val entityHeaderCache = ConcurrentHashMap<String, TmdbEntityHeader>()
private val entityRailCache = ConcurrentHashMap<String, List<MetaPreview>>()
private val entityBrowseCache = ConcurrentHashMap<String, TmdbEntityBrowseData>()
suspend fun fetchEnrichment(
tmdbId: String,
@ -45,8 +55,13 @@ class TmdbMetadataService @Inject constructor(
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$tmdbId:${contentType.name}:$normalizedLanguage"
enrichmentCache[cacheKey]?.let { return@withContext it }
enrichmentInFlight[cacheKey]?.let { return@withContext it.await() }
val numericId = tmdbId.toIntOrNull() ?: return@withContext null
val requestDeferred = CompletableDeferred<TmdbEnrichment?>()
enrichmentInFlight.putIfAbsent(cacheKey, requestDeferred)?.let { existing ->
return@withContext existing.await()
}
val tmdbType = when (contentType) {
ContentType.SERIES, ContentType.TV -> "tv"
else -> "movie"
@ -106,10 +121,11 @@ class TmdbMetadataService @Inject constructor(
val description = details?.overview?.takeIf { it.isNotBlank() }
val releaseInfo = details?.releaseDate
?: details?.firstAirDate
val status = details?.status?.trim()?.takeIf { it.isNotBlank() }
val rating = details?.voteAverage
val runtime = details?.runtime ?: details?.episodeRunTime?.firstOrNull()
val countries = details?.productionCountries
?.mapNotNull { it.name?.trim()?.takeIf { name -> name.isNotBlank() } }
?.mapNotNull { it.iso31661?.trim()?.uppercase()?.takeIf { code -> code.isNotBlank() } }
?.takeIf { it.isNotEmpty() }
?: details?.originCountry?.takeIf { it.isNotEmpty() }
val language = details?.originalLanguage?.takeIf { it.isNotBlank() }
@ -120,7 +136,8 @@ class TmdbMetadataService @Inject constructor(
val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany(
name = name,
logo = buildImageUrl(company.logoPath, size = "w300")
logo = buildImageUrl(company.logoPath, size = "w300"),
tmdbId = company.id
)
}
val networks = details?.networks
@ -129,22 +146,19 @@ class TmdbMetadataService @Inject constructor(
val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany(
name = name,
logo = buildImageUrl(network.logoPath, size = "w300")
logo = buildImageUrl(network.logoPath, size = "w300"),
tmdbId = network.id
)
}
val poster = buildImageUrl(details?.posterPath, size = "w500")
val backdrop = buildImageUrl(details?.backdropPath, size = "w1280")
val collectionId = details?.belongsToCollection?.id
val collectionName = details?.belongsToCollection?.name
val logoPath = images?.logos
?.sortedWith(
compareByDescending<com.nuvio.tv.data.remote.api.TmdbImage> {
it.iso6391 == normalizedLanguage.substringBefore("-")
}
.thenByDescending { it.iso6391 == "en" }
.thenByDescending { it.iso6391 == null }
)
?.firstOrNull()
?.filePath
val logoPath = images?.logos?.let {
selectBestLocalizedImagePath(it, normalizedLanguage)
}
val logo = buildImageUrl(logoPath, size = "w500")
@ -259,7 +273,7 @@ class TmdbMetadataService @Inject constructor(
genres.isEmpty() && description == null && backdrop == null && logo == null &&
poster == null && castMembers.isEmpty() && director.isEmpty() && writer.isEmpty() &&
releaseInfo == null && rating == null && runtime == null && countries.isNullOrEmpty() && language == null &&
productionCompanies.isEmpty() && networks.isEmpty() && ageRating == null
productionCompanies.isEmpty() && networks.isEmpty() && ageRating == null && status == null
) {
return@withContext null
}
@ -282,14 +296,24 @@ class TmdbMetadataService @Inject constructor(
productionCompanies = productionCompanies,
networks = networks,
ageRating = ageRating,
status = status,
countries = countries,
language = language
language = language,
collectionId = collectionId,
collectionName = collectionName
)
enrichmentCache[cacheKey] = enrichment
requestDeferred.complete(enrichment)
enrichment
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch TMDB enrichment: ${e.message}", e)
requestDeferred.complete(null)
null
} finally {
enrichmentInFlight.remove(cacheKey, requestDeferred)
}
}
@ -301,27 +325,41 @@ class TmdbMetadataService @Inject constructor(
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$tmdbId:${seasonNumbers.sorted().joinToString(",")}:$normalizedLanguage"
episodeCache[cacheKey]?.let { return@withContext it }
episodeInFlight[cacheKey]?.let { return@withContext it.await() }
val numericId = tmdbId.toIntOrNull() ?: return@withContext emptyMap()
val requestDeferred = CompletableDeferred<Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
episodeInFlight.putIfAbsent(cacheKey, requestDeferred)?.let { existing ->
return@withContext existing.await()
}
val result = mutableMapOf<Pair<Int, Int>, TmdbEpisodeEnrichment>()
seasonNumbers.distinct().forEach { season ->
try {
val response = tmdbApi.getTvSeasonDetails(numericId, season, TMDB_API_KEY, normalizedLanguage)
val episodes = response.body()?.episodes.orEmpty()
episodes.forEach { ep ->
val epNum = ep.episodeNumber ?: return@forEach
result[season to epNum] = ep.toEnrichment()
try {
seasonNumbers.distinct().forEach { season ->
try {
val response = tmdbApi.getTvSeasonDetails(numericId, season, TMDB_API_KEY, normalizedLanguage)
val episodes = response.body()?.episodes.orEmpty()
episodes.forEach { ep ->
val epNum = ep.episodeNumber ?: return@forEach
result[season to epNum] = ep.toEnrichment()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}")
}
}
if (result.isNotEmpty()) {
episodeCache[cacheKey] = result
val finalResult = result.toMap()
if (finalResult.isNotEmpty()) {
episodeCache[cacheKey] = finalResult
}
requestDeferred.complete(finalResult)
finalResult
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} finally {
episodeInFlight.remove(cacheKey, requestDeferred)
}
result
}
suspend fun fetchMoreLikeThis(
@ -406,7 +444,20 @@ class TmdbMetadataService @Inject constructor(
val backdrop = buildImageUrl(localizedBackdropPath ?: rec.backdropPath, size = "w1280")
val fallbackPoster = buildImageUrl(rec.posterPath, size = "w780")
val releaseInfo = (rec.releaseDate ?: rec.firstAirDate)?.take(4)
val releaseInfo = if (recTmdbType == "tv") {
val startYear = rec.firstAirDate?.take(4)
if (startYear != null) {
val tvDetails = runCatching {
tmdbApi.getTvDetails(rec.id, TMDB_API_KEY, normalizedLanguage).body()
}.getOrNull()
val status = tvDetails?.status
val endYear = tvDetails?.lastAirDate?.take(4)
buildShowYearRange(startYear, endYear, status)
} else null
} else {
rec.releaseDate?.take(4)
}
MetaPreview(
id = "tmdb:${rec.id}",
@ -433,17 +484,385 @@ class TmdbMetadataService @Inject constructor(
}
}
private val collectionCache = ConcurrentHashMap<String, List<MetaPreview>>()
suspend fun fetchMovieCollection(
collectionId: Int,
language: String = "en"
): List<MetaPreview> = withContext(Dispatchers.IO) {
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$collectionId:$normalizedLanguage:collection"
collectionCache[cacheKey]?.let { return@withContext it }
try {
val collectionResponse = tmdbApi.getCollectionDetails(collectionId, TMDB_API_KEY, normalizedLanguage).body()
val rawParts = collectionResponse?.parts.orEmpty()
// Show in release order
val sortedParts = rawParts.sortedBy { it.releaseDate ?: "9999" }
val includeImageLanguage = buildString {
append(normalizedLanguage.substringBefore("-"))
append(",")
append(normalizedLanguage)
append(",en,null")
}
val items = coroutineScope {
sortedParts.map { part ->
async {
val title = part.title ?: return@async null
val localizedBackdropPath = runCatching {
tmdbApi.getMovieImages(part.id, TMDB_API_KEY, includeImageLanguage).body()
}.getOrNull()?.let { images ->
selectBestLocalizedImagePath(
images = images.backdrops.orEmpty(),
normalizedLanguage = normalizedLanguage
)
}
val backdrop = buildImageUrl(localizedBackdropPath ?: part.backdropPath, size = "w1280")
val fallbackPoster = buildImageUrl(part.posterPath, size = "w780")
val releaseInfo = part.releaseDate?.take(4)
MetaPreview(
id = "tmdb:${part.id}",
type = ContentType.MOVIE,
name = title,
poster = backdrop ?: fallbackPoster,
posterShape = PosterShape.LANDSCAPE,
background = backdrop,
logo = null,
description = part.overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseInfo,
imdbRating = part.voteAverage?.toFloat(),
genres = emptyList()
)
}
}.awaitAll().filterNotNull()
}
collectionCache[cacheKey] = items
items
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch collection for $collectionId: ${e.message}")
emptyList()
}
}
suspend fun fetchEntityBrowse(
entityKind: TmdbEntityKind,
entityId: Int,
sourceType: String,
fallbackName: String? = null,
language: String = "en"
): TmdbEntityBrowseData? = withContext(Dispatchers.IO) {
val normalizedLanguage = normalizeTmdbLanguage(language)
val normalizedSourceType = normalizeEntitySourceType(sourceType)
val cacheKey = "${entityKind.routeValue}:$entityId:$normalizedSourceType:$normalizedLanguage"
entityBrowseCache[cacheKey]?.let { return@withContext it }
val header = fetchEntityHeader(
entityKind = entityKind,
entityId = entityId,
fallbackName = fallbackName,
language = normalizedLanguage
)
val rails = buildEntityMediaOrder(entityKind, normalizedSourceType)
.flatMap { mediaType ->
TmdbEntityRailType.values().mapNotNull { railType ->
val pageResult = fetchEntityRailPage(
entityKind = entityKind,
entityId = entityId,
mediaType = mediaType,
railType = railType,
language = normalizedLanguage,
page = 1
)
val items = pageResult.items
if (items.isEmpty()) {
null
} else {
TmdbEntityRail(
mediaType = mediaType,
railType = railType,
items = items,
currentPage = 1,
hasMore = pageResult.hasMore,
isLoading = false
)
}
}
}
if (header == null && rails.isEmpty()) return@withContext null
val data = TmdbEntityBrowseData(
header = header ?: TmdbEntityHeader(
id = entityId,
kind = entityKind,
name = fallbackName?.takeIf { it.isNotBlank() } ?: "Unknown",
logo = null,
originCountry = null,
secondaryLabel = null,
description = null
),
rails = rails
)
entityBrowseCache[cacheKey] = data
data
}
private suspend fun fetchEntityHeader(
entityKind: TmdbEntityKind,
entityId: Int,
fallbackName: String?,
language: String
): TmdbEntityHeader? {
val cacheKey = "${entityKind.routeValue}:$entityId:$language:header"
entityHeaderCache[cacheKey]?.let { return it }
val header = try {
when (entityKind) {
TmdbEntityKind.COMPANY -> {
val body = tmdbApi.getCompanyDetails(entityId, TMDB_API_KEY).body()
if (body == null) {
null
} else {
TmdbEntityHeader(
id = body.id,
kind = entityKind,
name = body.name?.takeIf { it.isNotBlank() }
?: fallbackName?.takeIf { it.isNotBlank() }
?: "Unknown",
logo = buildImageUrl(body.logoPath, size = "w500"),
originCountry = body.originCountry?.takeIf { it.isNotBlank() },
secondaryLabel = body.headquarters?.takeIf { it.isNotBlank() },
description = body.description?.takeIf { it.isNotBlank() }
)
}
}
TmdbEntityKind.NETWORK -> {
val body = tmdbApi.getNetworkDetails(entityId, TMDB_API_KEY).body()
if (body == null) {
null
} else {
TmdbEntityHeader(
id = body.id,
kind = entityKind,
name = body.name?.takeIf { it.isNotBlank() }
?: fallbackName?.takeIf { it.isNotBlank() }
?: "Unknown",
logo = buildImageUrl(body.logoPath, size = "w500"),
originCountry = body.originCountry?.takeIf { it.isNotBlank() },
secondaryLabel = body.headquarters?.takeIf { it.isNotBlank() },
description = null
)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch ${entityKind.routeValue} header for $entityId: ${e.message}")
null
} ?: fallbackName?.takeIf { it.isNotBlank() }?.let {
TmdbEntityHeader(
id = entityId,
kind = entityKind,
name = it,
logo = null,
originCountry = null,
secondaryLabel = null,
description = null
)
}
if (header != null) {
entityHeaderCache[cacheKey] = header
}
return header
}
suspend fun fetchEntityRailPage(
entityKind: TmdbEntityKind,
entityId: Int,
mediaType: TmdbEntityMediaType,
railType: TmdbEntityRailType,
language: String,
page: Int
): TmdbEntityRailPageResult {
if (entityKind == TmdbEntityKind.NETWORK && mediaType == TmdbEntityMediaType.MOVIE) {
return TmdbEntityRailPageResult(items = emptyList(), hasMore = false)
}
val cacheKey = "${entityKind.routeValue}:$entityId:${mediaType.value}:${railType.value}:$language:page:$page"
entityRailCache[cacheKey]?.let { cached ->
return TmdbEntityRailPageResult(
items = cached,
hasMore = cached.isNotEmpty()
)
}
val today = LocalDate.now().toString()
val voteCountFloor = if (railType == TmdbEntityRailType.TOP_RATED) TOP_RATED_VOTE_COUNT_FLOOR else null
val result = try {
val response = when (mediaType) {
TmdbEntityMediaType.MOVIE -> {
tmdbApi.discoverMovies(
apiKey = TMDB_API_KEY,
language = language,
page = page,
sortBy = movieSortBy(railType),
withCompanies = entityId.toString(),
releaseDateLte = if (railType == TmdbEntityRailType.RECENT) today else null,
voteCountGte = voteCountFloor
).body()
}
TmdbEntityMediaType.TV -> {
tmdbApi.discoverTv(
apiKey = TMDB_API_KEY,
language = language,
page = page,
sortBy = tvSortBy(railType),
withCompanies = if (entityKind == TmdbEntityKind.COMPANY) entityId.toString() else null,
withNetworks = if (entityKind == TmdbEntityKind.NETWORK) entityId.toString() else null,
firstAirDateLte = if (railType == TmdbEntityRailType.RECENT) today else null,
voteCountGte = voteCountFloor
).body()
}
}
val results = response?.results.orEmpty()
val totalPages = response?.totalPages ?: page
val mappedItems = results
.filter { it.id > 0 }
.mapNotNull { discoverItem ->
mapEntityDiscoverResult(
result = discoverItem,
mediaType = mediaType
)
}
.take(ENTITY_RAIL_MAX_ITEMS)
TmdbEntityRailPageResult(
items = mappedItems,
hasMore = page < totalPages && mappedItems.isNotEmpty()
)
} catch (e: Exception) {
Log.w(
TAG,
"Failed to fetch ${entityKind.routeValue} rail ${railType.value}/${mediaType.value} for $entityId: ${e.message}"
)
TmdbEntityRailPageResult(items = emptyList(), hasMore = false)
}
if (result.items.isNotEmpty()) {
entityRailCache[cacheKey] = result.items
}
return result
}
private fun mapEntityDiscoverResult(
result: TmdbDiscoverResult,
mediaType: TmdbEntityMediaType
): MetaPreview? {
val title = result.title?.takeIf { it.isNotBlank() }
?: result.name?.takeIf { it.isNotBlank() }
?: result.originalTitle?.takeIf { it.isNotBlank() }
?: result.originalName?.takeIf { it.isNotBlank() }
?: return null
val poster = buildImageUrl(result.posterPath, size = "w500")
?: buildImageUrl(result.backdropPath, size = "w780")
?: return null
val background = buildImageUrl(result.backdropPath, size = "w1280")
val releaseInfo = when (mediaType) {
TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4)
TmdbEntityMediaType.TV -> result.firstAirDate?.take(4)
}
return MetaPreview(
id = "tmdb:${result.id}",
type = if (mediaType == TmdbEntityMediaType.TV) ContentType.SERIES else ContentType.MOVIE,
name = title,
poster = poster,
posterShape = PosterShape.POSTER,
background = background,
logo = null,
description = result.overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseInfo,
imdbRating = result.voteAverage?.toFloat(),
genres = emptyList()
)
}
internal fun buildEntityMediaOrder(
entityKind: TmdbEntityKind,
sourceType: String
): List<TmdbEntityMediaType> {
if (entityKind == TmdbEntityKind.NETWORK) {
return listOf(TmdbEntityMediaType.TV)
}
return when (normalizeEntitySourceType(sourceType)) {
"movie" -> listOf(TmdbEntityMediaType.MOVIE, TmdbEntityMediaType.TV)
else -> listOf(TmdbEntityMediaType.TV, TmdbEntityMediaType.MOVIE)
}
}
private fun normalizeEntitySourceType(sourceType: String): String {
return when (sourceType.trim().lowercase(Locale.US)) {
"movie" -> "movie"
"tv", "series", "show" -> "tv"
else -> "tv"
}
}
private fun movieSortBy(railType: TmdbEntityRailType): String = when (railType) {
TmdbEntityRailType.POPULAR -> "popularity.desc"
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
TmdbEntityRailType.RECENT -> "primary_release_date.desc"
}
private fun tvSortBy(railType: TmdbEntityRailType): String = when (railType) {
TmdbEntityRailType.POPULAR -> "popularity.desc"
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
TmdbEntityRailType.RECENT -> "first_air_date.desc"
}
private fun buildShowYearRange(startYear: String, endYear: String?, status: String?): String {
val isEnded = status != null && status != "Returning Series" && status != "In Production"
return when {
isEnded && endYear != null && endYear != startYear -> "$startYear - $endYear"
isEnded -> startYear
else -> "$startYear - "
}
}
private fun buildImageUrl(path: String?, size: String): String? {
val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null
return "https://image.tmdb.org/t/p/$size$clean"
}
private fun normalizeTmdbLanguage(language: String?): String {
return language
val raw = language
?.trim()
?.takeIf { it.isNotBlank() }
?.replace('_', '-')
?: "en"
?: return "en"
// Normalize region code to uppercase (e.g. pt-br -> pt-BR)
val normalized = raw.split("-").let { parts ->
if (parts.size == 2) "${parts[0].lowercase(Locale.US)}-${parts[1].uppercase(Locale.US)}"
else raw.lowercase(Locale.US)
}
// Map codes unsupported by TMDB to their closest equivalent
return when (normalized) {
"es-419" -> "es-MX"
else -> normalized
}
}
private fun selectBestLocalizedImagePath(
@ -452,9 +871,12 @@ class TmdbMetadataService @Inject constructor(
): String? {
if (images.isEmpty()) return null
val languageCode = normalizedLanguage.substringBefore("-")
val regionCode = normalizedLanguage.substringAfter("-", "").uppercase(Locale.US).takeIf { it.length == 2 }
?: DEFAULT_LANGUAGE_REGIONS[languageCode]
return images
.sortedWith(
compareByDescending<TmdbImage> { it.iso6391 == normalizedLanguage }
compareByDescending<TmdbImage> { it.iso6391 == languageCode && it.iso31661 == regionCode }
.thenByDescending { it.iso6391 == languageCode && it.iso31661 == null }
.thenByDescending { it.iso6391 == languageCode }
.thenByDescending { it.iso6391 == "en" }
.thenByDescending { it.iso6391 == null }
@ -463,27 +885,47 @@ class TmdbMetadataService @Inject constructor(
?.filePath
}
companion object {
private val DEFAULT_LANGUAGE_REGIONS = mapOf(
"pt" to "PT",
"es" to "ES"
)
private const val ENTITY_RAIL_MAX_ITEMS = 20
private const val TOP_RATED_VOTE_COUNT_FLOOR = 200
}
suspend fun fetchPersonDetail(
personId: Int,
preferCrewCredits: Boolean? = null
preferCrewCredits: Boolean? = null,
language: String = "en"
): PersonDetail? =
withContext(Dispatchers.IO) {
val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}"
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}:$normalizedLanguage"
personCache[cacheKey]?.let { return@withContext it }
try {
val (person, credits) = coroutineScope {
val personDeferred = async {
tmdbApi.getPersonDetails(personId, TMDB_API_KEY).body()
tmdbApi.getPersonDetails(personId, TMDB_API_KEY, normalizedLanguage).body()
}
val creditsDeferred = async {
tmdbApi.getPersonCombinedCredits(personId, TMDB_API_KEY).body()
tmdbApi.getPersonCombinedCredits(personId, TMDB_API_KEY, normalizedLanguage).body()
}
Pair(personDeferred.await(), creditsDeferred.await())
}
if (person == null) return@withContext null
// If biography is empty and language is not English, fetch English fallback
val biography = if (person.biography.isNullOrBlank() && normalizedLanguage != "en") {
runCatching {
tmdbApi.getPersonDetails(personId, TMDB_API_KEY, "en").body()?.biography
}.getOrNull()
} else {
person.biography
}?.takeIf { it.isNotBlank() }
val preferCrewFilmography = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment)
val castMovieCredits = mapMovieCreditsFromCast(credits?.cast.orEmpty())
@ -505,7 +947,7 @@ class TmdbMetadataService @Inject constructor(
val detail = PersonDetail(
tmdbId = person.id,
name = person.name ?: "Unknown",
biography = person.biography?.takeIf { it.isNotBlank() },
biography = biography,
birthday = person.birthday?.takeIf { it.isNotBlank() },
deathday = person.deathday?.takeIf { it.isNotBlank() },
placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() },
@ -699,8 +1141,11 @@ data class TmdbEnrichment(
val productionCompanies: List<MetaCompany>,
val networks: List<MetaCompany>,
val ageRating: String?,
val status: String?,
val countries: List<String>?,
val language: String?
val language: String?,
val collectionId: Int?,
val collectionName: String?
)
data class TmdbEpisodeEnrichment(
@ -711,6 +1156,58 @@ data class TmdbEpisodeEnrichment(
val runtimeMinutes: Int?
)
enum class TmdbEntityKind(val routeValue: String) {
COMPANY("company"),
NETWORK("network");
companion object {
fun fromRouteValue(value: String): TmdbEntityKind = when (value.trim().lowercase(Locale.US)) {
"network" -> NETWORK
else -> COMPANY
}
}
}
enum class TmdbEntityMediaType(val value: String) {
MOVIE("movie"),
TV("tv")
}
enum class TmdbEntityRailType(val value: String) {
POPULAR("popular"),
TOP_RATED("top_rated"),
RECENT("recent")
}
data class TmdbEntityHeader(
val id: Int,
val kind: TmdbEntityKind,
val name: String,
val logo: String?,
val originCountry: String?,
val secondaryLabel: String?,
val description: String?
)
data class TmdbEntityRail(
val mediaType: TmdbEntityMediaType,
val railType: TmdbEntityRailType,
val items: List<MetaPreview>,
val currentPage: Int = 1,
val hasMore: Boolean = false,
val isLoading: Boolean = false
)
data class TmdbEntityBrowseData(
val header: TmdbEntityHeader,
val rails: List<TmdbEntityRail>
)
data class TmdbEntityRailPageResult(
val items: List<MetaPreview>,
val hasMore: Boolean
)
private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
val title = name?.takeIf { it.isNotBlank() }
val overview = overview?.takeIf { it.isNotBlank() }

View file

@ -1,7 +1,10 @@
package com.nuvio.tv.core.tmdb
import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.remote.api.TmdbApi
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -11,7 +14,7 @@ import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "TmdbService"
private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
private val TMDB_API_KEY = BuildConfig.TMDB_API_KEY
/**
* Service to handle TMDB ID conversions and lookups.
@ -26,6 +29,9 @@ class TmdbService @Inject constructor(
// Cache: TMDB ID -> IMDB ID
private val tmdbToImdbCache = ConcurrentHashMap<Int, String>()
private val imdbToTmdbInFlight = ConcurrentHashMap<String, CompletableDeferred<Int?>>()
private val tmdbToImdbInFlight = ConcurrentHashMap<String, CompletableDeferred<String?>>()
// Mutex for thread-safe cache operations
private val cacheMutex = Mutex()
@ -50,6 +56,13 @@ class TmdbService @Inject constructor(
return@withContext cached
}
val normalizedType = normalizeMediaType(mediaType)
val requestKey = "$imdbId:$normalizedType"
val requestDeferred = CompletableDeferred<Int?>()
imdbToTmdbInFlight.putIfAbsent(requestKey, requestDeferred)?.let { existing ->
return@withContext existing.await()
}
try {
Log.d(TAG, "Looking up TMDB ID for IMDB: $imdbId (type: $mediaType)")
@ -61,13 +74,17 @@ class TmdbService @Inject constructor(
if (!response.isSuccessful) {
Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}")
requestDeferred.complete(null)
return@withContext null
}
val body = response.body() ?: return@withContext null
val body = response.body()
if (body == null) {
requestDeferred.complete(null)
return@withContext null
}
// Determine which results to use based on media type
val normalizedType = normalizeMediaType(mediaType)
val result = when (normalizedType) {
"movie" -> body.movieResults?.firstOrNull()
"tv", "series" -> body.tvResults?.firstOrNull()
@ -82,16 +99,25 @@ class TmdbService @Inject constructor(
imdbToTmdbCache[imdbId] = found.id
tmdbToImdbCache[found.id] = imdbId
}
requestDeferred.complete(found.id)
return@withContext found.id
}
Log.w(TAG, "No TMDB result found for IMDB: $imdbId")
requestDeferred.complete(null)
null
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} catch (e: Exception) {
Log.e(TAG, "Error looking up TMDB ID for $imdbId: ${e.message}", e)
requestDeferred.complete(null)
null
} finally {
imdbToTmdbInFlight.remove(requestKey, requestDeferred)
}
}
@ -109,10 +135,16 @@ class TmdbService @Inject constructor(
return@withContext cached
}
val normalizedType = normalizeMediaType(mediaType)
val requestKey = "$tmdbId:$normalizedType"
val requestDeferred = CompletableDeferred<String?>()
tmdbToImdbInFlight.putIfAbsent(requestKey, requestDeferred)?.let { existing ->
return@withContext existing.await()
}
try {
Log.d(TAG, "Looking up IMDB ID for TMDB: $tmdbId (type: $mediaType)")
val normalizedType = normalizeMediaType(mediaType)
val response = when (normalizedType) {
"movie" -> tmdbApi.getMovieExternalIds(tmdbId, TMDB_API_KEY)
"tv", "series" -> tmdbApi.getTvExternalIds(tmdbId, TMDB_API_KEY)
@ -121,10 +153,15 @@ class TmdbService @Inject constructor(
if (!response.isSuccessful) {
Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}")
requestDeferred.complete(null)
return@withContext null
}
val body = response.body() ?: return@withContext null
val body = response.body()
if (body == null) {
requestDeferred.complete(null)
return@withContext null
}
body.imdbId?.let { imdbId ->
Log.d(TAG, "Found IMDB ID: $imdbId for TMDB: $tmdbId")
@ -134,16 +171,25 @@ class TmdbService @Inject constructor(
tmdbToImdbCache[tmdbId] = imdbId
imdbToTmdbCache[imdbId] = tmdbId
}
requestDeferred.complete(imdbId)
return@withContext imdbId
}
Log.w(TAG, "No IMDB ID found for TMDB: $tmdbId")
requestDeferred.complete(null)
null
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} catch (e: Exception) {
Log.e(TAG, "Error looking up IMDB ID for $tmdbId: ${e.message}", e)
requestDeferred.complete(null)
null
} finally {
tmdbToImdbInFlight.remove(requestKey, requestDeferred)
}
}
@ -202,6 +248,8 @@ class TmdbService @Inject constructor(
fun clearCache() {
imdbToTmdbCache.clear()
tmdbToImdbCache.clear()
imdbToTmdbInFlight.clear()
tmdbToImdbInFlight.clear()
Log.d(TAG, "Cache cleared")
}
@ -212,4 +260,9 @@ class TmdbService @Inject constructor(
imdbToTmdbCache[imdbId] = tmdbId
tmdbToImdbCache[tmdbId] = imdbId
}
/** Returns the cached TMDB ID for an IMDB ID without making any network call. */
fun cachedTmdbId(imdbId: String): Int? = imdbToTmdbCache[imdbId]
fun apiKey(): String = TMDB_API_KEY
}

View file

@ -0,0 +1,147 @@
package com.nuvio.tv.core.torrent
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
data class TorrServerFile(
val id: Int,
val path: String,
val length: Long
)
data class TorrServerStats(
val downloadSpeed: Long,
val uploadSpeed: Long,
val peers: Int,
val seeds: Int,
val preloadedBytes: Long,
val loadedSize: Long,
val torrentSize: Long,
val files: List<TorrServerFile>
)
@Singleton
class TorrServerApi @Inject constructor(
private val binary: TorrServerBinary
) {
companion object {
private const val TAG = "TorrServerApi"
private val JSON_TYPE = "application/json".toMediaType()
}
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val baseUrl: String get() = binary.baseUrl
suspend fun addTorrent(magnetLink: String, title: String? = null): String? = withContext(Dispatchers.IO) {
val body = JSONObject().apply {
put("action", "add")
put("link", magnetLink)
put("save_to_db", false)
if (title != null) put("title", title)
}
val request = Request.Builder()
.url("$baseUrl/torrents")
.post(body.toString().toRequestBody(JSON_TYPE))
.build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.e(TAG, "addTorrent failed: ${response.code}")
return@withContext null
}
val json = JSONObject(response.body?.string() ?: "{}")
val hash = json.optString("hash", "")
Log.d(TAG, "Torrent added: $hash")
hash.ifEmpty { null }
}
} catch (e: Exception) {
Log.e(TAG, "addTorrent error", e)
null
}
}
suspend fun getTorrentStats(hash: String): TorrServerStats? = withContext(Dispatchers.IO) {
val body = JSONObject().apply {
put("action", "get")
put("hash", hash)
}
val request = Request.Builder()
.url("$baseUrl/torrents")
.post(body.toString().toRequestBody(JSON_TYPE))
.build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext null
val json = JSONObject(response.body?.string() ?: "{}")
val files = mutableListOf<TorrServerFile>()
val fileList = json.optJSONArray("file_stats") ?: JSONArray()
for (i in 0 until fileList.length()) {
val f = fileList.getJSONObject(i)
files.add(TorrServerFile(
id = f.optInt("id", i + 1),
path = f.optString("path", ""),
length = f.optLong("length", 0)
))
}
TorrServerStats(
downloadSpeed = json.optLong("download_speed", 0),
uploadSpeed = json.optLong("upload_speed", 0),
peers = json.optInt("active_peers", 0),
seeds = json.optInt("connected_seeders", 0),
preloadedBytes = json.optLong("preloaded_bytes", 0),
loadedSize = json.optLong("loaded_size", 0),
torrentSize = json.optLong("torrent_size", 0),
files = files
)
}
} catch (e: Exception) {
Log.w(TAG, "getTorrentStats error", e)
null
}
}
suspend fun dropTorrent(hash: String) = withContext(Dispatchers.IO) {
val body = JSONObject().apply {
put("action", "drop")
put("hash", hash)
}
val request = Request.Builder()
.url("$baseUrl/torrents")
.post(body.toString().toRequestBody(JSON_TYPE))
.build()
try {
client.newCall(request).execute().close()
Log.d(TAG, "Torrent dropped: $hash")
} catch (e: Exception) {
Log.w(TAG, "dropTorrent error", e)
}
}
fun getStreamUrl(magnetLink: String, fileIdx: Int): String {
val encodedLink = URLEncoder.encode(magnetLink, "UTF-8")
return "$baseUrl/stream?link=$encodedLink&index=$fileIdx&play"
}
}

View file

@ -0,0 +1,165 @@
package com.nuvio.tv.core.torrent
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages the TorrServer binary lifecycle.
* The binary is bundled in jniLibs/ as libtorrserver.so and installed
* to nativeLibraryDir by the Android package manager.
*/
@Singleton
class TorrServerBinary @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private const val TAG = "TorrServerBinary"
const val PORT = 8091
private const val STARTUP_TIMEOUT_MS = 15_000L
private const val HEALTH_CHECK_INTERVAL_MS = 200L
}
private var process: Process? = null
private val healthClient = OkHttpClient.Builder()
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
val baseUrl: String get() = "http://127.0.0.1:$PORT"
private val binaryFile: File
get() = File(context.applicationInfo.nativeLibraryDir, "libtorrserver.so")
private val configDir: File
get() = File(context.filesDir, "torrserver").also { it.mkdirs() }
val isBinaryAvailable: Boolean
get() = binaryFile.exists()
fun isRunning(): Boolean {
return try {
val request = Request.Builder().url("$baseUrl/echo").build()
healthClient.newCall(request).execute().use { it.isSuccessful }
} catch (e: Exception) {
false
}
}
suspend fun start() = withContext(Dispatchers.IO) {
if (isRunning()) {
Log.d(TAG, "TorrServer already running")
return@withContext
}
// Kill any orphaned TorrServer process that may be holding the port
// (e.g., from a previous app session that was force-killed).
killOrphanedProcess()
if (!isBinaryAvailable) {
throw TorrentException("TorrServer binary not found at ${binaryFile.absolutePath}")
}
if (!binaryFile.canExecute()) {
binaryFile.setExecutable(true)
}
val pb = ProcessBuilder(
binaryFile.absolutePath,
"--port", PORT.toString(),
"--path", configDir.absolutePath
)
pb.directory(configDir)
pb.redirectErrorStream(true)
Log.d(TAG, "Starting TorrServer on port $PORT from ${binaryFile.absolutePath}")
process = pb.start()
// Read stdout in daemon thread for debugging
val proc = process!!
Thread {
try {
proc.inputStream.bufferedReader().forEachLine { line ->
Log.d(TAG, "[server] $line")
}
} catch (_: Exception) {}
}.apply {
isDaemon = true
start()
}
// Wait for health check — also detect early process death so we
// don't spin for 15s if the binary crashed on launch.
val deadline = System.currentTimeMillis() + STARTUP_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
if (isRunning()) {
Log.d(TAG, "TorrServer started successfully")
return@withContext
}
if (!isProcessAlive(process)) {
val exitCode = process?.exitValue() ?: -1
process = null
throw TorrentException("TorrServer process died on startup (exit code $exitCode)")
}
delay(HEALTH_CHECK_INTERVAL_MS)
}
stop()
throw TorrentException("TorrServer failed to start within ${STARTUP_TIMEOUT_MS / 1000}s")
}
private fun killOrphanedProcess() {
try {
// Try graceful shutdown in case an old instance is still responding
val request = Request.Builder().url("$baseUrl/shutdown").build()
healthClient.newCall(request).execute().close()
Thread.sleep(1000)
Log.d(TAG, "Shut down orphaned TorrServer instance")
} catch (_: Exception) {
// No orphan responding — nothing to do
}
}
fun stop() {
// Try graceful shutdown
try {
val request = Request.Builder().url("$baseUrl/shutdown").build()
healthClient.newCall(request).execute().close()
} catch (_: Exception) {}
// Force kill after 3 seconds
process?.let { proc ->
try {
Thread.sleep(3000)
if (isProcessAlive(proc)) {
proc.destroyForcibly()
}
} catch (_: Exception) {
proc.destroyForcibly()
}
}
process = null
Log.d(TAG, "TorrServer stopped")
}
private fun isProcessAlive(proc: Process?): Boolean {
if (proc == null) return false
return try {
proc.exitValue()
false
} catch (_: IllegalThreadStateException) {
true
} catch (_: Exception) {
false
}
}
}

View file

@ -0,0 +1,3 @@
package com.nuvio.tv.core.torrent
class TorrentException(message: String) : Exception(message)

View file

@ -0,0 +1,213 @@
package com.nuvio.tv.core.torrent
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
private val VIDEO_EXTENSIONS = setOf("mkv", "mp4", "avi", "webm", "ts", "m4v", "mov", "wmv", "flv")
@Singleton
class TorrentService @Inject constructor(
private val binary: TorrServerBinary,
private val api: TorrServerApi
) {
companion object {
private const val TAG = "TorrentService"
private val DEFAULT_TRACKERS = listOf(
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://exodus.desync.com:6969/announce",
"udp://tracker.torrent.eu.org:451/announce"
)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state = MutableStateFlow<TorrentState>(TorrentState.Idle)
val state: StateFlow<TorrentState> = _state.asStateFlow()
private var statsJob: Job? = null
private var currentHash: String? = null
/**
* Starts streaming a torrent. Returns the local HTTP URL for ExoPlayer.
*/
suspend fun startStream(
infoHash: String,
fileIdx: Int?,
filename: String? = null,
trackers: List<String> = emptyList()
): String = withContext(Dispatchers.IO) {
stopStream()
_state.value = TorrentState.Connecting
// Ensure binary is running
binary.start()
val magnetLink = buildMagnetUri(infoHash, trackers)
Log.d(TAG, "Starting stream: $magnetLink")
// Add torrent
val hash = api.addTorrent(magnetLink)
?: throw TorrentException("Failed to add torrent")
currentHash = hash
// Resolve file index
val resolvedIdx = resolveFileIndex(hash, fileIdx, filename)
// Get stream URL — TorrServer handles all buffering/piece management
val streamUrl = api.getStreamUrl(magnetLink, resolvedIdx)
Log.d(TAG, "Stream URL: $streamUrl")
// Start stats polling
startStatsPolling(hash)
_state.value = TorrentState.Streaming(
localUrl = streamUrl,
downloadSpeed = 0,
uploadSpeed = 0,
peers = 0,
seeds = 0,
bufferProgress = 0f,
totalProgress = 0f
)
streamUrl
}
fun stopStream() {
statsJob?.cancel()
statsJob = null
currentHash?.let { hash ->
try {
runBlocking(Dispatchers.IO) {
api.dropTorrent(hash)
}
} catch (e: Exception) {
Log.w(TAG, "Error dropping torrent", e)
}
}
currentHash = null
_state.value = TorrentState.Idle
}
fun shutdown() {
stopStream()
binary.stop()
}
private fun buildMagnetUri(infoHash: String, extraTrackers: List<String>): String {
val trackers = (DEFAULT_TRACKERS + extraTrackers).distinct()
val trackerParams = trackers.joinToString("") { "&tr=$it" }
return "magnet:?xt=urn:btih:$infoHash$trackerParams"
}
private suspend fun resolveFileIndex(hash: String, requestedIdx: Int?, filename: String?): Int {
// Poll for metadata — magnet links may not have it immediately
val deadline = System.currentTimeMillis() + 15_000L
var files: List<TorrServerFile> = emptyList()
while (System.currentTimeMillis() < deadline) {
files = api.getTorrentStats(hash)?.files ?: emptyList()
if (files.isNotEmpty()) break
Log.d(TAG, "Waiting for torrent metadata...")
delay(1_000L)
}
if (files.isEmpty()) {
Log.w(TAG, "No files after metadata timeout, guessing index ${requestedIdx?.plus(1) ?: 1}")
return requestedIdx?.plus(1) ?: 1
}
Log.d(TAG, "Torrent has ${files.size} files")
// Strategy 1: Match by filename (most reliable for season packs)
if (!filename.isNullOrBlank()) {
val name = filename.trim()
// Exact basename match
val exact = files.firstOrNull { f ->
f.path.substringAfterLast('/').equals(name, ignoreCase = true)
}
if (exact != null) {
Log.d(TAG, "File resolved by exact filename match: ${exact.path} -> id=${exact.id}")
return exact.id
}
// Contains match (addon filename may be substring of full path)
val contains = files.firstOrNull { f ->
f.path.contains(name, ignoreCase = true)
}
if (contains != null) {
Log.d(TAG, "File resolved by filename contains match: ${contains.path} -> id=${contains.id}")
return contains.id
}
}
// Strategy 2: Match by ID offset (requestedIdx + 1)
if (requestedIdx != null) {
val tsIdx = requestedIdx + 1
if (files.any { it.id == tsIdx }) {
Log.d(TAG, "File resolved by ID offset: id=$tsIdx")
return tsIdx
}
}
// Strategy 3: Positional index (handles TorrServer alphabetical sort mismatch)
if (requestedIdx != null && requestedIdx in files.indices) {
val positionalFile = files[requestedIdx]
Log.d(TAG, "File resolved by positional index: [$requestedIdx] -> ${positionalFile.path} (id=${positionalFile.id})")
return positionalFile.id
}
// Strategy 4: Fallback to largest video file
val videoFile = files
.filter { f ->
val ext = f.path.substringAfterLast('.', "").lowercase()
ext in VIDEO_EXTENSIONS
}
.maxByOrNull { it.length }
val result = videoFile?.id ?: files.maxByOrNull { it.length }?.id ?: 1
Log.d(TAG, "File resolved by largest video fallback: id=$result")
return result
}
private fun startStatsPolling(hash: String) {
statsJob?.cancel()
statsJob = scope.launch {
while (isActive) {
try {
val stats = api.getTorrentStats(hash)
val currentState = _state.value
if (stats != null && currentState is TorrentState.Streaming) {
_state.value = currentState.copy(
downloadSpeed = stats.downloadSpeed,
uploadSpeed = stats.uploadSpeed,
peers = stats.peers,
seeds = stats.seeds,
preloadedBytes = stats.preloadedBytes
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Stats polling error", e)
}
delay(1000)
}
}
}
}

View file

@ -0,0 +1,62 @@
package com.nuvio.tv.core.torrent
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
private val Context.torrentDataStore by preferencesDataStore(name = "torrent_settings")
data class TorrentSettingsData(
val p2pEnabled: Boolean = false,
val enableUpload: Boolean = true,
val hideTorrentStats: Boolean = true
)
@Singleton
class TorrentSettings @Inject constructor(
@ApplicationContext private val context: Context
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private object Keys {
val P2P_ENABLED = booleanPreferencesKey("p2p_enabled")
val ENABLE_UPLOAD = booleanPreferencesKey("enable_upload")
val HIDE_TORRENT_STATS = booleanPreferencesKey("hide_torrent_stats")
}
val settings: Flow<TorrentSettingsData> = context.torrentDataStore.data.map { prefs ->
TorrentSettingsData(
p2pEnabled = prefs[Keys.P2P_ENABLED] ?: false,
enableUpload = prefs[Keys.ENABLE_UPLOAD] ?: true,
hideTorrentStats = prefs[Keys.HIDE_TORRENT_STATS] ?: true
)
}
fun setP2pEnabled(enabled: Boolean) {
scope.launch {
context.torrentDataStore.edit { it[Keys.P2P_ENABLED] = enabled }
}
}
fun setEnableUpload(enabled: Boolean) {
scope.launch {
context.torrentDataStore.edit { it[Keys.ENABLE_UPLOAD] = enabled }
}
}
fun setHideTorrentStats(enabled: Boolean) {
scope.launch {
context.torrentDataStore.edit { it[Keys.HIDE_TORRENT_STATS] = enabled }
}
}
}

View file

@ -0,0 +1,22 @@
package com.nuvio.tv.core.torrent
import androidx.compose.runtime.Immutable
@Immutable
sealed class TorrentState {
data object Idle : TorrentState()
data object Connecting : TorrentState()
data class Streaming(
val localUrl: String,
val downloadSpeed: Long,
val uploadSpeed: Long,
val peers: Int,
val seeds: Int,
val bufferProgress: Float,
val totalProgress: Float,
val preloadedBytes: Long = 0L
) : TorrentState()
data class Error(val message: String) : TorrentState()
}

View file

@ -0,0 +1,40 @@
package com.nuvio.tv.core.util
import com.nuvio.tv.domain.model.CatalogRow
import com.nuvio.tv.domain.model.MetaPreview
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
private val YEAR_REGEX = Regex("""\b(19|20)\d{2}\b""")
private val ISO_DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE
fun MetaPreview.isUnreleased(today: LocalDate): Boolean {
released?.trim()?.takeIf { it.isNotEmpty() }?.let { rawReleased ->
val releaseDate = rawReleased.substringBefore('T')
try {
val date = LocalDate.parse(releaseDate, ISO_DATE_FORMATTER)
return date.isAfter(today)
} catch (_: DateTimeParseException) {
}
}
val info = releaseInfo ?: return false
// Try full date parse first (e.g. "2026-06-15")
try {
val date = LocalDate.parse(info.trim(), ISO_DATE_FORMATTER)
return date.isAfter(today)
} catch (_: DateTimeParseException) {
// fall through to year-only
}
// Fall back to year extraction
val yearStr = YEAR_REGEX.find(info)?.value ?: return false
val year = yearStr.toIntOrNull() ?: return false
return year > today.year
}
fun CatalogRow.filterReleasedItems(today: LocalDate): CatalogRow {
val filtered = items.filterNot { it.isUnreleased(today) }
return if (filtered.size == items.size) this else copy(items = filtered)
}

View file

@ -46,15 +46,20 @@ class AddonPreferences @Inject constructor(
private val gson = Gson()
private val orderedUrlsKey = stringPreferencesKey("installed_addon_urls_ordered")
private val legacyUrlsKey = stringSetPreferencesKey("installed_addon_urls")
private val userSetNamesKey = stringPreferencesKey("addon_user_set_names")
private val manifestSuffix = "/manifest.json"
private fun canonicalizeUrl(url: String): String {
val trimmed = url.trim().trimEnd('/')
return if (trimmed.endsWith(manifestSuffix, ignoreCase = true)) {
trimmed.dropLast(manifestSuffix.length).trimEnd('/')
val queryStart = trimmed.indexOf('?')
val path = if (queryStart >= 0) trimmed.substring(0, queryStart) else trimmed
val query = if (queryStart >= 0) trimmed.substring(queryStart) else ""
val cleanPath = if (path.endsWith(manifestSuffix, ignoreCase = true)) {
path.dropLast(manifestSuffix.length).trimEnd('/')
} else {
trimmed
path.trimEnd('/')
}
return cleanPath + query
}
val installedAddonUrls: Flow<List<String>> = effectiveProfileIdFlow.flatMapLatest { pid ->
@ -133,6 +138,28 @@ class AddonPreferences @Inject constructor(
}
}
val userSetNames: Flow<Map<String, String>> = effectiveProfileIdFlow.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { preferences ->
val json = preferences[userSetNamesKey]
if (json != null) parseNameMap(json) else emptyMap()
}
}
suspend fun setUserSetNames(names: Map<String, String>) {
store().edit { preferences ->
preferences[userSetNamesKey] = gson.toJson(names)
}
}
private fun parseNameMap(json: String): Map<String, String> {
return try {
val type = object : TypeToken<Map<String, String>>() {}.type
gson.fromJson(json, type) ?: emptyMap()
} catch (e: Exception) {
emptyMap()
}
}
private fun getDefaultAddons(): Set<String> = setOf(
"https://v3-cinemeta.strem.io",
"https://opensubtitles-v3.strem.io"

View file

@ -0,0 +1,42 @@
package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AnimeSkipSettingsDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "animeskip_settings"
}
private fun store() = factory.get(profileManager.activeProfileId.value, FEATURE)
private val enabledKey = booleanPreferencesKey("animeskip_enabled")
private val clientIdKey = stringPreferencesKey("animeskip_client_id")
val enabled: Flow<Boolean> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { it[enabledKey] ?: false }
}
val clientId: Flow<String> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { it[clientIdKey] ?: "" }
}
suspend fun setEnabled(enabled: Boolean) {
store().edit { it[enabledKey] = enabled }
}
suspend fun setClientId(clientId: String) {
store().edit { it[clientIdKey] = clientId.trim() }
}
}

View file

@ -0,0 +1,108 @@
package com.nuvio.tv.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.authSessionNoticeDataStore: DataStore<Preferences> by preferencesDataStore(
name = "auth_session_notice_store"
)
enum class StartupAuthNotice {
NUVIO,
TRAKT
}
@Singleton
class AuthSessionNoticeDataStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private val hadNuvioAuthKey = booleanPreferencesKey("had_nuvio_auth")
private val nuvioExplicitLogoutKey = booleanPreferencesKey("nuvio_explicit_logout")
private val pendingNuvioNoticeKey = booleanPreferencesKey("pending_nuvio_notice")
private val hadTraktAuthKey = booleanPreferencesKey("had_trakt_auth")
private val traktExplicitLogoutKey = booleanPreferencesKey("trakt_explicit_logout")
private val pendingTraktNoticeKey = booleanPreferencesKey("pending_trakt_notice")
val pendingNotice: Flow<StartupAuthNotice?> = context.authSessionNoticeDataStore.data.map { preferences ->
when {
preferences[pendingNuvioNoticeKey] == true -> StartupAuthNotice.NUVIO
preferences[pendingTraktNoticeKey] == true -> StartupAuthNotice.TRAKT
else -> null
}
}
suspend fun markNuvioAuthenticated() {
context.authSessionNoticeDataStore.edit { preferences ->
preferences[hadNuvioAuthKey] = true
preferences[nuvioExplicitLogoutKey] = false
preferences[pendingNuvioNoticeKey] = false
}
}
suspend fun markNuvioExplicitLogout() {
context.authSessionNoticeDataStore.edit { preferences ->
preferences[hadNuvioAuthKey] = false
preferences[nuvioExplicitLogoutKey] = true
preferences[pendingNuvioNoticeKey] = false
}
}
suspend fun markUnexpectedNuvioLogoutIfNeeded() {
context.authSessionNoticeDataStore.edit { preferences ->
val hadAuth = preferences[hadNuvioAuthKey] == true
val explicitLogout = preferences[nuvioExplicitLogoutKey] == true
if (hadAuth && !explicitLogout) {
preferences[pendingNuvioNoticeKey] = true
}
preferences[hadNuvioAuthKey] = false
preferences[nuvioExplicitLogoutKey] = false
}
}
suspend fun markTraktAuthenticated() {
context.authSessionNoticeDataStore.edit { preferences ->
preferences[hadTraktAuthKey] = true
preferences[traktExplicitLogoutKey] = false
preferences[pendingTraktNoticeKey] = false
}
}
suspend fun markTraktExplicitLogout() {
context.authSessionNoticeDataStore.edit { preferences ->
preferences[hadTraktAuthKey] = false
preferences[traktExplicitLogoutKey] = true
preferences[pendingTraktNoticeKey] = false
}
}
suspend fun markUnexpectedTraktLogoutIfNeeded() {
context.authSessionNoticeDataStore.edit { preferences ->
val hadAuth = preferences[hadTraktAuthKey] == true
val explicitLogout = preferences[traktExplicitLogoutKey] == true
if (hadAuth && !explicitLogout) {
preferences[pendingTraktNoticeKey] = true
}
preferences[hadTraktAuthKey] = false
preferences[traktExplicitLogoutKey] = false
}
}
suspend fun consumeNotice(notice: StartupAuthNotice) {
context.authSessionNoticeDataStore.edit { preferences ->
when (notice) {
StartupAuthNotice.NUVIO -> preferences[pendingNuvioNoticeKey] = false
StartupAuthNotice.TRAKT -> preferences[pendingTraktNoticeKey] = false
}
}
}
}

View file

@ -0,0 +1,262 @@
package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.domain.model.Collection
import com.nuvio.tv.domain.model.CollectionCatalogSource
import com.nuvio.tv.domain.model.CollectionFolder
import com.nuvio.tv.domain.model.FolderViewMode
import com.nuvio.tv.domain.model.PosterShape
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
data class ValidationResult(
val valid: Boolean,
val error: String? = null,
val collectionCount: Int = 0,
val folderCount: Int = 0
)
@Singleton
class CollectionsDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "collections"
}
private fun store(profileId: Int = profileManager.activeProfileId.value) =
factory.get(profileId, FEATURE)
private val gson = Gson()
private val collectionsKey = stringPreferencesKey("collections_json")
val collections: Flow<List<Collection>> =
profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
parseCollections(prefs[collectionsKey])
}
}
suspend fun setCollections(collections: List<Collection>) {
store().edit { prefs ->
if (collections.isEmpty()) {
prefs.remove(collectionsKey)
} else {
prefs[collectionsKey] = gson.toJson(collections.map { it.toSerializable() })
}
}
}
suspend fun addCollection(collection: Collection) {
store().edit { prefs ->
val current = parseCollections(prefs[collectionsKey]).toMutableList()
current.add(collection)
prefs[collectionsKey] = gson.toJson(current.map { it.toSerializable() })
}
}
suspend fun updateCollection(collection: Collection) {
store().edit { prefs ->
val current = parseCollections(prefs[collectionsKey]).toMutableList()
val index = current.indexOfFirst { it.id == collection.id }
if (index >= 0) {
current[index] = collection
}
prefs[collectionsKey] = gson.toJson(current.map { it.toSerializable() })
}
}
suspend fun removeCollection(collectionId: String) {
store().edit { prefs ->
val current = parseCollections(prefs[collectionsKey]).toMutableList()
current.removeAll { it.id == collectionId }
if (current.isEmpty()) {
prefs.remove(collectionsKey)
} else {
prefs[collectionsKey] = gson.toJson(current.map { it.toSerializable() })
}
}
}
fun generateId(): String = UUID.randomUUID().toString()
fun exportToJson(collections: List<Collection>): String {
return gson.toJson(collections.map { it.toSerializable() })
}
fun importFromJson(json: String): List<Collection> {
return parseCollections(json)
}
suspend fun getCurrentCollections(): List<Collection> {
val prefs = store().data.first()
return parseCollections(prefs[collectionsKey])
}
suspend fun exportCurrentProfileJson(): String? {
val prefs = store().data.first()
return prefs[collectionsKey]
}
fun validateCollectionsJson(json: String): ValidationResult {
if (json.isBlank()) return ValidationResult(false, "Empty input")
return try {
val type = object : TypeToken<List<Map<String, Any?>>>() {}.type
val parsed = gson.fromJson<List<Map<String, Any?>>>(json, type)
?: return ValidationResult(false, "Invalid format: expected an array")
if (parsed.isEmpty()) return ValidationResult(false, "Empty array: no collections found")
var folderCount = 0
val validShapes = setOf("POSTER", "LANDSCAPE", "SQUARE", "poster", "wide", "square")
for ((i, item) in parsed.withIndex()) {
val id = item["id"] as? String
if (id.isNullOrBlank()) return ValidationResult(false, "Collection ${i + 1}: missing or invalid \"id\"")
val title = item["title"] as? String
?: return ValidationResult(false, "Collection \"$id\": missing or invalid \"title\"")
val folders = item["folders"] as? List<*>
?: return ValidationResult(false, "Collection \"$title\": \"folders\" must be an array")
for ((j, f) in folders.withIndex()) {
val folder = f as? Map<*, *>
?: return ValidationResult(false, "Collection \"$title\", folder ${j + 1}: invalid format")
val folderId = folder["id"] as? String
if (folderId.isNullOrBlank()) return ValidationResult(false, "Collection \"$title\", folder ${j + 1}: missing \"id\"")
val folderTitle = folder["title"] as? String
?: return ValidationResult(false, "Collection \"$title\", folder \"$folderId\": missing \"title\"")
val sources = folder["catalogSources"] as? List<*>
?: return ValidationResult(false, "Collection \"$title\", folder \"$folderTitle\": \"catalogSources\" must be an array")
val shape = folder["tileShape"] as? String
if (shape != null && shape !in validShapes) {
return ValidationResult(false, "Collection \"$title\", folder \"$folderTitle\": invalid tileShape \"$shape\"")
}
for ((k, s) in sources.withIndex()) {
val source = s as? Map<*, *>
?: return ValidationResult(false, "Collection \"$title\", folder \"$folderTitle\", source ${k + 1}: invalid format")
if (source["addonId"] !is String || source["type"] !is String || source["catalogId"] !is String) {
return ValidationResult(false, "Collection \"$title\", folder \"$folderTitle\", source ${k + 1}: missing required fields")
}
}
folderCount++
}
}
ValidationResult(true, collectionCount = parsed.size, folderCount = folderCount)
} catch (e: Exception) {
ValidationResult(false, "JSON parse error: ${e.message}")
}
}
private fun parseCollections(json: String?): List<Collection> {
if (json.isNullOrBlank()) return emptyList()
return try {
val type = object : TypeToken<List<SerializableCollection>>() {}.type
val parsed = gson.fromJson<List<SerializableCollection>>(json, type).orEmpty()
parsed.map { it.toDomain() }
} catch (_: Exception) {
emptyList()
}
}
@androidx.annotation.Keep
private data class SerializableCollection(
val id: String,
val title: String,
val backdropImageUrl: String? = null,
val pinToTop: Boolean = false,
val focusGlowEnabled: Boolean? = null,
val viewMode: String = "TABBED_GRID",
val showAllTab: Boolean = true,
val folders: List<SerializableFolder> = emptyList()
)
@androidx.annotation.Keep
private data class SerializableFolder(
val id: String,
val title: String,
val coverImageUrl: String? = null,
val focusGifUrl: String? = null,
val focusGifEnabled: Boolean? = null,
val coverEmoji: String? = null,
val tileShape: String = "SQUARE",
val hideTitle: Boolean = false,
val catalogSources: List<SerializableCatalogSource> = emptyList()
)
@androidx.annotation.Keep
private data class SerializableCatalogSource(
val addonId: String,
val type: String,
val catalogId: String,
val genre: String? = null
)
private fun Collection.toSerializable() = SerializableCollection(
id = id,
title = title,
backdropImageUrl = backdropImageUrl,
pinToTop = pinToTop,
focusGlowEnabled = focusGlowEnabled,
viewMode = viewMode.name,
showAllTab = showAllTab,
folders = folders.map { folder ->
SerializableFolder(
id = folder.id,
title = folder.title,
coverImageUrl = folder.coverImageUrl,
focusGifUrl = folder.focusGifUrl,
focusGifEnabled = folder.focusGifEnabled,
coverEmoji = folder.coverEmoji,
tileShape = folder.tileShape.name,
hideTitle = folder.hideTitle,
catalogSources = folder.catalogSources.map { source ->
SerializableCatalogSource(
addonId = source.addonId,
type = source.type,
catalogId = source.catalogId,
genre = source.genre
)
}
)
}
)
private fun SerializableCollection.toDomain() = Collection(
id = id,
title = title,
backdropImageUrl = backdropImageUrl,
pinToTop = pinToTop,
focusGlowEnabled = focusGlowEnabled ?: true,
viewMode = FolderViewMode.fromString(viewMode),
showAllTab = showAllTab,
folders = folders.map { folder ->
CollectionFolder(
id = folder.id,
title = folder.title,
coverImageUrl = folder.coverImageUrl,
focusGifUrl = folder.focusGifUrl,
focusGifEnabled = folder.focusGifEnabled ?: true,
coverEmoji = folder.coverEmoji,
tileShape = PosterShape.fromString(folder.tileShape),
hideTitle = folder.hideTitle,
catalogSources = folder.catalogSources.map { source ->
CollectionCatalogSource(
addonId = source.addonId,
type = source.type,
catalogId = source.catalogId,
genre = source.genre
)
}
)
}
)
}

View file

@ -0,0 +1,148 @@
package com.nuvio.tv.data.local
import android.content.Context
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.nuvio.tv.core.profile.ProfileManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
data class CachedNextUpItem(
val contentId: String,
val contentType: String,
val name: String,
val poster: String?,
val backdrop: String?,
val logo: String?,
val videoId: String,
val season: Int,
val episode: Int,
val episodeTitle: String?,
val episodeDescription: String? = null,
val thumbnail: String?,
val released: String? = null,
val hasAired: Boolean = true,
val airDateLabel: String? = null,
val lastWatched: Long,
val imdbRating: Float? = null,
val genres: List<String> = emptyList(),
val releaseInfo: String? = null,
val sortTimestamp: Long,
val releaseTimestamp: Long? = null,
val isReleaseAlert: Boolean = false,
val isNewSeasonRelease: Boolean = false,
val seedSeason: Int? = null,
val seedEpisode: Int? = null,
val contentLanguage: String? = null
)
data class CachedInProgressItem(
val contentId: String,
val contentType: String,
val name: String,
val poster: String?,
val backdrop: String?,
val logo: String?,
val videoId: String,
val season: Int?,
val episode: Int?,
val episodeTitle: String?,
val position: Long,
val duration: Long,
val lastWatched: Long,
val progressPercent: Float?,
val episodeThumbnail: String? = null,
val episodeDescription: String? = null,
val episodeImdbRating: Float? = null,
val genres: List<String> = emptyList(),
val releaseInfo: String? = null,
val contentLanguage: String? = null
)
@Singleton
class ContinueWatchingEnrichmentCache @Inject constructor(
@ApplicationContext private val context: Context,
private val profileManager: ProfileManager
) {
companion object {
private const val TAG = "CwEnrichCache"
}
private val gson = Gson()
private val mutex = Mutex()
// --- Next Up snapshot cache ---
private fun nextUpFile(): File {
val profileId = profileManager.activeProfileId.value
val dir = File(context.cacheDir, "cw_enrichment")
dir.mkdirs()
return File(dir, "nextup_${profileId}.json")
}
suspend fun getNextUpSnapshot(): List<CachedNextUpItem> = withContext(Dispatchers.IO) {
mutex.withLock {
try {
val file = nextUpFile()
if (!file.exists()) return@withContext emptyList()
gson.fromJson(file.readText(), object : TypeToken<List<CachedNextUpItem>>() {}.type)
?: emptyList()
} catch (e: Exception) {
Log.w(TAG, "Failed to read next-up cache: ${e.message}")
emptyList()
}
}
}
suspend fun saveNextUpSnapshot(items: List<CachedNextUpItem>) = withContext(Dispatchers.IO) {
mutex.withLock {
try {
val file = nextUpFile()
file.writeText(gson.toJson(items))
} catch (e: Exception) {
Log.w(TAG, "Failed to write next-up cache: ${e.message}")
}
}
}
// --- In-progress snapshot cache ---
private fun inProgressFile(): File {
val profileId = profileManager.activeProfileId.value
val dir = File(context.cacheDir, "cw_enrichment")
dir.mkdirs()
return File(dir, "inprogress_${profileId}.json")
}
suspend fun getInProgressSnapshot(): List<CachedInProgressItem> = withContext(Dispatchers.IO) {
mutex.withLock {
try {
val file = inProgressFile()
if (!file.exists()) return@withContext emptyList()
gson.fromJson(file.readText(), object : TypeToken<List<CachedInProgressItem>>() {}.type)
?: emptyList()
} catch (e: Exception) {
Log.w(TAG, "Failed to read in-progress cache: ${e.message}")
emptyList()
}
}
}
suspend fun saveInProgressSnapshot(items: List<CachedInProgressItem>) = withContext(Dispatchers.IO) {
mutex.withLock {
try {
val file = inProgressFile()
file.writeText(gson.toJson(items))
} catch (e: Exception) {
Log.w(TAG, "Failed to write in-progress cache: ${e.message}")
}
}
}
}

View file

@ -3,13 +3,22 @@ package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.domain.model.FocusedPosterTrailerPlaybackTarget
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.core.sync.LocalHomeCatalogSettingsState
import com.nuvio.tv.core.sync.SyncHomeCatalogPayload
import com.nuvio.tv.core.sync.buildHomeCatalogSyncPayload
import com.nuvio.tv.core.sync.homeCatalogKey
import com.nuvio.tv.core.sync.homeCollectionKey
import com.nuvio.tv.domain.model.Addon
import com.nuvio.tv.domain.model.Collection
import com.nuvio.tv.domain.model.FocusedPosterTrailerPlaybackTarget
import com.nuvio.tv.domain.model.HomeLayout
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@ -26,7 +35,7 @@ class LayoutPreferenceDataStore @Inject constructor(
private const val DEFAULT_POSTER_CARD_HEIGHT_DP = 189
private const val DEFAULT_POSTER_CARD_CORNER_RADIUS_DP = 12
private const val DEFAULT_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 3
private const val MIN_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 1
private const val MIN_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 0
}
private fun store(profileId: Int = profileManager.activeProfileId.value) =
@ -40,6 +49,7 @@ class LayoutPreferenceDataStore @Inject constructor(
private val heroCatalogKeysKey = stringPreferencesKey("hero_catalog_keys")
private val homeCatalogOrderKeysKey = stringPreferencesKey("home_catalog_order_keys")
private val disabledHomeCatalogKeysKey = stringPreferencesKey("disabled_home_catalog_keys")
private val customCatalogTitlesKey = stringPreferencesKey("custom_catalog_titles")
private val sidebarCollapsedKey = booleanPreferencesKey("sidebar_collapsed_by_default")
private val modernSidebarEnabledKey = booleanPreferencesKey("modern_sidebar_enabled")
private val legacyModernSidebarEnabledKey = booleanPreferencesKey("glass_sidepanel_enabled")
@ -60,8 +70,13 @@ class LayoutPreferenceDataStore @Inject constructor(
private val posterCardHeightDpKey = intPreferencesKey("poster_card_height_dp")
private val posterCardCornerRadiusDpKey = intPreferencesKey("poster_card_corner_radius_dp")
private val blurUnwatchedEpisodesKey = booleanPreferencesKey("blur_unwatched_episodes")
private val blurContinueWatchingNextUpKey = booleanPreferencesKey("blur_continue_watching_next_up")
private val detailPageTrailerButtonEnabledKey = booleanPreferencesKey("detail_page_trailer_button_enabled")
private val preferExternalMetaAddonDetailKey = booleanPreferencesKey("prefer_external_meta_addon_detail")
private val modernHeroFullScreenBackdropKey = booleanPreferencesKey("modern_hero_full_screen_backdrop")
private val hideUnreleasedContentKey = booleanPreferencesKey("hide_unreleased_content")
private val showFullReleaseDateKey = booleanPreferencesKey("show_full_release_date")
private val memoryOnlyVerticalScrollKey = booleanPreferencesKey("memory_only_vertical_scroll")
private fun <T> profileFlow(extract: (prefs: androidx.datastore.preferences.core.Preferences) -> T): Flow<T> =
profileManager.activeProfileId.flatMapLatest { pid ->
@ -106,6 +121,10 @@ class LayoutPreferenceDataStore @Inject constructor(
parseCatalogKeys(prefs[disabledHomeCatalogKeysKey])
}
val customCatalogTitles: Flow<Map<String, String>> = profileFlow { prefs ->
parseCustomTitles(prefs[customCatalogTitlesKey])
}
val sidebarCollapsedByDefault: Flow<Boolean> = profileFlow { prefs ->
val modernSidebarEnabled =
prefs[modernSidebarEnabledKey] ?: prefs[legacyModernSidebarEnabledKey] ?: false
@ -128,6 +147,10 @@ class LayoutPreferenceDataStore @Inject constructor(
prefs[modernLandscapePostersEnabledKey] ?: false
}
val modernHeroFullScreenBackdropEnabled: Flow<Boolean> = profileFlow { prefs ->
prefs[modernHeroFullScreenBackdropKey] ?: false
}
val heroSectionEnabled: Flow<Boolean> = profileFlow { prefs ->
prefs[heroSectionEnabledKey] ?: true
}
@ -190,12 +213,34 @@ class LayoutPreferenceDataStore @Inject constructor(
prefs[blurUnwatchedEpisodesKey] ?: false
}
val blurContinueWatchingNextUp: Flow<Boolean> = profileFlow { prefs ->
prefs[blurContinueWatchingNextUpKey] ?: false
}
val detailPageTrailerButtonEnabled: Flow<Boolean> = profileFlow { prefs ->
prefs[detailPageTrailerButtonEnabledKey] ?: false
}
val preferExternalMetaAddonDetail: Flow<Boolean> = profileFlow { prefs ->
prefs[preferExternalMetaAddonDetailKey] ?: false
prefs[preferExternalMetaAddonDetailKey] ?: true
}
val hideUnreleasedContent: Flow<Boolean> = profileFlow { prefs ->
prefs[hideUnreleasedContentKey] ?: false
}
val showFullReleaseDate: Flow<Boolean> = profileFlow { prefs ->
prefs[showFullReleaseDateKey] ?: true
}
val memoryOnlyVerticalScroll: Flow<Boolean> = profileFlow { prefs ->
prefs[memoryOnlyVerticalScrollKey] ?: false
}
suspend fun setMemoryOnlyVerticalScroll(enabled: Boolean) {
store().edit { prefs ->
prefs[memoryOnlyVerticalScrollKey] = enabled
}
}
suspend fun setLayout(layout: HomeLayout) {
@ -283,6 +328,12 @@ class LayoutPreferenceDataStore @Inject constructor(
}
}
suspend fun setModernHeroFullScreenBackdropEnabled(enabled: Boolean) {
store().edit { prefs ->
prefs[modernHeroFullScreenBackdropKey] = enabled
}
}
suspend fun setHeroSectionEnabled(enabled: Boolean) {
store().edit { prefs ->
prefs[heroSectionEnabledKey] = enabled
@ -377,6 +428,12 @@ class LayoutPreferenceDataStore @Inject constructor(
}
}
suspend fun setBlurContinueWatchingNextUp(enabled: Boolean) {
store().edit { prefs ->
prefs[blurContinueWatchingNextUpKey] = enabled
}
}
suspend fun setDetailPageTrailerButtonEnabled(enabled: Boolean) {
store().edit { prefs ->
prefs[detailPageTrailerButtonEnabledKey] = enabled
@ -389,6 +446,18 @@ class LayoutPreferenceDataStore @Inject constructor(
}
}
suspend fun setHideUnreleasedContent(enabled: Boolean) {
store().edit { prefs ->
prefs[hideUnreleasedContentKey] = enabled
}
}
suspend fun setShowFullReleaseDate(enabled: Boolean) {
store().edit { prefs ->
prefs[showFullReleaseDateKey] = enabled
}
}
private fun parseCatalogKeys(json: String?): List<String> {
if (json.isNullOrBlank()) return emptyList()
return try {
@ -407,4 +476,83 @@ class LayoutPreferenceDataStore @Inject constructor(
.distinct()
.toList()
}
private fun parseCustomTitles(json: String?): Map<String, String> {
if (json.isNullOrBlank()) return emptyMap()
return try {
val type = object : TypeToken<Map<String, String>>() {}.type
gson.fromJson<Map<String, String>>(json, type).orEmpty()
} catch (_: Exception) {
emptyMap()
}
}
suspend fun setCustomCatalogTitles(titles: Map<String, String>) {
store().edit { prefs ->
val filtered = titles.filterValues { it.isNotBlank() }
if (filtered.isEmpty()) {
prefs.remove(customCatalogTitlesKey)
} else {
prefs[customCatalogTitlesKey] = gson.toJson(filtered)
}
}
}
internal suspend fun getHomeCatalogSettingsState(): LocalHomeCatalogSettingsState {
return readHomeCatalogSettingsState(store().data.first())
}
internal suspend fun exportCatalogSettingsToSyncPayload(
addons: List<Addon>,
collections: List<Collection>
): SyncHomeCatalogPayload {
return buildHomeCatalogSyncPayload(
addons = addons,
collections = collections,
localState = getHomeCatalogSettingsState()
)
}
suspend fun applyCatalogSettingsFromRemote(payload: SyncHomeCatalogPayload) {
val sortedItems = payload.items.sortedBy { it.order }
val orderKeys = sortedItems.map { item ->
if (item.isCollection) homeCollectionKey(item.collectionId)
else homeCatalogKey(item.addonId, item.type, item.catalogId)
}
val disabledKeys = sortedItems.filter { !it.enabled }.map { item ->
if (item.isCollection) homeCollectionKey(item.collectionId)
else homeCatalogKey(item.addonId, item.type, item.catalogId)
}
val titles = sortedItems.associate { item ->
val key = if (item.isCollection) homeCollectionKey(item.collectionId)
else homeCatalogKey(item.addonId, item.type, item.catalogId)
key to item.customTitle
}.filterValues { it.isNotBlank() }
store().edit { prefs ->
if (orderKeys.isNotEmpty()) {
prefs[homeCatalogOrderKeysKey] = gson.toJson(orderKeys)
} else {
prefs.remove(homeCatalogOrderKeysKey)
}
if (disabledKeys.isNotEmpty()) {
prefs[disabledHomeCatalogKeysKey] = gson.toJson(disabledKeys)
} else {
prefs.remove(disabledHomeCatalogKeysKey)
}
if (titles.isNotEmpty()) {
prefs[customCatalogTitlesKey] = gson.toJson(titles)
} else {
prefs.remove(customCatalogTitlesKey)
}
}
}
private fun readHomeCatalogSettingsState(prefs: Preferences): LocalHomeCatalogSettingsState {
return LocalHomeCatalogSettingsState(
orderKeys = parseCatalogKeys(prefs[homeCatalogOrderKeysKey]),
disabledKeys = parseCatalogKeys(prefs[disabledHomeCatalogKeysKey]).toSet(),
customTitles = parseCustomTitles(prefs[customCatalogTitlesKey])
)
}
}

View file

@ -2,6 +2,7 @@ package com.nuvio.tv.data.local
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import com.google.gson.Gson
@ -28,6 +29,19 @@ class LibraryPreferences @Inject constructor(
private val gson = Gson()
private val libraryItemsKey = stringSetPreferencesKey("library_items")
private val sortOptionKey = stringPreferencesKey("library_sort_option")
val sortOption: Flow<String?> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { preferences ->
preferences[sortOptionKey]
}
}
suspend fun setSortOption(key: String) {
store().edit { preferences ->
preferences[sortOptionKey] = key
}
}
val libraryItems: Flow<List<SavedLibraryItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { preferences ->
@ -54,7 +68,8 @@ class LibraryPreferences @Inject constructor(
saved.id == item.id && saved.type.equals(item.type, ignoreCase = true)
} ?: false
}
preferences[libraryItemsKey] = filtered.toSet() + gson.toJson(item)
val itemWithTimestamp = if (item.addedAt == 0L) item.copy(addedAt = System.currentTimeMillis()) else item
preferences[libraryItemsKey] = filtered.toSet() + gson.toJson(itemWithTimestamp)
}
}

View file

@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import javax.inject.Inject
import javax.inject.Singleton
import com.nuvio.tv.ui.util.languageCodeToName
/**
* Available subtitle languages
@ -28,52 +29,90 @@ data class SubtitleLanguage(
val name: String
)
val SubtitleLanguage.displayName: String
get() = languageCodeToName(code)
const val SUBTITLE_LANGUAGE_FORCED = "forced"
val AVAILABLE_SUBTITLE_LANGUAGES = listOf(
SubtitleLanguage("af", "Afrikaans"),
SubtitleLanguage("sq", "Albanian"),
SubtitleLanguage("am", "Amharic"),
SubtitleLanguage("ar", "Arabic"),
SubtitleLanguage("hy", "Armenian"),
SubtitleLanguage("az", "Azerbaijani"),
SubtitleLanguage("eu", "Basque"),
SubtitleLanguage("be", "Belarusian"),
SubtitleLanguage("bn", "Bengali"),
SubtitleLanguage("bs", "Bosnian"),
SubtitleLanguage("bg", "Bulgarian"),
SubtitleLanguage("my", "Burmese"),
SubtitleLanguage("ca", "Catalan"),
SubtitleLanguage("zh", "Chinese"),
SubtitleLanguage("zh-CN", "Chinese (Simplified)"),
SubtitleLanguage("zh-TW", "Chinese (Traditional)"),
SubtitleLanguage("hr", "Croatian"),
SubtitleLanguage("cs", "Czech"),
SubtitleLanguage("da", "Danish"),
SubtitleLanguage("nl", "Dutch"),
SubtitleLanguage("en", "English"),
SubtitleLanguage("es", "Spanish"),
SubtitleLanguage("et", "Estonian"),
SubtitleLanguage("tl", "Filipino"),
SubtitleLanguage("fi", "Finnish"),
SubtitleLanguage("fr", "French"),
SubtitleLanguage("gl", "Galician"),
SubtitleLanguage("ka", "Georgian"),
SubtitleLanguage("de", "German"),
SubtitleLanguage("el", "Greek"),
SubtitleLanguage("gu", "Gujarati"),
SubtitleLanguage("he", "Hebrew"),
SubtitleLanguage("hi", "Hindi"),
SubtitleLanguage("hu", "Hungarian"),
SubtitleLanguage("is", "Icelandic"),
SubtitleLanguage("id", "Indonesian"),
SubtitleLanguage("ga", "Irish"),
SubtitleLanguage("it", "Italian"),
SubtitleLanguage("ja", "Japanese"),
SubtitleLanguage("kn", "Kannada"),
SubtitleLanguage("kk", "Kazakh"),
SubtitleLanguage("km", "Khmer"),
SubtitleLanguage("ko", "Korean"),
SubtitleLanguage("lo", "Lao"),
SubtitleLanguage("lv", "Latvian"),
SubtitleLanguage("lt", "Lithuanian"),
SubtitleLanguage("mk", "Macedonian"),
SubtitleLanguage("ms", "Malay"),
SubtitleLanguage("ml", "Malayalam"),
SubtitleLanguage("mt", "Maltese"),
SubtitleLanguage("mr", "Marathi"),
SubtitleLanguage("mn", "Mongolian"),
SubtitleLanguage("ne", "Nepali"),
SubtitleLanguage("no", "Norwegian"),
SubtitleLanguage("pa", "Punjabi"),
SubtitleLanguage("fa", "Persian"),
SubtitleLanguage("pl", "Polish"),
SubtitleLanguage("pt", "Portuguese (Portugal)"),
SubtitleLanguage("pt-br", "Portuguese (Brazil)"),
SubtitleLanguage("ru", "Russian"),
SubtitleLanguage("ja", "Japanese"),
SubtitleLanguage("ko", "Korean"),
SubtitleLanguage("zh", "Chinese"),
SubtitleLanguage("ar", "Arabic"),
SubtitleLanguage("hi", "Hindi"),
SubtitleLanguage("tr", "Turkish"),
SubtitleLanguage("pl", "Polish"),
SubtitleLanguage("nl", "Dutch"),
SubtitleLanguage("sv", "Swedish"),
SubtitleLanguage("da", "Danish"),
SubtitleLanguage("no", "Norwegian"),
SubtitleLanguage("fi", "Finnish"),
SubtitleLanguage("th", "Thai"),
SubtitleLanguage("vi", "Vietnamese"),
SubtitleLanguage("id", "Indonesian"),
SubtitleLanguage("ms", "Malay"),
SubtitleLanguage("he", "Hebrew"),
SubtitleLanguage("el", "Greek"),
SubtitleLanguage("cs", "Czech"),
SubtitleLanguage("hu", "Hungarian"),
SubtitleLanguage("ro", "Romanian"),
SubtitleLanguage("uk", "Ukrainian"),
SubtitleLanguage("bg", "Bulgarian"),
SubtitleLanguage("hr", "Croatian"),
SubtitleLanguage("ru", "Russian"),
SubtitleLanguage("sr", "Serbian"),
SubtitleLanguage("si", "Sinhala"),
SubtitleLanguage("sk", "Slovak"),
SubtitleLanguage("sl", "Slovenian"),
SubtitleLanguage("sr", "Serbian"),
SubtitleLanguage("es", "Spanish"),
SubtitleLanguage("es-419", "Spanish (Latin America)"),
SubtitleLanguage("sw", "Swahili"),
SubtitleLanguage("sv", "Swedish"),
SubtitleLanguage("ta", "Tamil"),
SubtitleLanguage("te", "Telugu"),
SubtitleLanguage("ml", "Malayalam"),
SubtitleLanguage("bn", "Bengali"),
SubtitleLanguage("mr", "Marathi"),
SubtitleLanguage("gu", "Gujarati"),
SubtitleLanguage("kn", "Kannada"),
SubtitleLanguage("pa", "Punjabi")
SubtitleLanguage("th", "Thai"),
SubtitleLanguage("tr", "Turkish"),
SubtitleLanguage("uk", "Ukrainian"),
SubtitleLanguage("ur", "Urdu"),
SubtitleLanguage("uz", "Uzbek"),
SubtitleLanguage("vi", "Vietnamese"),
SubtitleLanguage("cy", "Welsh"),
SubtitleLanguage("zu", "Zulu")
)
/**
@ -111,6 +150,7 @@ data class BufferSettings(
object AudioLanguageOption {
const val DEFAULT = "default" // Use media file default
const val DEVICE = "device" // Use device locale
const val ORIGINAL = "original" // Use content's original language (from TMDB)
}
/**
@ -118,6 +158,8 @@ object AudioLanguageOption {
*/
data class PlayerSettings(
val playerPreference: PlayerPreference = PlayerPreference.INTERNAL,
val internalPlayerEngine: InternalPlayerEngine = InternalPlayerEngine.EXOPLAYER,
val autoSwitchInternalPlayerOnError: Boolean = false,
val useLibass: Boolean = false,
val libassRenderType: LibassRenderType = LibassRenderType.OVERLAY_OPEN_GL,
val subtitleStyle: SubtitleStyleSettings = SubtitleStyleSettings(),
@ -126,15 +168,21 @@ data class PlayerSettings(
val decoderPriority: Int = 1, // EXTENSION_RENDERER_MODE_ON (0=off, 1=on, 2=prefer)
val tunnelingEnabled: Boolean = false,
val skipSilence: Boolean = false,
val audioAmplificationDb: Int = 0,
val persistAudioAmplification: Boolean = false,
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
val secondaryPreferredAudioLanguage: String? = null,
val loadingOverlayEnabled: Boolean = true,
val showPlayerLoadingStatus: Boolean = true,
val pauseOverlayEnabled: Boolean = true,
val osdClockEnabled: Boolean = true,
val skipIntroEnabled: Boolean = true,
// Dolby Vision Profile 7 → HEVC fallback (requires forked ExoPlayer)
val mapDV7ToHevc: Boolean = false,
val mpvHardwareDecodeMode: MpvHardwareDecodeMode = MpvHardwareDecodeMode.AUTO_SAFE,
// Display settings
val frameRateMatchingMode: FrameRateMatchingMode = FrameRateMatchingMode.OFF,
val resolutionMatchingEnabled: Boolean = false,
// Stream selection settings
val streamAutoPlayMode: StreamAutoPlayMode = StreamAutoPlayMode.MANUAL,
val streamAutoPlaySource: StreamAutoPlaySource = StreamAutoPlaySource.ALL_SOURCES,
@ -142,13 +190,16 @@ data class PlayerSettings(
val streamAutoPlaySelectedPlugins: Set<String> = emptySet(),
val streamAutoPlayRegex: String = "",
val streamAutoPlayNextEpisodeEnabled: Boolean = false,
val streamAutoPlayPreferBingeGroupForNextEpisode: Boolean = false,
val streamAutoPlayPreferBingeGroupForNextEpisode: Boolean = true,
val streamAutoPlayTimeoutSeconds: Int = 3,
val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE,
val nextEpisodeThresholdPercent: Float = 98f,
val nextEpisodeThresholdPercent: Float = 99f,
val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f,
val streamReuseLastLinkEnabled: Boolean = false,
val streamReuseLastLinkCacheHours: Int = 24,
val subtitleOrganizationMode: SubtitleOrganizationMode = SubtitleOrganizationMode.NONE
val subtitleOrganizationMode: SubtitleOrganizationMode = SubtitleOrganizationMode.NONE,
val addonSubtitleStartupMode: AddonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES,
val resizeMode: Int = 0
)
enum class StreamAutoPlayMode {
@ -180,12 +231,31 @@ enum class SubtitleOrganizationMode {
BY_ADDON
}
enum class AddonSubtitleStartupMode {
FAST_STARTUP,
PREFERRED_ONLY,
ALL_SUBTITLES
}
enum class MpvHardwareDecodeMode {
LEGACY_DIRECT_COPY,
AUTO_SAFE,
HARDWARE_COPY,
HARDWARE_DIRECT,
DISABLED
}
enum class PlayerPreference {
INTERNAL,
EXTERNAL,
ASK_EVERY_TIME
}
enum class InternalPlayerEngine {
EXOPLAYER,
MVP_PLAYER
}
/**
* Enum representing the different libass render types
* Maps to io.github.peerless2012.ass.media.type.AssRenderType
@ -205,6 +275,8 @@ class PlayerSettingsDataStore @Inject constructor(
) {
companion object {
private const val FEATURE = "player_settings"
private const val AUDIO_AMPLIFICATION_DB_MIN = 0
private const val AUDIO_AMPLIFICATION_DB_MAX = 10
}
private fun store(profileId: Int = profileManager.activeProfileId.value) =
@ -214,6 +286,9 @@ class PlayerSettingsDataStore @Inject constructor(
// Player preference key
private val playerPreferenceKey = stringPreferencesKey("player_preference")
private val internalPlayerEngineKey = stringPreferencesKey("internal_player_engine")
private val autoSwitchInternalPlayerOnErrorKey =
booleanPreferencesKey("auto_switch_internal_player_on_error")
// Libass settings keys
private val useLibassKey = booleanPreferencesKey("use_libass")
@ -223,14 +298,20 @@ class PlayerSettingsDataStore @Inject constructor(
private val decoderPriorityKey = intPreferencesKey("decoder_priority")
private val tunnelingEnabledKey = booleanPreferencesKey("tunneling_enabled")
private val skipSilenceKey = booleanPreferencesKey("skip_silence")
private val audioAmplificationDbKey = intPreferencesKey("audio_amplification_db")
private val persistAudioAmplificationKey = booleanPreferencesKey("persist_audio_amplification")
private val preferredAudioLanguageKey = stringPreferencesKey("preferred_audio_language")
private val secondaryPreferredAudioLanguageKey = stringPreferencesKey("secondary_preferred_audio_language")
private val loadingOverlayEnabledKey = booleanPreferencesKey("loading_overlay_enabled")
private val showPlayerLoadingStatusKey = booleanPreferencesKey("show_player_loading_status")
private val pauseOverlayEnabledKey = booleanPreferencesKey("pause_overlay_enabled")
private val osdClockEnabledKey = booleanPreferencesKey("osd_clock_enabled")
private val skipIntroEnabledKey = booleanPreferencesKey("skip_intro_enabled")
private val mapDV7ToHevcKey = booleanPreferencesKey("map_dv7_to_hevc")
private val mpvHardwareDecodeModeKey = stringPreferencesKey("mpv_hardware_decode_mode")
private val frameRateMatchingKey = booleanPreferencesKey("frame_rate_matching")
private val frameRateMatchingModeKey = stringPreferencesKey("frame_rate_matching_mode")
private val resolutionMatchingEnabledKey = booleanPreferencesKey("resolution_matching_enabled")
private val streamAutoPlayModeKey = stringPreferencesKey("stream_auto_play_mode")
private val streamAutoPlaySourceKey = stringPreferencesKey("stream_auto_play_source")
private val streamAutoPlaySelectedAddonsKey = stringSetPreferencesKey("stream_auto_play_selected_addons")
@ -238,6 +319,7 @@ class PlayerSettingsDataStore @Inject constructor(
private val streamAutoPlayRegexKey = stringPreferencesKey("stream_auto_play_regex")
private val streamAutoPlayNextEpisodeEnabledKey = booleanPreferencesKey("stream_auto_play_next_episode_enabled")
private val streamAutoPlayPreferBingeGroupForNextEpisodeKey = booleanPreferencesKey("stream_auto_play_prefer_bingegroup_next_episode")
private val streamAutoPlayTimeoutSecondsKey = intPreferencesKey("stream_auto_play_timeout_seconds")
private val nextEpisodeThresholdModeKey = stringPreferencesKey("next_episode_threshold_mode")
private val nextEpisodeThresholdPercentLegacyKey = intPreferencesKey("next_episode_threshold_percent")
private val nextEpisodeThresholdMinutesBeforeEndLegacyKey = intPreferencesKey("next_episode_threshold_minutes_before_end")
@ -246,6 +328,8 @@ class PlayerSettingsDataStore @Inject constructor(
private val streamReuseLastLinkEnabledKey = booleanPreferencesKey("stream_reuse_last_link_enabled")
private val streamReuseLastLinkCacheHoursKey = intPreferencesKey("stream_reuse_last_link_cache_hours")
private val subtitleOrganizationModeKey = stringPreferencesKey("subtitle_organization_mode")
private val addonSubtitleStartupModeKey = stringPreferencesKey("addon_subtitle_startup_mode")
private val resizeModeKey = intPreferencesKey("resize_mode")
// Subtitle style settings keys
private val subtitlePreferredLanguageKey = stringPreferencesKey("subtitle_preferred_language")
@ -304,6 +388,19 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
val secondaryPreferredAudioLanguage = prefs[secondaryPreferredAudioLanguageKey]
if (secondaryPreferredAudioLanguage != null) {
val normalizedSecondaryPreferredAudioLanguage =
normalizeSecondaryAudioLanguageCode(secondaryPreferredAudioLanguage)
if (normalizedSecondaryPreferredAudioLanguage != secondaryPreferredAudioLanguage) {
if (normalizedSecondaryPreferredAudioLanguage != null) {
prefs[secondaryPreferredAudioLanguageKey] = normalizedSecondaryPreferredAudioLanguage
} else {
prefs.remove(secondaryPreferredAudioLanguageKey)
}
}
}
val preferredSubtitleLanguage = prefs[subtitlePreferredLanguageKey]
if (preferredSubtitleLanguage != null) {
val normalizedPreferredSubtitleLanguage =
@ -334,6 +431,10 @@ class PlayerSettingsDataStore @Inject constructor(
playerPreference = prefs[playerPreferenceKey]?.let {
runCatching { PlayerPreference.valueOf(it) }.getOrDefault(PlayerPreference.INTERNAL)
} ?: PlayerPreference.INTERNAL,
internalPlayerEngine = prefs[internalPlayerEngineKey]?.let {
runCatching { InternalPlayerEngine.valueOf(it) }.getOrDefault(InternalPlayerEngine.EXOPLAYER)
} ?: InternalPlayerEngine.EXOPLAYER,
autoSwitchInternalPlayerOnError = prefs[autoSwitchInternalPlayerOnErrorKey] ?: false,
useLibass = prefs[useLibassKey] ?: false,
libassRenderType = prefs[libassRenderTypeKey]?.let {
try { LibassRenderType.valueOf(it) } catch (e: Exception) { LibassRenderType.OVERLAY_OPEN_GL }
@ -341,14 +442,23 @@ class PlayerSettingsDataStore @Inject constructor(
decoderPriority = prefs[decoderPriorityKey] ?: 1,
tunnelingEnabled = prefs[tunnelingEnabledKey] ?: false,
skipSilence = prefs[skipSilenceKey] ?: false,
audioAmplificationDb = (prefs[audioAmplificationDbKey] ?: 0).coerceIn(
AUDIO_AMPLIFICATION_DB_MIN,
AUDIO_AMPLIFICATION_DB_MAX
),
persistAudioAmplification = prefs[persistAudioAmplificationKey] ?: false,
preferredAudioLanguage = normalizeSelectableLanguageCode(
prefs[preferredAudioLanguageKey] ?: AudioLanguageOption.DEVICE
),
secondaryPreferredAudioLanguage = prefs[secondaryPreferredAudioLanguageKey]
?.let(::normalizeSecondaryAudioLanguageCode),
loadingOverlayEnabled = prefs[loadingOverlayEnabledKey] ?: true,
showPlayerLoadingStatus = prefs[showPlayerLoadingStatusKey] ?: true,
pauseOverlayEnabled = prefs[pauseOverlayEnabledKey] ?: true,
osdClockEnabled = prefs[osdClockEnabledKey] ?: true,
skipIntroEnabled = prefs[skipIntroEnabledKey] ?: true,
mapDV7ToHevc = prefs[mapDV7ToHevcKey] ?: false,
mpvHardwareDecodeMode = parseMpvHardwareDecodeMode(prefs[mpvHardwareDecodeModeKey]),
frameRateMatchingMode = prefs[frameRateMatchingModeKey]?.let {
runCatching { FrameRateMatchingMode.valueOf(it) }.getOrNull()
} ?: if (prefs[frameRateMatchingKey] == true) {
@ -356,6 +466,7 @@ class PlayerSettingsDataStore @Inject constructor(
} else {
FrameRateMatchingMode.OFF
},
resolutionMatchingEnabled = prefs[resolutionMatchingEnabledKey] ?: false,
streamAutoPlayMode = prefs[streamAutoPlayModeKey]?.let {
runCatching { StreamAutoPlayMode.valueOf(it) }.getOrDefault(StreamAutoPlayMode.MANUAL)
} ?: StreamAutoPlayMode.MANUAL,
@ -367,14 +478,15 @@ class PlayerSettingsDataStore @Inject constructor(
streamAutoPlayRegex = prefs[streamAutoPlayRegexKey] ?: "",
streamAutoPlayNextEpisodeEnabled = prefs[streamAutoPlayNextEpisodeEnabledKey] ?: false,
streamAutoPlayPreferBingeGroupForNextEpisode =
prefs[streamAutoPlayPreferBingeGroupForNextEpisodeKey] ?: false,
prefs[streamAutoPlayPreferBingeGroupForNextEpisodeKey] ?: true,
streamAutoPlayTimeoutSeconds = (prefs[streamAutoPlayTimeoutSecondsKey] ?: 3).coerceIn(0, 11),
nextEpisodeThresholdMode = prefs[nextEpisodeThresholdModeKey]?.let {
runCatching { NextEpisodeThresholdMode.valueOf(it) }.getOrDefault(NextEpisodeThresholdMode.PERCENTAGE)
} ?: NextEpisodeThresholdMode.PERCENTAGE,
nextEpisodeThresholdPercent = normalizeHalfStep(
value = prefs[nextEpisodeThresholdPercentKey]
?: prefs[nextEpisodeThresholdPercentLegacyKey]?.toFloat()
?: 98f,
?: 99f,
min = 97f,
max = 99.5f
),
@ -388,6 +500,8 @@ class PlayerSettingsDataStore @Inject constructor(
streamReuseLastLinkEnabled = prefs[streamReuseLastLinkEnabledKey] ?: false,
streamReuseLastLinkCacheHours = (prefs[streamReuseLastLinkCacheHoursKey] ?: 24).coerceIn(1, 168),
subtitleOrganizationMode = parseSubtitleOrganizationMode(prefs[subtitleOrganizationModeKey]),
addonSubtitleStartupMode = parseAddonSubtitleStartupMode(prefs[addonSubtitleStartupModeKey]),
resizeMode = (prefs[resizeModeKey] ?: 0).coerceIn(0, 4),
subtitleStyle = SubtitleStyleSettings(
preferredLanguage = normalizeSelectableLanguageCode(
prefs[subtitlePreferredLanguageKey] ?: "en"
@ -444,6 +558,18 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setInternalPlayerEngine(engine: InternalPlayerEngine) {
store().edit { prefs ->
prefs[internalPlayerEngineKey] = engine.name
}
}
suspend fun setAutoSwitchInternalPlayerOnError(enabled: Boolean) {
store().edit { prefs ->
prefs[autoSwitchInternalPlayerOnErrorKey] = enabled
}
}
// Audio settings setters
suspend fun setDecoderPriority(priority: Int) {
@ -464,6 +590,27 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setAudioAmplificationDb(db: Int) {
store().edit { prefs ->
prefs[audioAmplificationDbKey] = db.coerceIn(
AUDIO_AMPLIFICATION_DB_MIN,
AUDIO_AMPLIFICATION_DB_MAX
)
}
}
suspend fun setPersistAudioAmplification(enabled: Boolean, dbToPersist: Int? = null) {
store().edit { prefs ->
prefs[persistAudioAmplificationKey] = enabled
if (enabled && dbToPersist != null) {
prefs[audioAmplificationDbKey] = dbToPersist.coerceIn(
AUDIO_AMPLIFICATION_DB_MIN,
AUDIO_AMPLIFICATION_DB_MAX
)
}
}
}
suspend fun setPreferredAudioLanguage(language: String) {
store().edit { prefs ->
prefs[preferredAudioLanguageKey] = normalizeSelectableLanguageCode(
@ -472,6 +619,19 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setSecondaryPreferredAudioLanguage(language: String?) {
store().edit { prefs ->
val normalizedLanguage = language
?.takeIf { it.isNotBlank() }
?.let(::normalizeSecondaryAudioLanguageCode)
if (normalizedLanguage != null) {
prefs[secondaryPreferredAudioLanguageKey] = normalizedLanguage
} else {
prefs.remove(secondaryPreferredAudioLanguageKey)
}
}
}
suspend fun setPauseOverlayEnabled(enabled: Boolean) {
store().edit { prefs ->
prefs[pauseOverlayEnabledKey] = enabled
@ -496,6 +656,12 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setShowPlayerLoadingStatus(enabled: Boolean) {
store().edit { prefs ->
prefs[showPlayerLoadingStatusKey] = enabled
}
}
suspend fun setFrameRateMatchingMode(mode: FrameRateMatchingMode) {
store().edit { prefs ->
prefs[frameRateMatchingModeKey] = mode.name
@ -503,6 +669,12 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setResolutionMatchingEnabled(enabled: Boolean) {
store().edit { prefs ->
prefs[resolutionMatchingEnabledKey] = enabled
}
}
suspend fun setFrameRateMatching(enabled: Boolean) {
setFrameRateMatchingMode(
if (enabled) FrameRateMatchingMode.START_STOP else FrameRateMatchingMode.OFF
@ -551,6 +723,12 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setStreamAutoPlayTimeoutSeconds(seconds: Int) {
store().edit { prefs ->
prefs[streamAutoPlayTimeoutSecondsKey] = seconds.coerceIn(0, 11)
}
}
suspend fun setNextEpisodeThresholdMode(mode: NextEpisodeThresholdMode) {
store().edit { prefs ->
prefs[nextEpisodeThresholdModeKey] = mode.name
@ -600,6 +778,18 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setAddonSubtitleStartupMode(mode: AddonSubtitleStartupMode) {
store().edit { prefs ->
prefs[addonSubtitleStartupModeKey] = mode.name
}
}
suspend fun setResizeMode(mode: Int) {
store().edit { prefs ->
prefs[resizeModeKey] = mode.coerceIn(0, 4)
}
}
private fun parseSubtitleOrganizationMode(value: String?): SubtitleOrganizationMode {
return when (value) {
null, "NONE" -> SubtitleOrganizationMode.NONE
@ -609,6 +799,26 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
private fun parseAddonSubtitleStartupMode(value: String?): AddonSubtitleStartupMode {
return when (value) {
null, "ALL_SUBTITLES" -> AddonSubtitleStartupMode.ALL_SUBTITLES
"PREFERRED_ONLY" -> AddonSubtitleStartupMode.PREFERRED_ONLY
"FAST_STARTUP" -> AddonSubtitleStartupMode.FAST_STARTUP
else -> AddonSubtitleStartupMode.ALL_SUBTITLES
}
}
private fun parseMpvHardwareDecodeMode(value: String?): MpvHardwareDecodeMode {
return when (value) {
null, "AUTO_SAFE" -> MpvHardwareDecodeMode.AUTO_SAFE
"HARDWARE_COPY" -> MpvHardwareDecodeMode.HARDWARE_COPY
"HARDWARE_DIRECT" -> MpvHardwareDecodeMode.HARDWARE_DIRECT
"DISABLED" -> MpvHardwareDecodeMode.DISABLED
"LEGACY_DIRECT_COPY" -> MpvHardwareDecodeMode.LEGACY_DIRECT_COPY
else -> MpvHardwareDecodeMode.AUTO_SAFE
}
}
private fun normalizeSelectableLanguageCode(language: String): String {
val code = language.trim().lowercase()
return when (code) {
@ -619,12 +829,29 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
private fun normalizeSecondaryAudioLanguageCode(language: String): String? {
val normalized = normalizeSelectableLanguageCode(language)
return when (normalized) {
AudioLanguageOption.DEFAULT,
AudioLanguageOption.DEVICE,
AudioLanguageOption.ORIGINAL,
SUBTITLE_LANGUAGE_FORCED -> null
else -> normalized
}
}
suspend fun setMapDV7ToHevc(enabled: Boolean) {
store().edit { prefs ->
prefs[mapDV7ToHevcKey] = enabled
}
}
suspend fun setMpvHardwareDecodeMode(mode: MpvHardwareDecodeMode) {
store().edit { prefs ->
prefs[mpvHardwareDecodeModeKey] = mode.name
}
}
/**
* Set whether to use libass for ASS/SSA subtitle rendering
*/

View file

@ -67,9 +67,13 @@ class PluginDataStore @Inject constructor(
get() {
val pid = effectiveProfileId()
val dirName = if (pid == 1) "plugin_code" else "plugin_code_p${pid}"
return File(context.filesDir, dirName).also { it.mkdirs() }
return File(context.filesDir, dirName)
}
private suspend fun ensureCodeDir(): File = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
codeDir.also { it.mkdirs() }
}
// Repositories
val repositories: Flow<List<PluginRepository>> = effectiveProfileIdFlow.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
@ -170,21 +174,30 @@ class PluginDataStore @Inject constructor(
return File(codeDir, "$scraperId.js")
}
fun saveScraperCode(scraperId: String, code: String) {
getScraperCodeFile(scraperId).writeText(code)
suspend fun saveScraperCode(scraperId: String, code: String) {
val dir = ensureCodeDir()
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
File(dir, "$scraperId.js").writeText(code)
}
}
fun getScraperCode(scraperId: String): String? {
val file = getScraperCodeFile(scraperId)
return if (file.exists()) file.readText() else null
suspend fun getScraperCode(scraperId: String): String? {
return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
val file = File(codeDir, "$scraperId.js")
if (file.exists()) file.readText() else null
}
}
fun deleteScraperCode(scraperId: String) {
getScraperCodeFile(scraperId).delete()
suspend fun deleteScraperCode(scraperId: String) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
File(codeDir, "$scraperId.js").delete()
}
}
fun clearAllScraperCode() {
codeDir.listFiles()?.forEach { it.delete() }
suspend fun clearAllScraperCode() {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
codeDir.listFiles()?.forEach { it.delete() }
}
}
// Per-scraper settings

View file

@ -120,14 +120,16 @@ internal data class ProfileJson(
val name: String,
val avatarColorHex: String,
val usesPrimaryAddons: Boolean = false,
val usesPrimaryPlugins: Boolean = false
val usesPrimaryPlugins: Boolean = false,
val avatarId: String? = null
) {
fun toDomain() = UserProfile(
id = id,
name = name,
avatarColorHex = avatarColorHex,
usesPrimaryAddons = usesPrimaryAddons,
usesPrimaryPlugins = usesPrimaryPlugins
usesPrimaryPlugins = usesPrimaryPlugins,
avatarId = avatarId
)
companion object {
@ -136,7 +138,8 @@ internal data class ProfileJson(
name = profile.name,
avatarColorHex = profile.avatarColorHex,
usesPrimaryAddons = profile.usesPrimaryAddons,
usesPrimaryPlugins = profile.usesPrimaryPlugins
usesPrimaryPlugins = profile.usesPrimaryPlugins,
avatarId = profile.avatarId
)
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.ConcurrentHashMap
@ -15,9 +16,17 @@ class ProfileDataStoreFactory @Inject constructor(
@ApplicationContext private val context: Context
) {
private val cache = ConcurrentHashMap<String, DataStore<Preferences>>()
private val deletedProfileIds = ConcurrentHashMap.newKeySet<Int>()
fun get(profileId: Int, featureName: String): DataStore<Preferences> {
val fileName = if (profileId == 1) featureName else "${featureName}_p${profileId}"
if (profileId != 1 && profileId in deletedProfileIds) {
return cache.compute(fileName) { _, _ ->
PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile(fileName)
}
}!!
}
return cache.getOrPut(fileName) {
PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile(fileName)
@ -25,10 +34,23 @@ class ProfileDataStoreFactory @Inject constructor(
}
}
fun clearProfile(profileId: Int) {
suspend fun clearProfile(profileId: Int) {
if (profileId == 1) return
deletedProfileIds.add(profileId)
val suffix = "_p${profileId}"
val keysToRemove = cache.keys.filter { key -> key.endsWith(suffix) }
keysToRemove.forEach { key -> cache.remove(key) }
for (key in keysToRemove) {
val store = cache[key]
if (store != null) {
runCatching { store.edit { it.clear() } }
}
cache.remove(key)
}
}
fun isProfileDeleted(profileId: Int): Boolean = profileId in deletedProfileIds
fun markProfileCreated(profileId: Int) {
deletedProfileIds.remove(profileId)
}
}

View file

@ -0,0 +1,77 @@
package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SearchHistoryDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "search_history"
private const val DEFAULT_MAX_RECENT_SEARCHES = 8
}
private fun store(profileId: Int = profileManager.activeProfileId.value) =
factory.get(profileId, FEATURE)
private val gson = Gson()
private val recentSearchesKey = stringPreferencesKey("recent_searches")
val recentSearches: Flow<List<String>> = profileManager.activeProfileId.flatMapLatest { profileId ->
factory.get(profileId, FEATURE).data.map { prefs ->
parseRecentSearches(prefs[recentSearchesKey])
}
}
suspend fun saveRecentSearch(query: String, maxItems: Int = DEFAULT_MAX_RECENT_SEARCHES) {
val normalized = query.trim()
if (normalized.isEmpty()) return
val current = recentSearches.first()
val updated = buildList {
add(normalized)
addAll(current.filterNot { it.equals(normalized, ignoreCase = true) })
}.take(maxItems.coerceAtLeast(1))
store().edit { prefs ->
prefs[recentSearchesKey] = gson.toJson(updated)
}
}
suspend fun clearRecentSearches() {
store().edit { prefs ->
prefs.remove(recentSearchesKey)
}
}
private fun parseRecentSearches(raw: String?): List<String> {
if (raw.isNullOrBlank()) return emptyList()
return try {
val type = object : TypeToken<List<String>>() {}.type
val parsed = gson.fromJson<List<String>>(raw, type).orEmpty()
normalizeRecentSearches(parsed)
} catch (_: Exception) {
emptyList()
}
}
private fun normalizeRecentSearches(items: List<String>): List<String> {
val seen = linkedSetOf<String>()
return items.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.filter { value -> seen.add(value.lowercase()) }
.toList()
}
}

View file

@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.flow.first
import org.json.JSONArray
import org.json.JSONObject
import java.security.MessageDigest
import javax.inject.Inject
@ -15,8 +16,13 @@ data class CachedStreamLink(
val streamName: String,
val headers: Map<String, String>,
val cachedAtMs: Long,
val rememberedAudioLanguage: String? = null,
val rememberedAudioName: String? = null
val filename: String? = null,
val videoHash: String? = null,
val videoSize: Long? = null,
val infoHash: String? = null,
val fileIdx: Int? = null,
val sources: List<String>? = null,
val bingeGroup: String? = null
)
@Singleton
@ -36,16 +42,26 @@ class StreamLinkCacheDataStore @Inject constructor(
url: String,
streamName: String,
headers: Map<String, String>?,
rememberedAudioLanguage: String? = null,
rememberedAudioName: String? = null
filename: String? = null,
videoHash: String? = null,
videoSize: Long? = null,
infoHash: String? = null,
fileIdx: Int? = null,
sources: List<String>? = null,
bingeGroup: String? = null
) {
val payload = JSONObject().apply {
put("url", url)
put("streamName", streamName)
put("cachedAtMs", System.currentTimeMillis())
put("headers", JSONObject(headers ?: emptyMap<String, String>()))
put("rememberedAudioLanguage", rememberedAudioLanguage)
put("rememberedAudioName", rememberedAudioName)
put("filename", filename)
put("videoHash", videoHash)
videoSize?.let { put("videoSize", it) }
infoHash?.let { put("infoHash", it) }
fileIdx?.let { put("fileIdx", it) }
sources?.let { put("sources", JSONArray(it)) }
bingeGroup?.let { put("bingeGroup", it) }
}.toString()
store().edit { prefs ->
@ -74,15 +90,30 @@ class StreamLinkCacheDataStore @Inject constructor(
val url = json.optString("url", "")
val streamName = json.optString("streamName", "")
if (url.isBlank() || streamName.isBlank()) return@runCatching null
val infoHash = json.optString("infoHash", "").ifBlank { null }
// Accept entry if it has either a real URL (HTTP stream) or an
// infoHash (torrent stream — URL is re-resolved on each playback).
if (streamName.isBlank() || (url.isBlank() && infoHash == null)) return@runCatching null
val sourcesJson = json.optJSONArray("sources")
val sources = sourcesJson?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optString(i).takeIf { it.isNotEmpty() }
}
}?.takeIf { it.isNotEmpty() }
CachedStreamLink(
url = url,
streamName = streamName,
headers = headers,
cachedAtMs = cachedAtMs,
rememberedAudioLanguage = json.optString("rememberedAudioLanguage", "").ifBlank { null },
rememberedAudioName = json.optString("rememberedAudioName", "").ifBlank { null }
filename = json.optString("filename", "").ifBlank { null },
videoHash = json.optString("videoHash", "").ifBlank { null },
videoSize = json.optLong("videoSize", -1L).takeIf { it >= 0L },
infoHash = infoHash,
fileIdx = if (json.has("fileIdx")) json.optInt("fileIdx", -1).takeIf { it >= 0 } else null,
sources = sources,
bingeGroup = json.optString("bingeGroup", "").ifBlank { null }
)
}.getOrNull()

View file

@ -3,6 +3,7 @@ package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.domain.model.AppFont
import com.nuvio.tv.domain.model.AppTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
@ -23,6 +24,7 @@ class ThemeDataStore @Inject constructor(
factory.get(profileId, FEATURE)
private val themeKey = stringPreferencesKey("selected_theme")
private val fontKey = stringPreferencesKey("selected_font")
val selectedTheme: Flow<AppTheme> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
@ -35,9 +37,26 @@ class ThemeDataStore @Inject constructor(
}
}
val selectedFont: Flow<AppFont> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
val fontName = prefs[fontKey] ?: AppFont.INTER.name
try {
AppFont.valueOf(fontName)
} catch (e: IllegalArgumentException) {
AppFont.INTER
}
}
}
suspend fun setTheme(theme: AppTheme) {
store().edit { prefs ->
prefs[themeKey] = theme.name
}
}
suspend fun setFont(font: AppFont) {
store().edit { prefs ->
prefs[fontKey] = font.name
}
}
}

View file

@ -24,29 +24,37 @@ class TmdbSettingsDataStore @Inject constructor(
factory.get(profileId, FEATURE)
private val enabledKey = booleanPreferencesKey("tmdb_enabled")
private val modernHomeEnabledKey = booleanPreferencesKey("tmdb_modern_home_enabled")
private val enrichContinueWatchingKey = booleanPreferencesKey("tmdb_enrich_continue_watching")
private val languageKey = stringPreferencesKey("tmdb_language")
private val useArtworkKey = booleanPreferencesKey("tmdb_use_artwork")
private val useBasicInfoKey = booleanPreferencesKey("tmdb_use_basic_info")
private val useDetailsKey = booleanPreferencesKey("tmdb_use_details")
private val useReleaseDatesKey = booleanPreferencesKey("tmdb_use_release_dates")
private val useCreditsKey = booleanPreferencesKey("tmdb_use_credits")
private val useProductionsKey = booleanPreferencesKey("tmdb_use_productions")
private val useNetworksKey = booleanPreferencesKey("tmdb_use_networks")
private val useEpisodesKey = booleanPreferencesKey("tmdb_use_episodes")
private val useMoreLikeThisKey = booleanPreferencesKey("tmdb_use_more_like_this")
private val useCollectionsKey = booleanPreferencesKey("tmdb_use_collections")
val settings: Flow<TmdbSettings> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
TmdbSettings(
enabled = prefs[enabledKey] ?: false,
modernHomeEnabled = prefs[modernHomeEnabledKey] ?: false,
enrichContinueWatching = prefs[enrichContinueWatchingKey] ?: true,
language = prefs[languageKey] ?: "en",
useArtwork = prefs[useArtworkKey] ?: true,
useBasicInfo = prefs[useBasicInfoKey] ?: true,
useDetails = prefs[useDetailsKey] ?: true,
useReleaseDates = prefs[useReleaseDatesKey] ?: true,
useCredits = prefs[useCreditsKey] ?: true,
useProductions = prefs[useProductionsKey] ?: true,
useNetworks = prefs[useNetworksKey] ?: true,
useEpisodes = prefs[useEpisodesKey] ?: true,
useMoreLikeThis = prefs[useMoreLikeThisKey] ?: true
useMoreLikeThis = prefs[useMoreLikeThisKey] ?: true,
useCollections = prefs[useCollectionsKey] ?: true
)
}
}
@ -55,6 +63,14 @@ class TmdbSettingsDataStore @Inject constructor(
store().edit { it[enabledKey] = enabled }
}
suspend fun setModernHomeEnabled(enabled: Boolean) {
store().edit { it[modernHomeEnabledKey] = enabled }
}
suspend fun setEnrichContinueWatching(enabled: Boolean) {
store().edit { it[enrichContinueWatchingKey] = enabled }
}
suspend fun setLanguage(language: String) {
store().edit { it[languageKey] = language.ifBlank { "en" } }
}
@ -71,6 +87,10 @@ class TmdbSettingsDataStore @Inject constructor(
store().edit { it[useDetailsKey] = enabled }
}
suspend fun setUseReleaseDates(enabled: Boolean) {
store().edit { it[useReleaseDatesKey] = enabled }
}
suspend fun setUseCredits(enabled: Boolean) {
store().edit { it[useCreditsKey] = enabled }
}
@ -90,4 +110,8 @@ class TmdbSettingsDataStore @Inject constructor(
suspend fun setUseMoreLikeThis(enabled: Boolean) {
store().edit { it[useMoreLikeThisKey] = enabled }
}
suspend fun setUseCollections(enabled: Boolean) {
store().edit { it[useCollectionsKey] = enabled }
}
}

View file

@ -0,0 +1,120 @@
package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TrackPreferenceDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "track_preference"
private const val SUB_TYPE = "sub_type"
private const val SUB_LANG = "sub_lang"
private const val SUB_NAME = "sub_name"
private const val SUB_TRACK_ID = "sub_track_id"
private const val SUB_ADDON_ID = "sub_addon_id"
private const val SUB_ADDON_URL = "sub_addon_url"
private const val SUB_ADDON_NAME = "sub_addon_name"
private const val AUDIO_LANG = "audio_lang"
private const val AUDIO_NAME = "audio_name"
private const val AUDIO_TRACK_ID = "audio_track_id"
}
private fun store() = factory.get(profileManager.activeProfileId.value, FEATURE)
private fun key(field: String, contentId: String) =
stringPreferencesKey("$field|$contentId")
suspend fun save(contentId: String, pref: PersistedTrackPreference) {
store().edit { prefs ->
fun set(field: String, value: String?) {
val k = key(field, contentId)
if (value != null) prefs[k] = value else prefs.remove(k)
}
set(SUB_TYPE, pref.subtitleType)
set(SUB_LANG, pref.subtitleLanguage)
set(SUB_NAME, pref.subtitleName)
set(SUB_TRACK_ID, pref.subtitleTrackId)
set(SUB_ADDON_ID, pref.addonSubtitleId)
set(SUB_ADDON_URL, pref.addonSubtitleUrl)
set(SUB_ADDON_NAME, pref.addonSubtitleAddonName)
set(AUDIO_LANG, pref.audioLanguage)
set(AUDIO_NAME, pref.audioName)
set(AUDIO_TRACK_ID, pref.audioTrackId)
}
}
suspend fun load(contentId: String): PersistedTrackPreference? {
val prefs = store().data.first()
val subType = prefs[key(SUB_TYPE, contentId)]
val audioLang = prefs[key(AUDIO_LANG, contentId)]
val audioName = prefs[key(AUDIO_NAME, contentId)]
val audioTrackId = prefs[key(AUDIO_TRACK_ID, contentId)]
if (subType == null && audioLang == null && audioName == null && audioTrackId == null) return null
return PersistedTrackPreference(
subtitleType = subType,
subtitleLanguage = prefs[key(SUB_LANG, contentId)],
subtitleName = prefs[key(SUB_NAME, contentId)],
subtitleTrackId = prefs[key(SUB_TRACK_ID, contentId)],
addonSubtitleId = prefs[key(SUB_ADDON_ID, contentId)],
addonSubtitleUrl = prefs[key(SUB_ADDON_URL, contentId)],
addonSubtitleAddonName = prefs[key(SUB_ADDON_NAME, contentId)],
audioLanguage = audioLang,
audioName = audioName,
audioTrackId = audioTrackId
)
}
}
data class PersistedTrackPreference(
val subtitleType: String?,
val subtitleLanguage: String?,
val subtitleName: String?,
val subtitleTrackId: String?,
val addonSubtitleId: String?,
val addonSubtitleUrl: String?,
val addonSubtitleAddonName: String?,
val audioLanguage: String?,
val audioName: String?,
val audioTrackId: String?
)
internal fun PersistedTrackPreference.toTrackPreference(): com.nuvio.tv.ui.screens.player.PlayerRuntimeController.TrackPreference? {
val audio = if (audioLanguage != null || audioName != null || audioTrackId != null) {
com.nuvio.tv.ui.screens.player.PlayerRuntimeController.RememberedTrackSelection(
language = audioLanguage,
name = audioName,
trackId = audioTrackId
)
} else null
val subtitle = when (subtitleType) {
"INTERNAL" -> com.nuvio.tv.ui.screens.player.PlayerRuntimeController.RememberedSubtitleSelection.Internal(
track = com.nuvio.tv.ui.screens.player.PlayerRuntimeController.RememberedTrackSelection(
language = subtitleLanguage,
name = subtitleName,
trackId = subtitleTrackId
)
)
"ADDON" -> com.nuvio.tv.ui.screens.player.PlayerRuntimeController.RememberedSubtitleSelection.Addon(
id = addonSubtitleId ?: "",
url = addonSubtitleUrl ?: "",
language = subtitleLanguage ?: "",
addonName = addonSubtitleAddonName ?: ""
)
"DISABLED" -> com.nuvio.tv.ui.screens.player.PlayerRuntimeController.RememberedSubtitleSelection.Disabled
else -> null
}
if (audio == null && subtitle == null) return null
return com.nuvio.tv.ui.screens.player.PlayerRuntimeController.TrackPreference(
audio = audio,
subtitle = subtitle
)
}

View file

@ -28,7 +28,7 @@ class TrailerSettingsDataStore @Inject constructor(
val settings: Flow<TrailerSettings> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
TrailerSettings(
enabled = prefs[enabledKey] ?: false,
enabled = prefs[enabledKey] ?: true,
delaySeconds = prefs[delaySecondsKey] ?: 7
)
}
@ -44,6 +44,6 @@ class TrailerSettingsDataStore @Inject constructor(
}
data class TrailerSettings(
val enabled: Boolean = false,
val enabled: Boolean = true,
val delaySeconds: Int = 7
)

View file

@ -1,26 +1,25 @@
package com.nuvio.tv.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.remote.dto.trakt.TraktDeviceCodeResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktTokenResponseDto
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.traktAuthDataStore: DataStore<Preferences> by preferencesDataStore(
name = "trakt_auth_store"
)
private const val TRAKT_ACCESS_TOKEN_MAX_LIFETIME_SECONDS = 86_400
internal fun normalizeTraktTokenLifetimeSeconds(expiresIn: Int): Int {
if (expiresIn <= 0) return TRAKT_ACCESS_TOKEN_MAX_LIFETIME_SECONDS
return expiresIn.coerceAtMost(TRAKT_ACCESS_TOKEN_MAX_LIFETIME_SECONDS)
}
data class TraktAuthState(
val accessToken: String? = null,
@ -41,10 +40,15 @@ data class TraktAuthState(
}
@Singleton
@OptIn(ExperimentalCoroutinesApi::class)
class TraktAuthDataStore @Inject constructor(
@ApplicationContext private val context: Context,
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "trakt_auth_store"
}
private val accessTokenKey = stringPreferencesKey("access_token")
private val refreshTokenKey = stringPreferencesKey("refresh_token")
private val tokenTypeKey = stringPreferencesKey("token_type")
@ -60,44 +64,44 @@ class TraktAuthDataStore @Inject constructor(
private val expiresAtKey = longPreferencesKey("expires_at")
private val pollIntervalKey = intPreferencesKey("poll_interval")
val state: Flow<TraktAuthState> = context.traktAuthDataStore.data.map { preferences ->
TraktAuthState(
accessToken = preferences[accessTokenKey],
refreshToken = preferences[refreshTokenKey],
tokenType = preferences[tokenTypeKey],
createdAt = preferences[createdAtKey],
expiresIn = preferences[expiresInKey],
username = preferences[usernameKey],
userSlug = preferences[userSlugKey],
deviceCode = preferences[deviceCodeKey],
userCode = preferences[userCodeKey],
verificationUrl = preferences[verificationUrlKey],
expiresAt = preferences[expiresAtKey],
pollInterval = preferences[pollIntervalKey]
)
private fun store(profileId: Int = profileManager.activeProfileId.value) =
factory.get(profileId, FEATURE)
val state: Flow<TraktAuthState> = profileManager.activeProfileId.flatMapLatest { profileId ->
store(profileId).data.map { preferences ->
TraktAuthState(
accessToken = preferences[accessTokenKey],
refreshToken = preferences[refreshTokenKey],
tokenType = preferences[tokenTypeKey],
createdAt = preferences[createdAtKey],
expiresIn = preferences[expiresInKey]?.let(::normalizeTraktTokenLifetimeSeconds),
username = preferences[usernameKey],
userSlug = preferences[userSlugKey],
deviceCode = preferences[deviceCodeKey],
userCode = preferences[userCodeKey],
verificationUrl = preferences[verificationUrlKey],
expiresAt = preferences[expiresAtKey],
pollInterval = preferences[pollIntervalKey]
)
}
}
val isAuthenticated: Flow<Boolean> = state.map { it.isAuthenticated }
val isEffectivelyAuthenticated: Flow<Boolean> = combine(
isAuthenticated,
profileManager.activeProfileId
) { authenticated, profileId ->
authenticated && profileId == 1
}
val isEffectivelyAuthenticated: Flow<Boolean> = isAuthenticated
suspend fun saveToken(token: TraktTokenResponseDto) {
context.traktAuthDataStore.edit { preferences ->
store().edit { preferences ->
preferences[accessTokenKey] = token.accessToken
preferences[refreshTokenKey] = token.refreshToken
preferences[tokenTypeKey] = token.tokenType
preferences[createdAtKey] = token.createdAt
preferences[expiresInKey] = token.expiresIn
preferences[expiresInKey] = normalizeTraktTokenLifetimeSeconds(token.expiresIn)
}
}
suspend fun saveUser(username: String?, userSlug: String?) {
context.traktAuthDataStore.edit { preferences ->
store().edit { preferences ->
if (username.isNullOrBlank()) {
preferences.remove(usernameKey)
} else {
@ -113,7 +117,7 @@ class TraktAuthDataStore @Inject constructor(
suspend fun saveDeviceFlow(data: TraktDeviceCodeResponseDto) {
val now = System.currentTimeMillis()
context.traktAuthDataStore.edit { preferences ->
store().edit { preferences ->
preferences[deviceCodeKey] = data.deviceCode
preferences[userCodeKey] = data.userCode
preferences[verificationUrlKey] = data.verificationUrl
@ -123,13 +127,13 @@ class TraktAuthDataStore @Inject constructor(
}
suspend fun updatePollInterval(seconds: Int) {
context.traktAuthDataStore.edit { preferences ->
store().edit { preferences ->
preferences[pollIntervalKey] = seconds
}
}
suspend fun clearDeviceFlow() {
context.traktAuthDataStore.edit { preferences ->
store().edit { preferences ->
preferences.remove(deviceCodeKey)
preferences.remove(userCodeKey)
preferences.remove(verificationUrlKey)
@ -139,7 +143,7 @@ class TraktAuthDataStore @Inject constructor(
}
suspend fun clearAuth() {
context.traktAuthDataStore.edit { preferences ->
store().edit { preferences ->
preferences.remove(accessTokenKey)
preferences.remove(refreshTokenKey)
preferences.remove(tokenTypeKey)
@ -155,4 +159,3 @@ class TraktAuthDataStore @Inject constructor(
}
}
}

View file

@ -3,15 +3,29 @@ package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class WatchProgressSource {
TRAKT,
NUVIO_SYNC;
companion object {
fun fromStorage(value: String?): WatchProgressSource {
return entries.firstOrNull { it.name == value } ?: TRAKT
}
}
}
@Singleton
@OptIn(ExperimentalCoroutinesApi::class)
class TraktSettingsDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
@ -21,6 +35,8 @@ class TraktSettingsDataStore @Inject constructor(
const val CONTINUE_WATCHING_DAYS_CAP_ALL = 0
const val DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60
const val DEFAULT_SHOW_UNAIRED_NEXT_UP = true
const val DEFAULT_SHOW_META_COMMENTS = true
val DEFAULT_WATCH_PROGRESS_SOURCE = WatchProgressSource.TRAKT
const val MIN_CONTINUE_WATCHING_DAYS_CAP = 7
const val MAX_CONTINUE_WATCHING_DAYS_CAP = 365
}
@ -31,6 +47,8 @@ class TraktSettingsDataStore @Inject constructor(
private val continueWatchingDaysCapKey = intPreferencesKey("continue_watching_days_cap")
private val dismissedNextUpKeysKey = stringSetPreferencesKey("dismissed_next_up_keys")
private val showUnairedNextUpKey = booleanPreferencesKey("show_unaired_next_up")
private val showMetaCommentsKey = booleanPreferencesKey("show_meta_comments")
private val watchProgressSourceKey = stringPreferencesKey("watch_progress_source")
val continueWatchingDaysCap: Flow<Int> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
@ -52,6 +70,18 @@ class TraktSettingsDataStore @Inject constructor(
}
}
val showMetaComments: Flow<Boolean> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
prefs[showMetaCommentsKey] ?: DEFAULT_SHOW_META_COMMENTS
}
}
val watchProgressSource: Flow<WatchProgressSource> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
WatchProgressSource.fromStorage(prefs[watchProgressSourceKey])
}
}
suspend fun setContinueWatchingDaysCap(days: Int) {
store().edit { prefs ->
prefs[continueWatchingDaysCapKey] = normalizeContinueWatchingDaysCap(days)
@ -74,9 +104,33 @@ class TraktSettingsDataStore @Inject constructor(
}
}
suspend fun removeDismissedNextUpKeysForContent(contentId: String) {
if (contentId.isBlank()) return
val prefix = "${contentId.trim()}|"
store().edit { prefs ->
val current = prefs[dismissedNextUpKeysKey] ?: emptySet()
val filtered = current.filterNot { it.startsWith(prefix) }
if (filtered.size != current.size) {
prefs[dismissedNextUpKeysKey] = filtered.toSet()
}
}
}
suspend fun setShowUnairedNextUp(enabled: Boolean) {
store().edit { prefs ->
prefs[showUnairedNextUpKey] = enabled
}
}
suspend fun setShowMetaComments(enabled: Boolean) {
store().edit { prefs ->
prefs[showMetaCommentsKey] = enabled
}
}
suspend fun setWatchProgressSource(source: WatchProgressSource) {
store().edit { prefs ->
prefs[watchProgressSourceKey] = source.name
}
}
}

View file

@ -32,13 +32,6 @@ class WatchProgressPreferences @Inject constructor(
private val gson = Gson()
private val watchProgressKey = stringPreferencesKey("watch_progress_map")
// Maximum items to keep in continue watching
private val maxItems = 50
private val maxEpisodesPerContent = 25
private val maxStoredEntries = 300
/**
* Get all watch progress items, sorted by last watched (most recent first)
* For series, only returns the series-level entry (not individual episode entries)
@ -49,23 +42,22 @@ class WatchProgressPreferences @Inject constructor(
val json = preferences[watchProgressKey] ?: "{}"
val allItems = parseProgressMap(json)
val contentLevelEntries = allItems.entries
.filter { (key, progress) -> key == progress.contentId }
.associate { it.value.contentId to it.value }
.toMutableMap()
val latestEpisodeFallbacks = allItems.values
// Group all entries by contentId and pick the most recently watched.
// When lastWatched is equal (e.g. batch mark-as-watched), prefer the highest season/episode.
val latestByContent = allItems.values
.groupBy { it.contentId }
.mapValues { (_, items) -> items.maxByOrNull { it.lastWatched } }
latestEpisodeFallbacks.forEach { (contentId, latest) ->
if (contentLevelEntries[contentId] == null && latest != null) {
contentLevelEntries[contentId] = latest
.mapValues { (_, items) ->
items.maxWithOrNull(
compareBy<WatchProgress> { it.lastWatched }
.thenBy { it.season ?: 0 }
.thenBy { it.episode ?: 0 }
)
}
}
.values
.filterNotNull()
contentLevelEntries.values
.sortedByDescending { it.lastWatched }
val result = latestByContent.sortedByDescending { it.lastWatched }
result
}
}
@ -92,7 +84,10 @@ class WatchProgressPreferences @Inject constructor(
return store().data.map { preferences ->
val json = preferences[watchProgressKey] ?: "{}"
val map = parseProgressMap(json)
map[contentId]
// Try direct key first (movies), then find latest episode entry (series).
map[contentId] ?: map.values
.filter { it.contentId == contentId }
.maxByOrNull { it.lastWatched }
}
}
@ -129,19 +124,19 @@ class WatchProgressPreferences @Inject constructor(
store().edit { preferences ->
val json = preferences[watchProgressKey] ?: "{}"
val map = parseProgressMap(json).toMutableMap()
val key = createKey(progress)
map[key] = progress
upsertProgressEntries(map, listOf(progress))
if (progress.season != null && progress.episode != null) {
val seriesKey = progress.contentId
val existingSeriesProgress = map[seriesKey]
if (existingSeriesProgress == null || progress.lastWatched > existingSeriesProgress.lastWatched) {
map[seriesKey] = progress.copy(videoId = progress.videoId)
}
}
val pruned = pruneOldItems(map)
preferences[watchProgressKey] = gson.toJson(pruned)
}
}
suspend fun saveProgressBatch(progressList: List<WatchProgress>) {
if (progressList.isEmpty()) return
store().edit { preferences ->
val json = preferences[watchProgressKey] ?: "{}"
val map = parseProgressMap(json).toMutableMap()
upsertProgressEntries(map, progressList)
val pruned = pruneOldItems(map)
preferences[watchProgressKey] = gson.toJson(pruned)
}
@ -182,17 +177,69 @@ class WatchProgressPreferences @Inject constructor(
}
}
/**
* Remove watch progress for multiple episodes in a single DataStore transaction.
*/
suspend fun removeProgressBatch(contentId: String, episodes: List<Pair<Int, Int>>) {
if (episodes.isEmpty()) return
store().edit { preferences ->
val json = preferences[watchProgressKey] ?: "{}"
val map = parseProgressMap(json).toMutableMap()
for ((season, episode) in episodes) {
map.remove("${contentId}_s${season}e${episode}")
}
map.remove(contentId)
Log.d(TAG, "removeProgressBatch contentId=$contentId removed=${episodes.size} episodes entriesAfter=${map.size}")
preferences[watchProgressKey] = gson.toJson(map)
}
}
/**
* Mark content as completed
*/
suspend fun markAsCompleted(progress: WatchProgress) {
// If the incoming duration is a dummy sentinel (≤ 1ms), check for an
// existing local entry with a real duration from prior playback.
// This creates a proper completed entry that syncs correctly cross-device.
val effectiveDuration = if (progress.duration <= 1L) {
val key = createKey(progress)
val existing = getAllRawEntries()[key]
existing?.duration?.takeIf { it > 1L } ?: progress.duration
} else {
progress.duration
}
val completedProgress = progress.copy(
position = progress.duration,
position = effectiveDuration,
duration = effectiveDuration,
lastWatched = System.currentTimeMillis()
)
saveProgress(completedProgress)
}
/**
* Mark multiple items as completed in a single DataStore transaction.
*/
suspend fun markAsCompletedBatch(progressList: List<WatchProgress>) {
if (progressList.isEmpty()) return
val rawEntries = getAllRawEntries()
val now = System.currentTimeMillis()
val completed = progressList.map { progress ->
val effectiveDuration = if (progress.duration <= 1L) {
val key = createKey(progress)
rawEntries[key]?.duration?.takeIf { it > 1L } ?: progress.duration
} else {
progress.duration
}
progress.copy(
position = effectiveDuration,
duration = effectiveDuration,
lastWatched = now
)
}
saveProgressBatch(completed)
}
/**
* Returns the raw keyWatchProgress map from DataStore (for sync push).
*/
@ -224,7 +271,7 @@ class WatchProgressPreferences @Inject constructor(
for ((key, remote) in remoteEntries) {
val existing = local[key]
if (existing == null || remote.lastWatched > existing.lastWatched) {
local[key] = remote
local[key] = mergeDisplayMetadata(remote, existing)
Log.d("WatchProgressPrefs", " merged key=$key (existing=${existing != null})")
} else {
Log.d("WatchProgressPrefs", " skipped key=$key (local is newer)")
@ -246,7 +293,10 @@ class WatchProgressPreferences @Inject constructor(
Log.w(TAG, "replaceWithRemoteEntries: remote empty while local has ${current.size} entries; preserving local watch progress")
return@edit
}
val pruned = pruneOldItems(remoteEntries.toMutableMap())
val merged = remoteEntries.mapValues { (key, remote) ->
mergeDisplayMetadata(remote, current[key])
}.toMutableMap()
val pruned = pruneOldItems(merged)
Log.d("WatchProgressPrefs", "replaceWithRemoteEntries: ${pruned.size} entries after prune, writing to DataStore")
preferences[watchProgressKey] = gson.toJson(pruned)
}
@ -269,6 +319,37 @@ class WatchProgressPreferences @Inject constructor(
}
}
private fun upsertProgressEntries(
map: MutableMap<String, WatchProgress>,
progressList: List<WatchProgress>
) {
progressList.forEach { progress ->
val key = createKey(progress)
map[key] = progress
// Remove legacy series-level mirror key if this is an episode entry.
// Mirror keys caused race conditions with stale progress data.
if (progress.season != null && progress.episode != null) {
val seriesKey = progress.contentId
if (seriesKey != key && map.containsKey(seriesKey)) {
map.remove(seriesKey)
}
}
}
}
private fun mergeDisplayMetadata(remote: WatchProgress, existing: WatchProgress?): WatchProgress {
if (existing == null) return remote
return remote.copy(
name = existing.name.takeIf { it.isNotBlank() } ?: remote.name.takeIf { it.isNotBlank() } ?: existing.name,
poster = existing.poster ?: remote.poster,
backdrop = existing.backdrop ?: remote.backdrop,
logo = existing.logo ?: remote.logo,
episodeTitle = existing.episodeTitle ?: remote.episodeTitle,
addonBaseUrl = remote.addonBaseUrl ?: existing.addonBaseUrl
)
}
private fun parseProgressMap(json: String): Map<String, WatchProgress> {
return try {
// Parse entry-by-entry so one malformed value doesn't wipe the entire map.
@ -374,75 +455,6 @@ class WatchProgressPreferences @Inject constructor(
}
private fun pruneOldItems(map: MutableMap<String, WatchProgress>): Map<String, WatchProgress> {
if (map.isEmpty()) return map
val latestByContent = map.values
.groupBy { it.contentId }
.mapValues { (_, items) -> items.maxOf { it.lastWatched } }
val inProgressContentIds = map.values
.asSequence()
.filter { it.isInProgress() }
.map { it.contentId }
.toSet()
val sortedContentIds = latestByContent
.entries
.sortedByDescending { it.value }
.map { it.key }
val keepContentIds = buildList {
sortedContentIds
.filter { it in inProgressContentIds }
.forEach { add(it) }
sortedContentIds
.filter { it !in inProgressContentIds }
.forEach { add(it) }
}
.distinct()
.take(maxItems)
val keepContentIdSet = keepContentIds.toSet()
val filteredByContent = map.filterValues { it.contentId in keepContentIdSet }
val boundedByContent = mutableMapOf<String, WatchProgress>()
keepContentIds.forEach { contentId ->
val entriesForContent = filteredByContent.filterValues { it.contentId == contentId }
// Keep canonical content-level record when present.
entriesForContent[contentId]?.let { boundedByContent[contentId] = it }
val recentEpisodeEntries = entriesForContent
.filterKeys { it != contentId }
.entries
.sortedByDescending { it.value.lastWatched }
.take(maxEpisodesPerContent)
recentEpisodeEntries.forEach { (key, value) ->
boundedByContent[key] = value
}
}
if (boundedByContent.size <= maxStoredEntries) return boundedByContent
val pinnedContentKeys = keepContentIds.filter { boundedByContent.containsKey(it) }.toSet()
val remainingSlots = (maxStoredEntries - pinnedContentKeys.size).coerceAtLeast(0)
val limited = mutableMapOf<String, WatchProgress>()
pinnedContentKeys.forEach { key ->
boundedByContent[key]?.let { limited[key] = it }
}
boundedByContent.entries
.asSequence()
.filter { (key, _) -> key !in pinnedContentKeys }
.sortedByDescending { (_, value) -> value.lastWatched }
.take(remainingSlots)
.forEach { (key, value) ->
limited[key] = value
}
return limited
return map
}
}

View file

@ -29,7 +29,7 @@ class WatchedItemsPreferences @Inject constructor(
private val gson = Gson()
private val watchedItemsKey = stringSetPreferencesKey("watched_items")
private val allItems: Flow<List<WatchedItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
internal val allItems: Flow<List<WatchedItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { preferences ->
val raw = preferences[watchedItemsKey] ?: emptySet()
raw.mapNotNull { json ->
@ -56,6 +56,13 @@ class WatchedItemsPreferences @Inject constructor(
}
}
fun getWatchedEpisodesWithTimestamps(contentId: String): Flow<Map<Pair<Int, Int>, Long>> {
return allItems.map { items ->
items.filter { it.contentId == contentId && it.season != null && it.episode != null }
.associate { (it.season!! to it.episode!!) to it.watchedAt }
}
}
suspend fun markAsWatched(item: WatchedItem) {
store().edit { preferences ->
val current = preferences[watchedItemsKey] ?: emptySet()
@ -72,6 +79,22 @@ class WatchedItemsPreferences @Inject constructor(
}
}
suspend fun markAsWatchedBatch(items: List<WatchedItem>) {
if (items.isEmpty()) return
store().edit { preferences ->
val current = preferences[watchedItemsKey] ?: emptySet()
val newKeys = items.map { Triple(it.contentId, it.season, it.episode) }.toSet()
val filtered = current.filterNot { json ->
runCatching {
gson.fromJson(json, WatchedItem::class.java)
}.getOrNull()?.let { existing ->
Triple(existing.contentId, existing.season, existing.episode) in newKeys
} ?: false
}
preferences[watchedItemsKey] = filtered.toSet() + items.map { gson.toJson(it) }
}
}
suspend fun unmarkAsWatched(contentId: String, season: Int? = null, episode: Int? = null) {
store().edit { preferences ->
val current = preferences[watchedItemsKey] ?: emptySet()
@ -88,6 +111,22 @@ class WatchedItemsPreferences @Inject constructor(
}
}
suspend fun unmarkAsWatchedBatch(contentId: String, episodes: List<Pair<Int, Int>>) {
if (episodes.isEmpty()) return
val removeKeys = episodes.map { (s, e) -> Triple(contentId, s, e) }.toSet()
store().edit { preferences ->
val current = preferences[watchedItemsKey] ?: emptySet()
val filtered = current.filterNot { json ->
runCatching {
gson.fromJson(json, WatchedItem::class.java)
}.getOrNull()?.let { existing ->
Triple(existing.contentId, existing.season, existing.episode) in removeKeys
} ?: false
}
preferences[watchedItemsKey] = filtered.toSet()
}
}
suspend fun getAllItems(): List<WatchedItem> {
return allItems.first()
}
@ -126,4 +165,10 @@ class WatchedItemsPreferences @Inject constructor(
.toSet()
}
}
suspend fun clearAll() {
store().edit { preferences ->
preferences.remove(watchedItemsKey)
}
}
}

View file

@ -0,0 +1,115 @@
package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Shared holder for fully-watched series IDs derived from the CW pipeline.
* Updated by HomeViewModel, observed by any screen that needs series watched badges.
* Persisted per profile so badges appear instantly on cold start.
*/
@Singleton
class WatchedSeriesStateHolder @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "watched_series_cache"
private val KEY = stringSetPreferencesKey("fully_watched_ids")
private val TIMESTAMPS_KEY = stringPreferencesKey("validated_timestamps")
private const val VALIDATION_TTL_MS = 12L * 60 * 60 * 1000 // 12 hours
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val gson = Gson()
private val _fullyWatchedSeriesIds = MutableStateFlow<Set<String>>(emptySet())
val fullyWatchedSeriesIds: StateFlow<Set<String>> = _fullyWatchedSeriesIds.asStateFlow()
/** Per-series validation timestamps (contentId → epochMs). */
@Volatile
private var validatedAtMap: Map<String, Long> = emptyMap()
private var loaded = false
private fun store() = factory.get(profileManager.activeProfileId.value, FEATURE)
fun loadFromDisk() {
if (loaded) return
scope.launch {
val prefs = store().data.first()
val persisted = prefs[KEY] ?: emptySet()
validatedAtMap = parseTimestamps(prefs[TIMESTAMPS_KEY])
if (_fullyWatchedSeriesIds.value.isEmpty() && persisted.isNotEmpty()) {
_fullyWatchedSeriesIds.value = persisted
}
loaded = true
}
}
fun update(ids: Set<String>) {
_fullyWatchedSeriesIds.value = ids
scope.launch {
store().edit { prefs -> prefs[KEY] = ids }
}
}
/**
* Update badge IDs and mark the given series as freshly validated.
*/
fun updateWithValidation(ids: Set<String>, validatedIds: Set<String>) {
_fullyWatchedSeriesIds.value = ids
val now = System.currentTimeMillis()
val updated = validatedAtMap.toMutableMap()
validatedIds.forEach { updated[it] = now }
// Prune entries no longer in the badge set.
updated.keys.retainAll(ids)
validatedAtMap = updated
scope.launch {
store().edit { prefs ->
prefs[KEY] = ids
prefs[TIMESTAMPS_KEY] = gson.toJson(updated)
}
}
}
/**
* Returns true if the given series was validated within the TTL window.
*/
fun isSeriesValidationFresh(contentId: String): Boolean {
val ts = validatedAtMap[contentId] ?: return false
return System.currentTimeMillis() - ts < VALIDATION_TTL_MS
}
/**
* Filters the given set to only those series that need re-validation
* (not validated within TTL).
*/
fun filterStaleIds(ids: Set<String>): Set<String> {
val now = System.currentTimeMillis()
return ids.filter { id ->
val ts = validatedAtMap[id] ?: return@filter true
now - ts >= VALIDATION_TTL_MS
}.toSet()
}
private fun parseTimestamps(json: String?): Map<String, Long> {
if (json.isNullOrBlank()) return emptyMap()
return runCatching {
val type = object : TypeToken<Map<String, Long>>() {}.type
gson.fromJson<Map<String, Long>>(json, type) ?: emptyMap()
}.getOrDefault(emptyMap())
}
}

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