Merge branch 'tapframe:main' into feature/ani-skip

This commit is contained in:
paregi12 2026-01-06 18:29:04 +05:30 committed by GitHub
commit ccad48fbb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2095 additions and 961 deletions

View file

@ -237,10 +237,10 @@ class KSPlayerView: UIView {
options.registerRemoteControll = false
// PERFORMANCE OPTIMIZATION: Buffer durations for smooth high bitrate playback
// preferredForwardBufferDuration = 3.0s: Slightly increased to reduce rebuffering during playback
options.preferredForwardBufferDuration = 1.0
// maxBufferDuration = 120.0s: Increased to allow the player to cache more content ahead of time (2 minutes)
options.maxBufferDuration = 120.0
// preferredForwardBufferDuration = 5.0s: Increased to prevent stalling on network hiccups
options.preferredForwardBufferDuration = 5.0
// maxBufferDuration = 300.0s: Increased to allow 5 minutes of cache ahead
options.maxBufferDuration = 300.0
// Enable "second open" to relax startup/seek buffering thresholds (already enabled)
options.isSecondOpen = true

View file

@ -1902,6 +1902,30 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-skia (2.4.14):
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React
- React-callinvoker
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-slider (5.1.1):
- hermes-engine
- RCTRequired
@ -2822,6 +2846,7 @@ DEPENDENCIES:
- react-native-google-cast (from `../node_modules/react-native-google-cast`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- "react-native-skia (from `../node_modules/@shopify/react-native-skia`)"
- "react-native-slider (from `../node_modules/@react-native-community/slider`)"
- react-native-video (from `../node_modules/react-native-video`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
@ -3059,6 +3084,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/netinfo"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-skia:
:path: "../node_modules/@shopify/react-native-skia"
react-native-slider:
:path: "../node_modules/@react-native-community/slider"
react-native-video:
@ -3148,13 +3175,13 @@ EXTERNAL SOURCES:
CHECKOUT OPTIONS:
DisplayCriteria:
:commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3
:commit: a7cddd878f557afa6a1f2faad9d756949406adde
:git: https://github.com/kingslay/KSPlayer.git
FFmpegKit:
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
:git: https://github.com/kingslay/FFmpegKit.git
KSPlayer:
:commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3
:commit: a7cddd878f557afa6a1f2faad9d756949406adde
:git: https://github.com/kingslay/KSPlayer.git
Libass:
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
@ -3254,6 +3281,7 @@ SPEC CHECKSUMS:
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
react-native-skia: 268f183f849742e9da216743ee234bd7ad81c69b
react-native-slider: f954578344106f0a732a4358ce3a3e11015eb6e1
react-native-video: f5982e21efab0dc356d92541a8a9e19581307f58
React-NativeModulesApple: a9464983ccc0f66f45e93558671f60fc7536e438

View file

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

View file

@ -161,7 +161,7 @@ public class ReactExoplayerView extends FrameLayout implements
AdEvent.AdEventListener,
AdErrorEvent.AdErrorListener {
public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 1;
public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 0.5;
public static final double DEFAULT_MIN_BUFFER_MEMORY_RESERVE = 0;
private static final String TAG = "ReactExoplayerView";
@ -244,7 +244,7 @@ public class ReactExoplayerView extends FrameLayout implements
private BufferingStrategy.BufferingStrategyEnum bufferingStrategy;
private boolean disableDisconnectError;
private boolean preventsDisplaySleepDuringVideoPlayback = true;
private float mProgressUpdateInterval = 250.0f;
private float mProgressUpdateInterval = 1000.0f;
protected boolean playInBackground = false;
private boolean mReportBandwidth = false;
private boolean controls = false;
@ -270,6 +270,7 @@ public class ReactExoplayerView extends FrameLayout implements
private final String instanceId = String.valueOf(UUID.randomUUID());
private CmcdConfiguration.Factory cmcdConfigurationFactory;
private static final ExecutorService SHARED_EXECUTOR = Executors.newSingleThreadExecutor();
public void setCmcdConfigurationFactory(CmcdConfiguration.Factory factory) {
this.cmcdConfigurationFactory = factory;
@ -642,6 +643,8 @@ public class ReactExoplayerView extends FrameLayout implements
}
private void initializePlayer() {
drmRetryCount = 0;
hasDrmFailed = false;
disableCache = ReactNativeVideoManager.Companion.getInstance().shouldDisableCache(source);
ReactExoplayerView self = this;
@ -664,10 +667,14 @@ public class ReactExoplayerView extends FrameLayout implements
PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder,
this.enterPictureInPictureOnLeave);
}
if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) {
long requestedCacheSize = source.getBufferConfig().getCacheSize();
long MAX_SAFE_CACHE_SIZE = 100L * 1024 * 1024;
long effectiveCacheSize = Math.min(requestedCacheSize, MAX_SAFE_CACHE_SIZE);
if (!source.isLocalAssetFile() && !source.isAsset() && effectiveCacheSize > 0) {
RNVSimpleCache.INSTANCE.setSimpleCache(
this.getContext(),
source.getBufferConfig().getCacheSize());
effectiveCacheSize
);
useCache = true;
} else {
useCache = false;
@ -677,8 +684,7 @@ public class ReactExoplayerView extends FrameLayout implements
exoPlayerView.invalidateAspectRatio();
// DRM session manager creation must be done on a different thread to prevent
// crashes so we start a new thread
ExecutorService es = Executors.newSingleThreadExecutor();
es.execute(() -> {
SHARED_EXECUTOR.execute(() -> {
// DRM initialization must run on a different thread
if (viewHasDropped && runningSource == source) {
return;
@ -876,13 +882,10 @@ public class ReactExoplayerView extends FrameLayout implements
MediaSource mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, videoSource);
// wait for player to be set
while (player == null) {
try {
wait();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
DebugLog.e(TAG, ex.toString());
}
if (player == null) {
DebugLog.w(TAG, "Player not ready yet, aborting source initialization");
playerNeedsSource = true;
return;
}
boolean haveResumePosition = resumeWindow != C.INDEX_UNSET;
@ -1515,8 +1518,7 @@ public class ReactExoplayerView extends FrameLayout implements
ArrayList<Track> textTracks = getTextTrackInfo();
if (source.getContentStartTime() != -1) {
ExecutorService es = Executors.newSingleThreadExecutor();
es.execute(() -> {
SHARED_EXECUTOR.execute(() -> {
// To prevent ANRs caused by getVideoTrackInfo we run this on a different thread
// and notify the player only when we're done
ArrayList<VideoTrack> videoTracks = getVideoTrackInfoFromManifest();
@ -1629,12 +1631,11 @@ public class ReactExoplayerView extends FrameLayout implements
// conditions
@WorkerThread
private ArrayList<VideoTrack> getVideoTrackInfoFromManifest(int retryCount) {
ExecutorService es = Executors.newSingleThreadExecutor();
final DataSource dataSource = this.mediaDataSourceFactory.createDataSource();
final Uri sourceUri = source.getUri();
final long startTime = source.getContentStartTime() * 1000 - 100; // s -> ms with 100ms offset
Future<ArrayList<VideoTrack>> result = es.submit(new Callable() {
Future<ArrayList<VideoTrack>> result = SHARED_EXECUTOR.submit(new Callable<ArrayList<VideoTrack>>() {
final DataSource ds = dataSource;
final Uri uri = sourceUri;
final long startTimeUs = startTime * 1000; // ms -> us
@ -1684,7 +1685,6 @@ public class ReactExoplayerView extends FrameLayout implements
if (results == null && retryCount < 1) {
return this.getVideoTrackInfoFromManifest(++retryCount);
}
es.shutdown();
return results;
} catch (Exception e) {
DebugLog.w(TAG, "error in getVideoTrackInfoFromManifest handling request:" + e.getMessage());
@ -1993,13 +1993,15 @@ public class ReactExoplayerView extends FrameLayout implements
if (!hasDrmFailed) {
// When DRM fails to reach the app level certificate server it will fail with a
// source error so we assume that it is DRM related and try one more time
hasDrmFailed = true;
playerNeedsSource = true;
updateResumePosition();
initializePlayer();
setPlayWhenReady(true);
return;
}
if (drmRetryCount < 1) {
drmRetryCount++;
hasDrmFailed = true;
playerNeedsSource = true;
updateResumePosition();
initializePlayer();
setPlayWhenReady(true);
return;
}
break;
default:
break;

64
package-lock.json generated
View file

@ -29,6 +29,7 @@
"@react-navigation/stack": "^7.2.10",
"@sentry/react-native": "^7.6.0",
"@shopify/flash-list": "^2.2.0",
"@shopify/react-native-skia": "^2.4.14",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.12.2",
@ -3642,6 +3643,33 @@
"react-native": "*"
}
},
"node_modules/@shopify/react-native-skia": {
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.14.tgz",
"integrity": "sha512-zFxjAQbfrdOxoNJoaOCZQzZliuAWXjFkrNZv2PtofG2RAUPWIxWmk2J/oOROpTwXgkmh1JLvFp3uONccTXUthQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"canvaskit-wasm": "0.40.0",
"react-reconciler": "0.31.0"
},
"bin": {
"setup-skia-web": "scripts/setup-canvaskit.js"
},
"peerDependencies": {
"react": ">=19.0",
"react-native": ">=0.78",
"react-native-reanimated": ">=3.19.1"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
},
"react-native-reanimated": {
"optional": true
}
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@ -4304,6 +4332,12 @@
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz",
"integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==",
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
@ -5158,6 +5192,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvaskit-wasm": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz",
"integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==",
"license": "BSD-3-Clause",
"dependencies": {
"@webgpu/types": "0.1.21"
}
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
@ -11307,6 +11350,27 @@
"async-limiter": "~1.0.0"
}
},
"node_modules/react-reconciler": {
"version": "0.31.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz",
"integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.25.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/react-reconciler/node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",

View file

@ -29,6 +29,7 @@
"@react-navigation/stack": "^7.2.10",
"@sentry/react-native": "^7.6.0",
"@shopify/flash-list": "^2.2.0",
"@shopify/react-native-skia": "^2.4.14",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.12.2",

View file

@ -12,9 +12,9 @@ import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { BlurView as ExpoBlurView } from 'expo-blur';
import Animated, {
useSharedValue,
useAnimatedStyle,
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
Easing
@ -44,36 +44,36 @@ interface TabletStreamsLayoutProps {
metadata?: any;
type: string;
currentEpisode?: any;
// Movie logo props
movieLogoError: boolean;
setMovieLogoError: (error: boolean) => void;
// Stream-related props
streamsEmpty: boolean;
selectedProvider: string;
filterItems: Array<{ id: string; name: string; }>;
handleProviderChange: (provider: string) => void;
activeFetchingScrapers: string[];
// Loading states
isAutoplayWaiting: boolean;
autoplayTriggered: boolean;
showNoSourcesError: boolean;
showInitialLoading: boolean;
showStillFetching: boolean;
// Stream rendering props
sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null>;
renderSectionHeader: ({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => React.ReactElement;
handleStreamPress: (stream: Stream) => void;
openAlert: (title: string, message: string) => void;
// Settings and theme
settings: any;
currentTheme: any;
colors: any;
// Other props
navigation: RootStackNavigationProp;
insets: any;
@ -122,19 +122,19 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
hasStremioStreamProviders,
}) => {
const styles = React.useMemo(() => createStyles(colors), [colors]);
// Animation values for backdrop entrance
const backdropOpacity = useSharedValue(0);
const backdropScale = useSharedValue(1.05);
const [backdropLoaded, setBackdropLoaded] = useState(false);
const [backdropError, setBackdropError] = useState(false);
// Animation values for content panels
const leftPanelOpacity = useSharedValue(0);
const leftPanelTranslateX = useSharedValue(-30);
const rightPanelOpacity = useSharedValue(0);
const rightPanelTranslateX = useSharedValue(30);
// Get the backdrop source - prioritize episode thumbnail, then show backdrop, then poster
// For episodes without thumbnails, use show's backdrop instead of poster
const backdropSource = React.useMemo(() => {
@ -148,7 +148,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
backdropError
});
}
// If episodeImage failed to load, skip it and use backdrop
if (backdropError && episodeImage && episodeImage !== metadata?.poster) {
if (__DEV__) console.log('[TabletStreamsLayout] Episode thumbnail failed, falling back to backdrop');
@ -157,26 +157,55 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
return { uri: bannerImage };
}
}
// If episodeImage exists and is not the same as poster, use it (real episode thumbnail)
if (episodeImage && episodeImage !== metadata?.poster && !backdropError) {
if (__DEV__) console.log('[TabletStreamsLayout] Using episode thumbnail:', episodeImage);
return { uri: episodeImage };
}
// If episodeImage is the same as poster (fallback case), prioritize backdrop
if (bannerImage) {
if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop:', bannerImage);
return { uri: bannerImage };
}
// No fallback to poster images
if (__DEV__) console.log('[TabletStreamsLayout] No backdrop source found');
return undefined;
}, [episodeImage, bannerImage, metadata?.poster, backdropError]);
// Animate backdrop when it loads, or animate content immediately if no backdrop
useEffect(() => {
if (backdropSource?.uri && !backdropLoaded && !backdropError) {
const timeoutId = setTimeout(() => {
leftPanelOpacity.value = withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
});
leftPanelTranslateX.value = withTiming(0, {
duration: 600,
easing: Easing.out(Easing.cubic)
});
rightPanelOpacity.value = withDelay(200, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
rightPanelTranslateX.value = withDelay(200, withTiming(0, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
}, 1000);
return () => clearTimeout(timeoutId);
}
}, [backdropSource?.uri, backdropLoaded, backdropError]);
useEffect(() => {
if (backdropSource?.uri && backdropLoaded) {
// Animate backdrop first
@ -188,7 +217,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
duration: 1000,
easing: Easing.out(Easing.cubic)
});
// Animate content panels with delay after backdrop starts loading
leftPanelOpacity.value = withDelay(300, withTiming(1, {
duration: 600,
@ -198,7 +227,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
duration: 600,
easing: Easing.out(Easing.cubic)
}));
rightPanelOpacity.value = withDelay(500, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
@ -217,7 +246,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
duration: 600,
easing: Easing.out(Easing.cubic)
});
rightPanelOpacity.value = withDelay(200, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
@ -228,7 +257,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
}));
}
}, [backdropSource?.uri, backdropLoaded, backdropError]);
// Reset animation when episode changes
useEffect(() => {
backdropOpacity.value = 0;
@ -240,28 +269,28 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
setBackdropLoaded(false);
setBackdropError(false);
}, [episodeImage]);
// Animated styles for backdrop
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
transform: [{ scale: backdropScale.value }],
}));
// Animated styles for content panels
const leftPanelAnimatedStyle = useAnimatedStyle(() => ({
opacity: leftPanelOpacity.value,
transform: [{ translateX: leftPanelTranslateX.value }],
}));
const rightPanelAnimatedStyle = useAnimatedStyle(() => ({
opacity: rightPanelOpacity.value,
transform: [{ translateX: rightPanelTranslateX.value }],
}));
const handleBackdropLoad = () => {
setBackdropLoaded(true);
};
const handleBackdropError = () => {
if (__DEV__) console.log('[TabletStreamsLayout] Backdrop image failed to load:', backdropSource?.uri);
setBackdropError(true);
@ -294,8 +323,8 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>
{isAutoplayWaiting ? 'Finding best stream for autoplay...' :
showStillFetching ? 'Still fetching streams…' :
'Finding available streams...'}
showStillFetching ? 'Still fetching streams…' :
'Finding available streams...'}
</Text>
</View>
);
@ -311,7 +340,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
// Flatten sections into a single list with header items
type ListItem = { type: 'header'; title: string; addonId: string } | { type: 'stream'; stream: Stream; index: number };
const flatListData: ListItem[] = [];
sections
.filter(Boolean)
@ -327,7 +356,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
if (item.type === 'header') {
return renderSectionHeader({ section: { title: item.title, addonId: item.addonId } });
}
const stream = item.stream;
return (
<StreamCard
@ -414,7 +443,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
locations={[0, 0.5, 1]}
style={styles.tabletFullScreenGradient}
/>
{/* Left Panel: Movie Logo/Episode Info */}
<Animated.View style={[styles.tabletLeftPanel, leftPanelAnimatedStyle]}>
{type === 'movie' && metadata ? (

File diff suppressed because it is too large Load diff

View file

@ -489,9 +489,18 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
};
}, []);
// Add effect to scroll to selected season
// Track previous season to only scroll when it actually changes
const previousSeasonRef = React.useRef<number | null>(null);
// Add effect to scroll to selected season (only when season changes, not on every groupedEpisodes update)
useEffect(() => {
if (selectedSeason && seasonScrollViewRef.current && Object.keys(groupedEpisodes).length > 0) {
// Only scroll if the season actually changed (not just groupedEpisodes update)
if (previousSeasonRef.current === selectedSeason) {
return; // Season didn't change, don't scroll
}
previousSeasonRef.current = selectedSeason;
// Find the index of the selected season
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
const selectedIndex = seasons.findIndex(season => season === selectedSeason);

View file

@ -0,0 +1,172 @@
import React, { useEffect } from 'react';
import { useWindowDimensions, StyleSheet } from 'react-native';
import {
Blur,
BlurMask,
Canvas,
Circle,
Extrapolate,
interpolate,
interpolateColors,
LinearGradient,
Path,
RadialGradient,
usePathValue,
vec,
} from '@shopify/react-native-skia';
import {
Easing,
useSharedValue,
withRepeat,
withTiming,
SharedValue,
useDerivedValue,
} from 'react-native-reanimated';
import {
type Point3D,
N_POINTS,
ALL_SHAPES,
ALL_SHAPES_X,
ALL_SHAPES_Y,
ALL_SHAPES_Z,
} from './shapes';
// Color palettes for each shape (gradient stops)
const COLOR_STOPS = [
{ start: '#FFD700', end: '#FF6B00' }, // Star: Gold → Orange
{ start: '#7C3AED', end: '#EC4899' }, // Plugin: Purple → Pink
{ start: '#00D9FF', end: '#0EA5E9' }, // Search: Cyan → Blue
{ start: '#FF006E', end: '#FB7185' }, // Heart: Pink → Rose
];
// ============ 3D UTILITIES ============
const rotateX = (p: Point3D, angle: number): Point3D => {
'worklet';
return {
x: p.x,
y: p.y * Math.cos(angle) - p.z * Math.sin(angle),
z: p.y * Math.sin(angle) + p.z * Math.cos(angle),
};
};
const rotateY = (p: Point3D, angle: number): Point3D => {
'worklet';
return {
x: p.x * Math.cos(angle) + p.z * Math.sin(angle),
y: p.y,
z: -p.x * Math.sin(angle) + p.z * Math.cos(angle),
};
};
interface ShapeAnimationProps {
scrollX: SharedValue<number>;
}
export const ShapeAnimation: React.FC<ShapeAnimationProps> = ({ scrollX }) => {
const iTime = useSharedValue(0.0);
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
// Pre-compute input range once
const shapeWidth = windowWidth;
const inputRange = ALL_SHAPES.map((_, idx) => shapeWidth * idx);
// Single optimized path - all 4 shapes batched into one Skia Path
const morphPath = usePathValue(skPath => {
'worklet';
const centerX = windowWidth / 2;
const centerY = windowHeight * 0.65;
const distance = 350;
for (let i = 0; i < N_POINTS; i++) {
// Interpolate 3D coordinates between all shapes
const baseX = interpolate(scrollX.value, inputRange, ALL_SHAPES_X[i], Extrapolate.CLAMP);
const baseY = interpolate(scrollX.value, inputRange, ALL_SHAPES_Y[i], Extrapolate.CLAMP);
const baseZ = interpolate(scrollX.value, inputRange, ALL_SHAPES_Z[i], Extrapolate.CLAMP);
// Apply 3D rotation
let p: Point3D = { x: baseX, y: baseY, z: baseZ };
p = rotateX(p, 0.2); // Fixed X tilt
p = rotateY(p, iTime.value); // Animated Y rotation
// Perspective projection
const scale = distance / (distance + p.z);
const screenX = centerX + p.x * scale;
const screenY = centerY + p.y * scale;
// Depth-based radius for parallax effect
const radius = Math.max(0.2, 0.5 * scale);
skPath.addCircle(screenX, screenY, radius);
}
return skPath;
});
// Interpolate gradient colors based on scroll position
const gradientColors = useDerivedValue(() => {
const startColors = COLOR_STOPS.map(c => c.start);
const endColors = COLOR_STOPS.map(c => c.end);
const start = interpolateColors(scrollX.value, inputRange, startColors);
const end = interpolateColors(scrollX.value, inputRange, endColors);
return [start, end];
});
// Rotation animation - infinite loop
useEffect(() => {
iTime.value = 0;
iTime.value = withRepeat(
withTiming(2 * Math.PI, {
duration: 12000,
easing: Easing.linear,
}),
-1,
false
);
}, []);
return (
<Canvas
style={[
styles.canvas,
{
width: windowWidth,
height: windowHeight,
},
]}>
{/* Background glow */}
<Circle
cx={windowWidth / 2}
cy={windowHeight * 0.65}
r={windowWidth * 0.6}>
<RadialGradient
c={vec(windowWidth / 2, windowHeight * 0.65)}
r={windowWidth * 0.6}
colors={['#ffffff20', 'transparent']}
/>
<Blur blur={60} />
</Circle>
{/* Single optimized path with interpolated gradient */}
<Path path={morphPath} style="fill">
<LinearGradient
start={vec(0, windowHeight * 0.4)}
end={vec(windowWidth, windowHeight * 0.9)}
colors={gradientColors}
/>
<BlurMask blur={5} style="solid" />
</Path>
</Canvas>
);
};
const styles = StyleSheet.create({
canvas: {
position: 'absolute',
top: 0,
left: 0,
},
});
export default ShapeAnimation;

View file

@ -0,0 +1,8 @@
// Fixed number of points for all shapes (for interpolation)
// Lower = better FPS, 1000 points is a good balance for smooth 60fps
export const N_POINTS = 1000;
export const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2;
// Normalize a shape to have height TARGET_HEIGHT
export const TARGET_HEIGHT = 200;

View file

@ -0,0 +1,35 @@
import { N_POINTS } from './constants';
import { type Point3D } from './types';
import { fibonacciPoint, normalizeShape, scaleShape } from './utils';
// Cube - map sphere to cube
const generateCubePoints = (size: number): Point3D[] => {
const points: Point3D[] = [];
const s = size / 2;
for (let i = 0; i < N_POINTS; i++) {
const { theta, phi } = fibonacciPoint(i, N_POINTS);
// Point on unit sphere
const sx = Math.sin(phi) * Math.cos(theta);
const sy = Math.sin(phi) * Math.sin(theta);
const sz = Math.cos(phi);
// Map to cube (cube mapping)
const absX = Math.abs(sx);
const absY = Math.abs(sy);
const absZ = Math.abs(sz);
const max = Math.max(absX, absY, absZ);
points.push({
x: (sx / max) * s,
y: (sy / max) * s,
z: (sz / max) * s,
});
}
return points;
};
export const CUBE_POINTS = scaleShape(
normalizeShape(generateCubePoints(150)),
0.75,
);

View file

@ -0,0 +1,35 @@
import { N_POINTS } from './constants';
import { type Point3D } from './types';
import { fibonacciPoint, normalizeShape } from './utils';
// Heart - starts from Fibonacci sphere, deforms into heart
const generateHeartPoints = (scale: number): Point3D[] => {
const points: Point3D[] = [];
for (let i = 0; i < N_POINTS; i++) {
const { theta, phi } = fibonacciPoint(i, N_POINTS);
// Use same angular coordinates as sphere
const u = theta;
const v = phi;
const sinV = Math.sin(v);
// Heart surface with same angular correspondence
const hx = sinV * (15 * Math.sin(u) - 4 * Math.sin(3 * u));
const hz = 8 * Math.cos(v);
const hy =
sinV *
(15 * Math.cos(u) -
5 * Math.cos(2 * u) -
2 * Math.cos(3 * u) -
Math.cos(4 * u));
points.push({
x: hx * scale * 0.06,
y: -hy * scale * 0.06,
z: hz * scale * 0.06,
});
}
return points;
};
export const HEART_POINTS = normalizeShape(generateHeartPoints(120));

View file

@ -0,0 +1,28 @@
export { type Point3D } from './types';
export { N_POINTS } from './constants';
import { N_POINTS } from './constants';
import { STAR_POINTS } from './star'; // Welcome to Nuvio
import { PLUGIN_POINTS } from './plugin'; // Powerful Addons
import { SEARCH_POINTS } from './search'; // Smart Discovery
import { HEART_POINTS } from './heart'; // Your Library (favorites)
// Array of all shapes - ordered to match onboarding slides
export const ALL_SHAPES = [
STAR_POINTS, // Slide 1: Welcome
PLUGIN_POINTS, // Slide 2: Addons
SEARCH_POINTS, // Slide 3: Discovery
HEART_POINTS, // Slide 4: Library
];
export const POINTS_ARRAY = new Array(N_POINTS).fill(0);
export const ALL_SHAPES_X = POINTS_ARRAY.map((_, pointIndex) =>
ALL_SHAPES.map(shape => shape[pointIndex].x),
);
export const ALL_SHAPES_Y = POINTS_ARRAY.map((_, pointIndex) =>
ALL_SHAPES.map(shape => shape[pointIndex].y),
);
export const ALL_SHAPES_Z = POINTS_ARRAY.map((_, pointIndex) =>
ALL_SHAPES.map(shape => shape[pointIndex].z),
);

View file

@ -0,0 +1,96 @@
import { N_POINTS } from './constants';
import { type Point3D } from './types';
import { normalizeShape, scaleShape } from './utils';
// LEGO Brick shape - perfectly represents "Addons" or "Plugins"
const generateLegoPoints = (): Point3D[] => {
const points: Point3D[] = [];
// Dimensions
const width = 160;
const depth = 80;
const height = 48;
const studRadius = 12;
const studHeight = 16;
// Distribute points: 70% body, 30% studs
const bodyPoints = Math.floor(N_POINTS * 0.7);
const studPoints = N_POINTS - bodyPoints;
const pointsPerStud = Math.floor(studPoints / 8); // 8 studs (2x4 brick)
// 1. Main Brick Body (Rectangular Prism)
for (let i = 0; i < bodyPoints; i++) {
const t1 = Math.random();
const t2 = Math.random();
const t3 = Math.random();
// Create density concentration on edges for better definition
const x = (Math.pow(t1, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * width / 2;
const y = (Math.pow(t2, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * height / 2;
const z = (Math.pow(t3, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * depth / 2;
// Snapping to faces to make it look solid
const face = Math.floor(Math.random() * 6);
let px = x, py = y, pz = z;
if (face === 0) px = width / 2;
else if (face === 1) px = -width / 2;
else if (face === 2) py = height / 2;
else if (face === 3) py = -height / 2;
else if (face === 4) pz = depth / 2;
else if (face === 5) pz = -depth / 2;
// Add some random noise inside/surface
if (Math.random() > 0.8) {
points.push({ x: x, y: y, z: z });
} else {
points.push({ x: px, y: py, z: pz });
}
}
// 2. Studs (Cylinders on top)
// 2x4 Grid positions
const studPositions = [
{ x: -width * 0.375, z: -depth * 0.25 }, { x: -width * 0.125, z: -depth * 0.25 },
{ x: width * 0.125, z: -depth * 0.25 }, { x: width * 0.375, z: -depth * 0.25 },
{ x: -width * 0.375, z: depth * 0.25 }, { x: -width * 0.125, z: depth * 0.25 },
{ x: width * 0.125, z: depth * 0.25 }, { x: width * 0.375, z: depth * 0.25 },
];
studPositions.forEach((pos, studIndex) => {
for (let j = 0; j < pointsPerStud; j++) {
const angle = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * studRadius;
// Top face of stud
if (Math.random() > 0.5) {
points.push({
x: pos.x + r * Math.cos(angle),
y: -height / 2 - studHeight, // Top
z: pos.z + r * Math.sin(angle),
});
} else {
// Side of stud
const h = Math.random() * studHeight;
points.push({
x: pos.x + studRadius * Math.cos(angle),
y: -height / 2 - h,
z: pos.z + studRadius * Math.sin(angle),
});
}
}
});
// FILL remaining points to prevent "undefined" errors
while (points.length < N_POINTS) {
points.push(points[points.length - 1] || { x: 0, y: 0, z: 0 });
}
// Slice to guarantee exact count
return points.slice(0, N_POINTS);
};
export const PLUGIN_POINTS = scaleShape(
normalizeShape(generateLegoPoints()),
0.4,
);

View file

@ -0,0 +1,57 @@
import { N_POINTS } from './constants';
import { type Point3D } from './types';
import { normalizeShape, scaleShape } from './utils';
// Magnifying glass/search shape - for "Discovery" page
const generateSearchPoints = (radius: number): Point3D[] => {
const points: Point3D[] = [];
const handleLength = radius * 0.8;
const handleWidth = radius * 0.15;
// Split points between ring and handle
const ringPoints = Math.floor(N_POINTS * 0.7);
const handlePoints = N_POINTS - ringPoints;
// Create the circular ring (lens)
for (let i = 0; i < ringPoints; i++) {
const t = i / ringPoints;
const mainAngle = t * Math.PI * 2;
const tubeAngle = (i * 17) % 20 / 20 * Math.PI * 2; // Distribute around tube
const tubeRadius = radius * 0.12;
const centerRadius = radius;
const cx = centerRadius * Math.cos(mainAngle);
const cy = centerRadius * Math.sin(mainAngle);
points.push({
x: cx + tubeRadius * Math.cos(tubeAngle) * Math.cos(mainAngle),
y: cy + tubeRadius * Math.cos(tubeAngle) * Math.sin(mainAngle),
z: tubeRadius * Math.sin(tubeAngle),
});
}
// Create the handle
for (let i = 0; i < handlePoints; i++) {
const t = i / handlePoints;
const handleAngle = (i * 13) % 12 / 12 * Math.PI * 2;
// Handle position (extends from bottom-right of ring)
const handleStart = radius * 0.7;
const hx = handleStart + t * handleLength;
const hy = handleStart + t * handleLength;
points.push({
x: hx + handleWidth * Math.cos(handleAngle) * 0.3,
y: hy + handleWidth * Math.cos(handleAngle) * 0.3,
z: handleWidth * Math.sin(handleAngle),
});
}
return points;
};
export const SEARCH_POINTS = scaleShape(
normalizeShape(generateSearchPoints(80)),
1.0,
);

View file

@ -0,0 +1,19 @@
import { N_POINTS } from './constants';
import { type Point3D } from './types';
import { fibonacciPoint, normalizeShape } from './utils';
// Sphere
const generateSpherePoints = (radius: number): Point3D[] => {
const points: Point3D[] = [];
for (let i = 0; i < N_POINTS; i++) {
const { theta, phi } = fibonacciPoint(i, N_POINTS);
points.push({
x: radius * Math.sin(phi) * Math.cos(theta),
y: radius * Math.sin(phi) * Math.sin(theta),
z: radius * Math.cos(phi),
});
}
return points;
};
export const SPHERE_POINTS = normalizeShape(generateSpherePoints(100));

View file

@ -0,0 +1,31 @@
import { N_POINTS } from './constants';
import { type Point3D } from './types';
import { fibonacciPoint, normalizeShape, scaleShape } from './utils';
// Star shape - for "Welcome" page
const generateStarPoints = (outerRadius: number, innerRadius: number): Point3D[] => {
const points: Point3D[] = [];
const numPoints = 5; // 5-pointed star
for (let i = 0; i < N_POINTS; i++) {
const { theta, phi, t } = fibonacciPoint(i, N_POINTS);
// Create star cross-section
const angle = theta * numPoints;
const radiusFactor = 0.5 + 0.5 * Math.cos(angle);
const radius = innerRadius + (outerRadius - innerRadius) * radiusFactor;
const sinPhi = Math.sin(phi);
points.push({
x: radius * sinPhi * Math.cos(theta),
y: radius * sinPhi * Math.sin(theta),
z: radius * Math.cos(phi) * 0.3, // Flatten z for star shape
});
}
return points;
};
export const STAR_POINTS = scaleShape(
normalizeShape(generateStarPoints(100, 40)),
0.9,
);

View file

@ -0,0 +1,48 @@
import { N_POINTS } from './constants';
import { type Point3D } from './types';
import { normalizeShape, scaleShape } from './utils';
// Torus - uniform grid with same index correspondence
const generateTorusPoints = (major: number, minor: number): Point3D[] => {
const points: Point3D[] = [];
// Calculate approximate grid dimensions
const ratio = major / minor;
const minorSegments = Math.round(Math.sqrt(N_POINTS / ratio));
const majorSegments = Math.round(N_POINTS / minorSegments);
let idx = 0;
for (let i = 0; i < majorSegments && idx < N_POINTS; i++) {
const u = (i / majorSegments) * Math.PI * 2;
for (let j = 0; j < minorSegments && idx < N_POINTS; j++) {
const v = (j / minorSegments) * Math.PI * 2;
points.push({
x: (major + minor * Math.cos(v)) * Math.cos(u),
y: (major + minor * Math.cos(v)) * Math.sin(u),
z: minor * Math.sin(v),
});
idx++;
}
}
// Fill missing points if necessary
while (points.length < N_POINTS) {
const t = points.length / N_POINTS;
const u = t * Math.PI * 2 * majorSegments;
const v = t * Math.PI * 2 * minorSegments;
points.push({
x: (major + minor * Math.cos(v)) * Math.cos(u),
y: (major + minor * Math.cos(v)) * Math.sin(u),
z: minor * Math.sin(v),
});
}
return points.slice(0, N_POINTS);
};
export const TORUS_POINTS = scaleShape(
normalizeShape(generateTorusPoints(50, 25)),
1.2,
);

View file

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

View file

@ -0,0 +1,54 @@
import { GOLDEN_RATIO, TARGET_HEIGHT } from './constants';
import { type Point3D } from './types';
// Generate Fibonacci points on unit sphere, then map to shape
export const fibonacciPoint = (
i: number,
total: number,
): { theta: number; phi: number; t: number } => {
const t = i / total;
const theta = (2 * Math.PI * i) / GOLDEN_RATIO;
const phi = Math.acos(1 - 2 * t);
return { theta, phi, t };
};
export const normalizeShape = (points: Point3D[]): Point3D[] => {
// Find min/max for each axis
let minX = Infinity,
maxX = -Infinity;
let minY = Infinity,
maxY = -Infinity;
let minZ = Infinity,
maxZ = -Infinity;
for (const p of points) {
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y);
maxY = Math.max(maxY, p.y);
minZ = Math.min(minZ, p.z);
maxZ = Math.max(maxZ, p.z);
}
// Calculate current dimensions
const currentHeight = maxY - minY;
const scale = TARGET_HEIGHT / currentHeight;
// Center and scale uniformly
const centerY = (minY + maxY) / 2;
return points.map(p => ({
x: (p.x - (minX + maxX) / 2) * scale,
y: (p.y - centerY) * scale,
z: (p.z - (minZ + maxZ) / 2) * scale,
}));
};
// Additional scale for single shape
export const scaleShape = (points: Point3D[], factor: number): Point3D[] => {
return points.map(p => ({
x: p.x * factor,
y: p.y * factor,
z: p.z * factor,
}));
};

View file

@ -115,6 +115,13 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
/* Animations - State & Refs */
const [showBackwardSign, setShowBackwardSign] = React.useState(false);
const [showForwardSign, setShowForwardSign] = React.useState(false);
const [previewTime, setPreviewTime] = React.useState(currentTime);
const isSlidingRef = React.useRef(false);
React.useEffect(() => {
if (!isSlidingRef.current) {
setPreviewTime(currentTime);
}
}, [currentTime]);
/* Separate Animations for Each Button */
const backwardPressAnim = React.useRef(new Animated.Value(0)).current;
@ -280,10 +287,22 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
}}
minimumValue={0}
maximumValue={duration || 1}
value={currentTime}
onValueChange={onSliderValueChange}
onSlidingStart={onSlidingStart}
onSlidingComplete={onSlidingComplete}
value={previewTime}
onValueChange={(v) => setPreviewTime(v)}
onSlidingStart={() => {
isSlidingRef.current = true;
onSlidingStart();
}}
onSlidingComplete={(v) => {
isSlidingRef.current = false;
setPreviewTime(v);
onSlidingComplete(v);
}}
minimumTrackTintColor={currentTheme.colors.primary}
maximumTrackTintColor={currentTheme.colors.mediumEmphasis}
thumbTintColor={Platform.OS === 'android' ? currentTheme.colors.white : undefined}
@ -608,4 +627,4 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
);
};
export default PlayerControls;
export default PlayerControls;

View file

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

View file

@ -550,7 +550,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId);
const movieDetails = await tmdbService.getMovieDetails(
tmdbId,
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'
);
if (movieDetails) {
const imdbId = movieDetails.imdb_id || movieDetails.external_ids?.imdb_id;
@ -634,7 +634,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
try {
const showDetails = await tmdbService.getTVShowDetails(
parseInt(tmdbId),
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'
);
if (showDetails) {
// OPTIMIZATION: Fetch external IDs, credits, and logo in parallel
@ -824,9 +824,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Store addon logo before TMDB enrichment overwrites it
const addonLogo = (finalMetadata as any).logo;
// If localization is enabled, merge TMDB localized text (name/overview) before first render
// If localization is enabled AND title/description enrichment is enabled, merge TMDB localized text (name/overview) before first render
try {
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata && settings.tmdbEnrichTitleDescription) {
const tmdbSvc = TMDBService.getInstance();
let finalTmdbId: number | null = tmdbId;
if (!finalTmdbId) {
@ -857,8 +857,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
finalMetadata = {
...finalMetadata,
name: finalMetadata.name || localized.title,
description: finalMetadata.description || localized.overview,
name: localized.title || finalMetadata.name,
description: localized.overview || finalMetadata.description,
movieDetails: movieDetailsObj,
...(productionInfo.length > 0 && { networks: productionInfo }),
};
@ -894,8 +894,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
finalMetadata = {
...finalMetadata,
name: finalMetadata.name || localized.name,
description: finalMetadata.description || localized.overview,
name: localized.name || finalMetadata.name,
description: localized.overview || finalMetadata.description,
tvDetails,
...(productionInfo.length > 0 && { networks: productionInfo }),
};
@ -909,14 +909,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Centralized logo fetching logic
try {
if (addonLogo) {
finalMetadata.logo = addonLogo;
if (__DEV__) {
console.log('[useMetadata] Using addon-provided logo:', { hasLogo: true });
}
// Check both master switch AND granular logos setting
} else if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) {
// Only use TMDB logos when both enrichment AND logos option are ON
// When TMDB enrichment AND logos are enabled, prioritize TMDB logo over addon logo
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) {
const tmdbService = TMDBService.getInstance();
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
const contentType = type === 'series' ? 'tv' : 'movie';
@ -932,23 +926,26 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (tmdbIdForLogo) {
const logoUrl = await tmdbService.getContentLogo(contentType, tmdbIdForLogo, preferredLanguage);
finalMetadata.logo = logoUrl || undefined; // TMDB logo or undefined (no addon fallback)
// Use TMDB logo if found, otherwise fall back to addon logo
finalMetadata.logo = logoUrl || addonLogo || undefined;
if (__DEV__) {
console.log('[useMetadata] Logo fetch result:', {
contentType,
tmdbIdForLogo,
preferredLanguage,
logoUrl: !!logoUrl,
tmdbLogoFound: !!logoUrl,
usingAddonFallback: !logoUrl && !!addonLogo,
enrichmentEnabled: true
});
}
} else {
finalMetadata.logo = undefined; // No TMDB ID means no logo
if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, will show text title');
// No TMDB ID, fall back to addon logo
finalMetadata.logo = addonLogo || undefined;
if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, using addon logo');
}
} else {
// When enrichment or logos is OFF, keep addon logo or undefined
finalMetadata.logo = finalMetadata.logo || undefined;
// When enrichment or logos is OFF, use addon logo
finalMetadata.logo = addonLogo || finalMetadata.logo || undefined;
if (__DEV__) {
console.log('[useMetadata] TMDB logo enrichment disabled, using addon logo:', {
hasAddonLogo: !!finalMetadata.logo,
@ -1125,10 +1122,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Fetch season posters from TMDB only if enrichment AND season posters are enabled
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichSeasonPosters) {
try {
const lang = settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}` : 'en';
const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null);
if (tmdbIdToUse) {
if (!tmdbId) setTmdbId(tmdbIdToUse);
const showDetails = await tmdbService.getTVShowDetails(tmdbIdToUse);
const showDetails = await tmdbService.getTVShowDetails(tmdbIdToUse, lang);
if (showDetails?.seasons) {
Object.keys(groupedAddonEpisodes).forEach(seasonStr => {
const seasonNum = parseInt(seasonStr, 10);
@ -1156,29 +1154,40 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
try {
const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null);
if (tmdbIdToUse) {
const lang = `${settings.tmdbLanguagePreference || 'en'}-US`;
// Use just the language code (e.g., 'ar', not 'ar-US') for TMDB API
const lang = settings.tmdbLanguagePreference || 'en';
const seasons = Object.keys(groupedAddonEpisodes).map(Number);
for (const seasonNum of seasons) {
const seasonEps = groupedAddonEpisodes[seasonNum];
// Parallel fetch a reasonable batch (limit concurrency implicitly by season)
const localized = await Promise.all(
seasonEps.map(async ep => {
try {
const data = await tmdbService.getEpisodeDetails(Number(tmdbIdToUse), seasonNum, ep.episode_number, lang);
if (data) {
// Fetch all seasons in parallel (much faster than fetching each episode individually)
const seasonPromises = seasons.map(async seasonNum => {
try {
// getSeasonDetails returns all episodes for a season in one call
const seasonData = await tmdbService.getSeasonDetails(Number(tmdbIdToUse), seasonNum, undefined, lang);
if (seasonData && seasonData.episodes) {
// Create a map of episode number -> localized data for fast lookup
const localizedMap = new Map<number, { name: string; overview: string }>();
for (const ep of seasonData.episodes) {
localizedMap.set(ep.episode_number, { name: ep.name, overview: ep.overview });
}
// Merge localized data into addon episodes
groupedAddonEpisodes[seasonNum] = groupedAddonEpisodes[seasonNum].map(ep => {
const localized = localizedMap.get(ep.episode_number);
if (localized) {
return {
...ep,
name: data.name || ep.name,
overview: data.overview || ep.overview,
name: localized.name || ep.name,
overview: localized.overview || ep.overview,
};
}
} catch { }
return ep;
})
);
groupedAddonEpisodes[seasonNum] = localized;
}
if (__DEV__) logger.log('[useMetadata] merged localized episode names/overviews from TMDB');
return ep;
});
}
} catch { }
});
await Promise.all(seasonPromises);
if (__DEV__) logger.log('[useMetadata] merged localized episode names/overviews from TMDB (batch)');
}
} catch (e) {
if (__DEV__) console.log('[useMetadata] failed to merge localized episode text', e);
@ -1264,13 +1273,14 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Fallback to TMDB if no addon episodes
logger.log('📺 No addon episodes found, falling back to TMDB');
const lang = settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}` : 'en';
const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
if (tmdbIdResult) {
setTmdbId(tmdbIdResult);
const [allEpisodes, showDetails] = await Promise.all([
tmdbService.getAllEpisodes(tmdbIdResult),
tmdbService.getTVShowDetails(tmdbIdResult)
tmdbService.getAllEpisodes(tmdbIdResult, lang),
tmdbService.getTVShowDetails(tmdbIdResult, lang)
]);
const transformedEpisodes: GroupedEpisodes = {};
@ -2038,7 +2048,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setLoadingRecommendations(true);
try {
const tmdbService = TMDBService.getInstance();
const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId));
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId), lang);
// Convert TMDB results to StreamingContent format (simplified)
const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({
@ -2056,7 +2067,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} finally {
setLoadingRecommendations(false);
}
}, [tmdbId, type]);
}, [tmdbId, type, settings.useTmdbLocalizedMetadata, settings.tmdbLanguagePreference]);
// Fetch TMDB ID if needed and then recommendations
useEffect(() => {

View file

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

View file

@ -53,7 +53,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
if (Platform.OS === 'ios') {
StatusBar.setHidden(false);
}
} catch {}
} catch { }
}, [colors.darkBackground]);
const handleBack = useCallback(() => {
@ -97,22 +97,22 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
/>
);
const SettingItem = ({
title,
description,
value,
onValueChange,
isLast = false
}: {
title: string;
description: string;
value: boolean;
const SettingItem = ({
title,
description,
value,
onValueChange,
isLast = false
}: {
title: string;
description: string;
value: boolean;
onValueChange: (value: boolean) => void;
isLast?: boolean;
}) => (
<View style={[
styles.settingItem,
{
{
borderBottomColor: isLast ? 'transparent' : colors.border,
}
]}>
@ -159,10 +159,10 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
>
@ -170,13 +170,13 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>
Continue Watching
</Text>
{/* Content */}
<ScrollView
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
@ -184,22 +184,98 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
<View style={styles.section}>
<Text style={styles.sectionTitle}>PLAYBACK BEHAVIOR</Text>
<View style={styles.settingsCard}>
<SettingItem
title="Use Cached Streams"
description="When enabled, clicking Continue Watching items will open the player directly using previously played streams. When disabled, opens a content screen instead."
value={settings.useCachedStreams}
onValueChange={(value) => handleUpdateSetting('useCachedStreams', value)}
isLast={!settings.useCachedStreams}
/>
{!settings.useCachedStreams && (
<SettingItem
title="Open Metadata Screen"
description="When cached streams are disabled, open the Metadata screen instead of the Streams screen. This shows content details and allows manual stream selection."
value={settings.openMetadataScreenWhenCacheDisabled}
onValueChange={(value) => handleUpdateSetting('openMetadataScreenWhenCacheDisabled', value)}
isLast={true}
title="Use Cached Streams"
description="When enabled, clicking Continue Watching items will open the player directly using previously played streams. When disabled, opens a content screen instead."
value={settings.useCachedStreams}
onValueChange={(value) => handleUpdateSetting('useCachedStreams', value)}
isLast={!settings.useCachedStreams}
/>
)}
{!settings.useCachedStreams && (
<SettingItem
title="Open Metadata Screen"
description="When cached streams are disabled, open the Metadata screen instead of the Streams screen. This shows content details and allows manual stream selection."
value={settings.openMetadataScreenWhenCacheDisabled}
onValueChange={(value) => handleUpdateSetting('openMetadataScreenWhenCacheDisabled', value)}
isLast={true}
/>
)}
</View>
</View>
{/* Card Appearance Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>CARD APPEARANCE</Text>
<View style={styles.settingsCard}>
<View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
Card Style
</Text>
<Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}>
Choose how Continue Watching items appear on the home screen
</Text>
<View style={styles.cardStyleOptionsContainer}>
<TouchableOpacity
style={[
styles.cardStyleOption,
{
backgroundColor: settings.continueWatchingCardStyle === 'wide' ? colors.primary : colors.elevation1,
borderColor: settings.continueWatchingCardStyle === 'wide' ? colors.primary : colors.border,
}
]}
onPress={() => handleUpdateSetting('continueWatchingCardStyle', 'wide')}
activeOpacity={0.7}
>
<View style={styles.cardPreviewWide}>
<View style={[styles.cardPreviewImage, { backgroundColor: colors.mediumGray }]} />
<View style={styles.cardPreviewContent}>
<View style={[styles.cardPreviewLine, { backgroundColor: colors.highEmphasis, width: '70%' }]} />
<View style={[styles.cardPreviewLine, { backgroundColor: colors.mediumEmphasis, width: '50%', height: 6 }]} />
<View style={[styles.cardPreviewProgress, { backgroundColor: colors.elevation2 }]}>
<View style={[styles.cardPreviewProgressFill, { backgroundColor: colors.primary, width: '60%' }]} />
</View>
</View>
</View>
<Text style={[
styles.cardStyleLabel,
{ color: settings.continueWatchingCardStyle === 'wide' ? colors.white : colors.highEmphasis }
]}>
Wide
</Text>
{settings.continueWatchingCardStyle === 'wide' && (
<MaterialIcons name="check-circle" size={18} color={colors.white} style={styles.cardStyleCheck} />
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.cardStyleOption,
{
backgroundColor: settings.continueWatchingCardStyle === 'poster' ? colors.primary : colors.elevation1,
borderColor: settings.continueWatchingCardStyle === 'poster' ? colors.primary : colors.border,
}
]}
onPress={() => handleUpdateSetting('continueWatchingCardStyle', 'poster')}
activeOpacity={0.7}
>
<View style={styles.cardPreviewPoster}>
<View style={[styles.cardPreviewPosterImage, { backgroundColor: colors.mediumGray }]} />
<View style={[styles.cardPreviewPosterProgress, { backgroundColor: colors.elevation2 }]}>
<View style={[styles.cardPreviewProgressFill, { backgroundColor: colors.primary, width: '45%' }]} />
</View>
</View>
<Text style={[
styles.cardStyleLabel,
{ color: settings.continueWatchingCardStyle === 'poster' ? colors.white : colors.highEmphasis }
]}>
Poster
</Text>
{settings.continueWatchingCardStyle === 'poster' && (
<MaterialIcons name="check-circle" size={18} color={colors.white} style={styles.cardStyleCheck} />
)}
</TouchableOpacity>
</View>
</View>
</View>
</View>
@ -207,80 +283,80 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
<View style={styles.section}>
<Text style={styles.sectionTitle}>CACHE SETTINGS</Text>
<View style={styles.settingsCard}>
<View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
Stream Cache Duration
</Text>
<Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}>
How long to keep cached stream links before they expire
</Text>
<View style={styles.ttlOptionsContainer}>
{TTL_OPTIONS.map((row, rowIndex) => (
<View key={rowIndex} style={styles.ttlRow}>
{row.map((option) => (
<TTLPickerItem key={option.value} option={option} />
))}
</View>
))}
<View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
Stream Cache Duration
</Text>
<Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}>
How long to keep cached stream links before they expire
</Text>
<View style={styles.ttlOptionsContainer}>
{TTL_OPTIONS.map((row, rowIndex) => (
<View key={rowIndex} style={styles.ttlRow}>
{row.map((option) => (
<TTLPickerItem key={option.value} option={option} />
))}
</View>
))}
</View>
</View>
</View>
</View>
</View>
)}
{settings.useCachedStreams && (
<View style={styles.section}>
<View style={[styles.warningCard, { borderColor: colors.warning }]}>
<View style={styles.warningHeader}>
<MaterialIcons name="warning" size={20} color={colors.warning} />
<Text style={[styles.warningTitle, { color: colors.warning }]}>
Important Note
<View style={styles.warningHeader}>
<MaterialIcons name="warning" size={20} color={colors.warning} />
<Text style={[styles.warningTitle, { color: colors.warning }]}>
Important Note
</Text>
</View>
<Text style={[styles.warningText, { color: colors.mediumEmphasis }]}>
Not all stream links may remain active for the full cache duration. Longer cache times may result in expired links. If a cached link fails, the app will fall back to fetching fresh streams.
</Text>
</View>
<Text style={[styles.warningText, { color: colors.mediumEmphasis }]}>
Not all stream links may remain active for the full cache duration. Longer cache times may result in expired links. If a cached link fails, the app will fall back to fetching fresh streams.
</Text>
</View>
</View>
)}
<View style={styles.section}>
<View style={styles.infoCard}>
<View style={styles.infoHeader}>
<MaterialIcons name="info" size={20} color={colors.primary} />
<Text style={[styles.infoTitle, { color: colors.highEmphasis }]}>
How it works
<View style={styles.infoHeader}>
<MaterialIcons name="info" size={20} color={colors.primary} />
<Text style={[styles.infoTitle, { color: colors.highEmphasis }]}>
How it works
</Text>
</View>
<Text style={[styles.infoText, { color: colors.mediumEmphasis }]}>
{settings.useCachedStreams ? (
<>
Streams are cached for your selected duration after playing{'\n'}
Cached streams are validated before use{'\n'}
If cache is invalid or expired, falls back to content screen{'\n'}
"Use Cached Streams" controls direct player vs screen navigation{'\n'}
"Open Metadata Screen" appears only when cached streams are disabled
</>
) : (
<>
When cached streams are disabled, clicking Continue Watching items opens content screens{'\n'}
"Open Metadata Screen" option controls which screen to open{'\n'}
Metadata screen shows content details and allows manual stream selection{'\n'}
Streams screen shows available streams for immediate playback
</>
)}
</Text>
</View>
<Text style={[styles.infoText, { color: colors.mediumEmphasis }]}>
{settings.useCachedStreams ? (
<>
Streams are cached for your selected duration after playing{'\n'}
Cached streams are validated before use{'\n'}
If cache is invalid or expired, falls back to content screen{'\n'}
"Use Cached Streams" controls direct player vs screen navigation{'\n'}
"Open Metadata Screen" appears only when cached streams are disabled
</>
) : (
<>
When cached streams are disabled, clicking Continue Watching items opens content screens{'\n'}
"Open Metadata Screen" option controls which screen to open{'\n'}
Metadata screen shows content details and allows manual stream selection{'\n'}
Streams screen shows available streams for immediate playback
</>
)}
</Text>
</View>
</View>
</ScrollView>
{/* Saved indicator */}
<Animated.View
<Animated.View
style={[
styles.savedIndicator,
{
{
backgroundColor: colors.primary,
opacity: fadeAnim
opacity: fadeAnim
}
]}
>
@ -466,6 +542,84 @@ const createStyles = (colors: any) => StyleSheet.create({
fontSize: 14,
lineHeight: 20,
},
// Card Style Selector Styles
cardStyleOptionsContainer: {
flexDirection: 'row',
width: '100%',
gap: 12,
},
cardStyleOption: {
flex: 1,
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
position: 'relative',
},
cardPreviewWide: {
flexDirection: 'row',
width: 100,
height: 60,
borderRadius: 6,
overflow: 'hidden',
marginBottom: 8,
alignSelf: 'center',
},
cardPreviewImage: {
width: 40,
height: '100%',
borderTopLeftRadius: 6,
borderBottomLeftRadius: 6,
},
cardPreviewContent: {
flex: 1,
padding: 4,
justifyContent: 'space-between',
},
cardPreviewLine: {
height: 8,
borderRadius: 2,
},
cardPreviewProgress: {
height: 4,
borderRadius: 2,
width: '100%',
},
cardPreviewProgressFill: {
height: '100%',
borderRadius: 2,
},
cardPreviewPoster: {
width: 44,
height: 60,
borderRadius: 6,
overflow: 'hidden',
marginBottom: 8,
position: 'relative',
},
cardPreviewPosterImage: {
width: '100%',
height: '100%',
borderRadius: 6,
},
cardPreviewPosterProgress: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 4,
},
cardStyleLabel: {
fontSize: 14,
fontWeight: '600',
marginTop: 4,
},
cardStyleCheck: {
position: 'absolute',
top: 8,
right: 8,
},
});
export default ContinueWatchingSettingsScreen;

View file

@ -667,7 +667,9 @@ const HomeScreen = () => {
}
// Normal flow when addons are present (featured moved to ListHeaderComponent)
data.push({ type: 'thisWeek', key: 'thisWeek' });
if (settings.showThisWeekSection) {
data.push({ type: 'thisWeek', key: 'thisWeek' });
}
// Only show a limited number of catalogs initially for performance
const catalogsToShow = catalogs.slice(0, visibleCatalogCount);
@ -687,7 +689,7 @@ const HomeScreen = () => {
}
return data;
}, [hasAddons, catalogs, visibleCatalogCount]);
}, [hasAddons, catalogs, visibleCatalogCount, settings.showThisWeekSection]);
const handleLoadMoreCatalogs = useCallback(() => {
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));

View file

@ -64,11 +64,11 @@ const SettingItem: React.FC<SettingItemProps> = ({
const isTabletDevice = Platform.OS !== 'web' && (Dimensions.get('window').width >= 768);
return (
<TouchableOpacity
<TouchableOpacity
activeOpacity={onPress ? 0.7 : 1}
onPress={onPress}
style={[
styles.settingItem,
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
]}
@ -127,8 +127,8 @@ const HomeScreenSettings: React.FC = () => {
if (Platform.OS === 'ios') {
StatusBar.setHidden(false);
}
} catch {}
return () => {};
} catch { }
return () => { };
}, [isDarkMode, colors.darkBackground])
);
@ -169,7 +169,7 @@ const HomeScreenSettings: React.FC = () => {
if (isTabletDevice && settings.heroStyle !== 'carousel') {
updateSetting('heroStyle', 'carousel' as any);
}
} catch {}
} catch { }
}, [isTabletDevice, settings.heroStyle, updateSetting]);
const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => (
@ -184,20 +184,20 @@ const HomeScreenSettings: React.FC = () => {
// Radio button component for content source selection
const RadioOption = ({ selected, onPress, label }: { selected: boolean, onPress: () => void, label: string }) => (
<TouchableOpacity
style={styles.radioOption}
<TouchableOpacity
style={styles.radioOption}
onPress={onPress}
activeOpacity={0.7}
>
<View style={styles.radioContainer}>
<View style={[
styles.radio,
styles.radio,
{ borderColor: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
{selected && <View style={[styles.radioInner, { backgroundColor: colors.primary }]} />}
</View>
<Text style={[
styles.radioLabel,
styles.radioLabel,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
{label}
@ -254,9 +254,9 @@ const HomeScreenSettings: React.FC = () => {
}, [settings.selectedHeroCatalogs]);
const ChevronRight = () => (
<MaterialIcons
name="chevron-right"
size={24}
<MaterialIcons
name="chevron-right"
size={24}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
);
@ -269,30 +269,30 @@ const HomeScreenSettings: React.FC = () => {
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
/>
<Text style={[styles.backText, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Settings
</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
{/* Empty for now, but ready for future actions */}
</View>
</View>
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Home Screen Settings
</Text>
{/* Saved indicator */}
<Animated.View
<Animated.View
style={[
styles.savedIndicator,
{
styles.savedIndicator,
{
opacity: fadeAnim,
backgroundColor: isDarkMode ? 'rgba(0, 180, 150, 0.9)' : 'rgba(0, 180, 150, 0.9)'
}
@ -303,7 +303,7 @@ const HomeScreenSettings: React.FC = () => {
<Text style={styles.savedIndicatorText}>Changes Applied</Text>
</Animated.View>
<ScrollView
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
@ -317,9 +317,22 @@ const HomeScreenSettings: React.FC = () => {
isDarkMode={isDarkMode}
colors={colors}
renderControl={() => (
<CustomSwitch
value={settings.showHeroSection}
onValueChange={(value) => handleUpdateSetting('showHeroSection', value)}
<CustomSwitch
value={settings.showHeroSection}
onValueChange={(value) => handleUpdateSetting('showHeroSection', value)}
/>
)}
/>
<SettingItem
title="Show This Week Section"
description="New episodes from current week"
icon="date-range"
isDarkMode={isDarkMode}
colors={colors}
renderControl={() => (
<CustomSwitch
value={settings.showThisWeekSection}
onValueChange={(value) => handleUpdateSetting('showThisWeekSection', value)}
/>
)}
/>
@ -344,7 +357,7 @@ const HomeScreenSettings: React.FC = () => {
<Text style={[styles.segmentTitle, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>Hero Layout</Text>
<SegmentedControl
options={[
{ label: 'Legacy', value: 'legacy' },
{ label: 'Legacy', value: 'legacy' },
{ label: 'Carousel', value: 'carousel' },
{ label: 'Apple TV', value: 'appletv' }
]}
@ -377,7 +390,7 @@ const HomeScreenSettings: React.FC = () => {
isDarkMode={isDarkMode}
colors={colors}
renderControl={() => (
<CustomSwitch
<CustomSwitch
value={settings.enableHomeHeroBackground}
onValueChange={(value) => handleUpdateSetting('enableHomeHeroBackground', value)}
/>
@ -393,7 +406,7 @@ const HomeScreenSettings: React.FC = () => {
<Text style={[styles.cardHeader, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>Posters</Text>
<View style={styles.settingsRowInline}>
<Text style={[styles.rowLabel, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>Show Titles</Text>
<CustomSwitch
<CustomSwitch
value={settings.showPosterTitles}
onValueChange={(value) => handleUpdateSetting('showPosterTitles', value)}
/>

View file

@ -25,6 +25,7 @@ import { useTheme } from '../contexts/ThemeContext';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { mmkvStorage } from '../services/mmkvStorage';
import { ShapeAnimation } from '../components/onboarding/ShapeAnimation';
const { width, height } = Dimensions.get('window');
@ -263,6 +264,29 @@ const OnboardingScreen = () => {
transform: [{ scale: buttonScale.value }],
}));
// Animated opacity for button and swipe indicator based on scroll
const lastSlideStart = (onboardingData.length - 1) * width;
const buttonOpacityStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollX.value,
[lastSlideStart - width * 0.3, lastSlideStart],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
const swipeOpacityStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollX.value,
[lastSlideStart - width * 0.3, lastSlideStart],
[1, 0],
Extrapolation.CLAMP
);
return { opacity };
});
const handlePressIn = () => {
buttonScale.value = withSpring(0.95, { damping: 15, stiffness: 400 });
};
@ -276,6 +300,9 @@ const OnboardingScreen = () => {
<StatusBar barStyle="light-content" backgroundColor="#0A0A0A" translucent />
<View style={styles.fullScreenContainer}>
{/* Shape Animation Background - iOS only */}
{Platform.OS === 'ios' && <ShapeAnimation scrollX={scrollX} />}
{/* Header */}
<Animated.View
entering={FadeIn.delay(300).duration(600)}
@ -321,19 +348,28 @@ const OnboardingScreen = () => {
))}
</View>
{/* Animated Button */}
<TouchableOpacity
onPress={handleNext}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[styles.button, buttonStyle]}>
<Text style={styles.buttonText}>
{currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Continue'}
</Text>
{/* Button and Swipe indicator with crossfade based on scroll */}
<View style={styles.footerButtonContainer}>
{/* Swipe Indicator - fades out on last slide */}
<Animated.View style={[styles.swipeIndicator, styles.absoluteFill, swipeOpacityStyle]}>
<Text style={styles.swipeText}>Swipe to continue</Text>
<Text style={styles.swipeArrow}></Text>
</Animated.View>
</TouchableOpacity>
{/* Get Started Button - fades in on last slide */}
<Animated.View style={[styles.absoluteFill, buttonOpacityStyle]}>
<TouchableOpacity
onPress={handleGetStarted}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[styles.button, buttonStyle]}>
<Text style={styles.buttonText}>Get Started</Text>
</Animated.View>
</TouchableOpacity>
</Animated.View>
</View>
</Animated.View>
</View>
</View>
@ -381,11 +417,12 @@ const styles = StyleSheet.create({
slide: {
width,
flex: 1,
justifyContent: 'center',
justifyContent: Platform.OS === 'ios' ? 'flex-start' : 'center', // Top on iOS, center on Android
paddingHorizontal: 32,
paddingTop: Platform.OS === 'ios' ? '20%' : 0, // Padding only on iOS
},
textContainer: {
alignItems: 'flex-start',
alignItems: 'flex-start', // Text always left-aligned
},
title: {
fontSize: 52,
@ -407,6 +444,7 @@ const styles = StyleSheet.create({
lineHeight: 24,
color: 'rgba(255, 255, 255, 0.4)',
maxWidth: 300,
textAlign: 'left', // Always left-aligned text
},
footer: {
paddingHorizontal: 24,
@ -437,6 +475,34 @@ const styles = StyleSheet.create({
color: '#0A0A0A',
letterSpacing: 0.3,
},
swipeIndicator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
gap: 8,
},
swipeText: {
fontSize: 14,
fontWeight: '500',
color: 'rgba(255, 255, 255, 0.4)',
letterSpacing: 0.3,
},
swipeArrow: {
fontSize: 18,
color: 'rgba(255, 255, 255, 0.4)',
},
footerButtonContainer: {
height: 56,
position: 'relative',
},
absoluteFill: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
});
export default OnboardingScreen;

View file

@ -677,6 +677,23 @@ const TMDBSettingsScreen = () => {
/>
</View>
{/* Title & Description */}
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Title & Description</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Use TMDb localized title and overview text
</Text>
</View>
<Switch
value={settings.tmdbEnrichTitleDescription}
onValueChange={(v) => updateSetting('tmdbEnrichTitleDescription', v)}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? currentTheme.colors.white : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
{/* Title Logos */}
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>

View file

@ -53,7 +53,7 @@ const TraktSettingsScreen: React.FC = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
const { currentTheme } = useTheme();
const {
settings: autosyncSettings,
isSyncing,
@ -101,7 +101,7 @@ const TraktSettingsScreen: React.FC = () => {
try {
const authenticated = await traktService.isAuthenticated();
setIsAuthenticated(authenticated);
if (authenticated) {
const profile = await traktService.getUserProfile();
setUserProfile(profile);
@ -151,8 +151,8 @@ const TraktSettingsScreen: React.FC = () => {
'Successfully Connected',
'Your Trakt account has been connected successfully.',
[
{
label: 'OK',
{
label: 'OK',
onPress: () => navigation.goBack(),
}
]
@ -190,9 +190,9 @@ const TraktSettingsScreen: React.FC = () => {
'Sign Out',
'Are you sure you want to sign out of your Trakt account?',
[
{ label: 'Cancel', onPress: () => {} },
{
label: 'Sign Out',
{ label: 'Cancel', onPress: () => { } },
{
label: 'Sign Out',
onPress: async () => {
setIsLoading(true);
try {
@ -224,26 +224,39 @@ const TraktSettingsScreen: React.FC = () => {
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark}
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark}
/>
<Text style={[styles.backText, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
Settings
</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
{/* Empty for now, but ready for future actions */}
</View>
</View>
<Text style={[styles.headerTitle, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
Trakt Settings
</Text>
<ScrollView
{/* Maintenance Mode Banner */}
{traktService.isMaintenanceMode() && (
<View style={styles.maintenanceBanner}>
<MaterialIcons name="engineering" size={24} color="#FFF" />
<View style={styles.maintenanceBannerTextContainer}>
<Text style={styles.maintenanceBannerTitle}>Under Maintenance</Text>
<Text style={styles.maintenanceBannerMessage}>
{traktService.getMaintenanceMessage()}
</Text>
</View>
</View>
)}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
@ -255,12 +268,44 @@ const TraktSettingsScreen: React.FC = () => {
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
</View>
) : traktService.isMaintenanceMode() ? (
<View style={styles.signInContainer}>
<TraktIcon
width={120}
height={120}
style={[styles.traktLogo, { opacity: 0.5 }]}
/>
<Text style={[
styles.signInTitle,
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}>
Trakt Unavailable
</Text>
<Text style={[
styles.signInDescription,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
The Trakt integration is temporarily paused for maintenance. All syncing and authentication is disabled until maintenance is complete.
</Text>
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: currentTheme.colors.border, opacity: 0.6 }
]}
disabled={true}
>
<MaterialIcons name="engineering" size={20} color={currentTheme.colors.mediumEmphasis} style={{ marginRight: 8 }} />
<Text style={[styles.buttonText, { color: currentTheme.colors.mediumEmphasis }]}>
Service Under Maintenance
</Text>
</TouchableOpacity>
</View>
) : isAuthenticated && userProfile ? (
<View style={styles.profileContainer}>
<View style={styles.profileHeader}>
{userProfile.avatar ? (
<FastImage
source={{ uri: userProfile.avatar }}
<FastImage
source={{ uri: userProfile.avatar }}
style={styles.avatar}
resizeMode={FastImage.resizeMode.cover}
/>
@ -315,7 +360,7 @@ const TraktSettingsScreen: React.FC = () => {
</View>
) : (
<View style={styles.signInContainer}>
<TraktIcon
<TraktIcon
width={120}
height={120}
style={styles.traktLogo}
@ -372,7 +417,7 @@ const TraktSettingsScreen: React.FC = () => {
styles.infoText,
{ color: currentTheme.colors.mediumEmphasis }
]}>
When connected to Trakt, Continue Watching is sourced from Trakt. Account sync for watch progress is disabled to avoid conflicts.
When connected to Trakt, full history is synced directly from the API and is not written to local storage. Your Continue Watching list reflects your global Trakt progress.
</Text>
</View>
<View style={styles.settingItem}>
@ -497,7 +542,7 @@ const TraktSettingsScreen: React.FC = () => {
</View>
)}
</ScrollView>
<CustomAlert
visible={alertVisible}
title={alertTitle}
@ -704,6 +749,31 @@ const styles = StyleSheet.create({
fontSize: 13,
lineHeight: 18,
},
// Maintenance mode styles
maintenanceBanner: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#E67E22',
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
borderRadius: 12,
},
maintenanceBannerTextContainer: {
marginLeft: 12,
flex: 1,
},
maintenanceBannerTitle: {
fontSize: 16,
fontWeight: '600',
color: '#FFF',
marginBottom: 4,
},
maintenanceBannerMessage: {
fontSize: 13,
color: '#FFF',
opacity: 0.9,
},
});
export default TraktSettingsScreen;

File diff suppressed because it is too large Load diff

View file

@ -577,6 +577,10 @@ export type TraktContentCommentLegacy =
| TraktEpisodeComment
| TraktListComment;
const TRAKT_MAINTENANCE_MODE = false;
const TRAKT_MAINTENANCE_MESSAGE = 'Trakt integration is temporarily unavailable for maintenance. Please try again later.';
export class TraktService {
private static instance: TraktService;
private accessToken: string | null = null;
@ -584,6 +588,16 @@ export class TraktService {
private tokenExpiry: number = 0;
private isInitialized: boolean = false;
public isMaintenanceMode(): boolean {
return TRAKT_MAINTENANCE_MODE;
}
public getMaintenanceMessage(): string {
return TRAKT_MAINTENANCE_MESSAGE;
}
// Rate limiting - Optimized for real-time scrobbling
private lastApiCall: number = 0;
private readonly MIN_API_INTERVAL = 500; // Reduced to 500ms for faster updates
@ -726,6 +740,12 @@ export class TraktService {
* Check if the user is authenticated with Trakt
*/
public async isAuthenticated(): Promise<boolean> {
// During maintenance, report as not authenticated to disable all syncing
if (this.isMaintenanceMode()) {
logger.log('[TraktService] Maintenance mode: reporting as not authenticated');
return false;
}
await this.ensureInitialized();
if (!this.accessToken) {
@ -756,6 +776,12 @@ export class TraktService {
* Exchange the authorization code for an access token
*/
public async exchangeCodeForToken(code: string, codeVerifier: string): Promise<boolean> {
// Block authentication during maintenance
if (this.isMaintenanceMode()) {
logger.warn('[TraktService] Maintenance mode: blocking new authentication');
return false;
}
await this.ensureInitialized();
try {
@ -887,6 +913,12 @@ export class TraktService {
body?: any,
retryCount: number = 0
): Promise<T> {
// Block all API requests during maintenance
if (this.isMaintenanceMode()) {
logger.warn('[TraktService] Maintenance mode: blocking API request to', endpoint);
throw new Error(TRAKT_MAINTENANCE_MESSAGE);
}
await this.ensureInitialized();
// Rate limiting: ensure minimum interval between API calls
@ -1106,10 +1138,10 @@ export class TraktService {
? imdbId
: `tt${imdbId}`;
const response = await this.client.get('/sync/watched/movies');
const movies = Array.isArray(response.data) ? response.data : [];
const movies = await this.apiRequest<any[]>('/sync/watched/movies');
const moviesArray = Array.isArray(movies) ? movies : [];
return movies.some(
return moviesArray.some(
(m: any) => m.movie?.ids?.imdb === imdb
);
} catch (err) {
@ -2690,6 +2722,26 @@ export class TraktService {
}
}
/**
* Remove a playback item from Trakt (Continue Watching) by Playback ID
*/
public async removePlaybackItem(playbackId: number): Promise<boolean> {
try {
logger.log(`🔍 [TraktService] removePlaybackItem called for playback ID: ${playbackId}`);
if (!playbackId) return false;
// Use DELETE /sync/playback/{id}
// Note: The ID here is the playback ID, not the movie/episode ID
await this.apiRequest<any>(`/sync/playback/${playbackId}`, 'DELETE');
logger.log(`✅ [TraktService] Successfully removed playback item ${playbackId}. Response: 204 No Content (Standard for DELETE)`);
return true;
} catch (error) {
logger.error(`[TraktService] Failed to remove playback item ${playbackId}:`, error);
return false;
}
}
/**
* Remove entire show from watched history by IMDB ID
*/