mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
Merge branch 'tapframe:main' into feature/ani-skip
This commit is contained in:
commit
ccad48fbb4
33 changed files with 2095 additions and 961 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"expo.jsEngine": "hermes",
|
||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||
"newArchEnabled": "true"
|
||||
}
|
||||
"newArchEnabled": "true",
|
||||
"ios.deploymentTarget": "16.0"
|
||||
}
|
||||
|
|
@ -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
64
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
|
|||
172
src/components/onboarding/ShapeAnimation.tsx
Normal file
172
src/components/onboarding/ShapeAnimation.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useWindowDimensions, StyleSheet } from 'react-native';
|
||||
import {
|
||||
Blur,
|
||||
BlurMask,
|
||||
Canvas,
|
||||
Circle,
|
||||
Extrapolate,
|
||||
interpolate,
|
||||
interpolateColors,
|
||||
LinearGradient,
|
||||
Path,
|
||||
RadialGradient,
|
||||
usePathValue,
|
||||
vec,
|
||||
} from '@shopify/react-native-skia';
|
||||
import {
|
||||
Easing,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
SharedValue,
|
||||
useDerivedValue,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import {
|
||||
type Point3D,
|
||||
N_POINTS,
|
||||
ALL_SHAPES,
|
||||
ALL_SHAPES_X,
|
||||
ALL_SHAPES_Y,
|
||||
ALL_SHAPES_Z,
|
||||
} from './shapes';
|
||||
|
||||
// Color palettes for each shape (gradient stops)
|
||||
const COLOR_STOPS = [
|
||||
{ start: '#FFD700', end: '#FF6B00' }, // Star: Gold → Orange
|
||||
{ start: '#7C3AED', end: '#EC4899' }, // Plugin: Purple → Pink
|
||||
{ start: '#00D9FF', end: '#0EA5E9' }, // Search: Cyan → Blue
|
||||
{ start: '#FF006E', end: '#FB7185' }, // Heart: Pink → Rose
|
||||
];
|
||||
|
||||
// ============ 3D UTILITIES ============
|
||||
const rotateX = (p: Point3D, angle: number): Point3D => {
|
||||
'worklet';
|
||||
return {
|
||||
x: p.x,
|
||||
y: p.y * Math.cos(angle) - p.z * Math.sin(angle),
|
||||
z: p.y * Math.sin(angle) + p.z * Math.cos(angle),
|
||||
};
|
||||
};
|
||||
|
||||
const rotateY = (p: Point3D, angle: number): Point3D => {
|
||||
'worklet';
|
||||
return {
|
||||
x: p.x * Math.cos(angle) + p.z * Math.sin(angle),
|
||||
y: p.y,
|
||||
z: -p.x * Math.sin(angle) + p.z * Math.cos(angle),
|
||||
};
|
||||
};
|
||||
|
||||
interface ShapeAnimationProps {
|
||||
scrollX: SharedValue<number>;
|
||||
}
|
||||
|
||||
export const ShapeAnimation: React.FC<ShapeAnimationProps> = ({ scrollX }) => {
|
||||
const iTime = useSharedValue(0.0);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
|
||||
// Pre-compute input range once
|
||||
const shapeWidth = windowWidth;
|
||||
const inputRange = ALL_SHAPES.map((_, idx) => shapeWidth * idx);
|
||||
|
||||
// Single optimized path - all 4 shapes batched into one Skia Path
|
||||
const morphPath = usePathValue(skPath => {
|
||||
'worklet';
|
||||
const centerX = windowWidth / 2;
|
||||
const centerY = windowHeight * 0.65;
|
||||
const distance = 350;
|
||||
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
// Interpolate 3D coordinates between all shapes
|
||||
const baseX = interpolate(scrollX.value, inputRange, ALL_SHAPES_X[i], Extrapolate.CLAMP);
|
||||
const baseY = interpolate(scrollX.value, inputRange, ALL_SHAPES_Y[i], Extrapolate.CLAMP);
|
||||
const baseZ = interpolate(scrollX.value, inputRange, ALL_SHAPES_Z[i], Extrapolate.CLAMP);
|
||||
|
||||
// Apply 3D rotation
|
||||
let p: Point3D = { x: baseX, y: baseY, z: baseZ };
|
||||
p = rotateX(p, 0.2); // Fixed X tilt
|
||||
p = rotateY(p, iTime.value); // Animated Y rotation
|
||||
|
||||
// Perspective projection
|
||||
const scale = distance / (distance + p.z);
|
||||
const screenX = centerX + p.x * scale;
|
||||
const screenY = centerY + p.y * scale;
|
||||
|
||||
// Depth-based radius for parallax effect
|
||||
const radius = Math.max(0.2, 0.5 * scale);
|
||||
skPath.addCircle(screenX, screenY, radius);
|
||||
}
|
||||
|
||||
return skPath;
|
||||
});
|
||||
|
||||
// Interpolate gradient colors based on scroll position
|
||||
const gradientColors = useDerivedValue(() => {
|
||||
const startColors = COLOR_STOPS.map(c => c.start);
|
||||
const endColors = COLOR_STOPS.map(c => c.end);
|
||||
|
||||
const start = interpolateColors(scrollX.value, inputRange, startColors);
|
||||
const end = interpolateColors(scrollX.value, inputRange, endColors);
|
||||
|
||||
return [start, end];
|
||||
});
|
||||
|
||||
// Rotation animation - infinite loop
|
||||
useEffect(() => {
|
||||
iTime.value = 0;
|
||||
iTime.value = withRepeat(
|
||||
withTiming(2 * Math.PI, {
|
||||
duration: 12000,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
style={[
|
||||
styles.canvas,
|
||||
{
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
},
|
||||
]}>
|
||||
{/* Background glow */}
|
||||
<Circle
|
||||
cx={windowWidth / 2}
|
||||
cy={windowHeight * 0.65}
|
||||
r={windowWidth * 0.6}>
|
||||
<RadialGradient
|
||||
c={vec(windowWidth / 2, windowHeight * 0.65)}
|
||||
r={windowWidth * 0.6}
|
||||
colors={['#ffffff20', 'transparent']}
|
||||
/>
|
||||
<Blur blur={60} />
|
||||
</Circle>
|
||||
|
||||
{/* Single optimized path with interpolated gradient */}
|
||||
<Path path={morphPath} style="fill">
|
||||
<LinearGradient
|
||||
start={vec(0, windowHeight * 0.4)}
|
||||
end={vec(windowWidth, windowHeight * 0.9)}
|
||||
colors={gradientColors}
|
||||
/>
|
||||
<BlurMask blur={5} style="solid" />
|
||||
</Path>
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
canvas: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default ShapeAnimation;
|
||||
8
src/components/onboarding/shapes/constants.ts
Normal file
8
src/components/onboarding/shapes/constants.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Fixed number of points for all shapes (for interpolation)
|
||||
// Lower = better FPS, 1000 points is a good balance for smooth 60fps
|
||||
export const N_POINTS = 1000;
|
||||
|
||||
export const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2;
|
||||
|
||||
// Normalize a shape to have height TARGET_HEIGHT
|
||||
export const TARGET_HEIGHT = 200;
|
||||
35
src/components/onboarding/shapes/cube.ts
Normal file
35
src/components/onboarding/shapes/cube.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Cube - map sphere to cube
|
||||
const generateCubePoints = (size: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
const s = size / 2;
|
||||
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi } = fibonacciPoint(i, N_POINTS);
|
||||
// Point on unit sphere
|
||||
const sx = Math.sin(phi) * Math.cos(theta);
|
||||
const sy = Math.sin(phi) * Math.sin(theta);
|
||||
const sz = Math.cos(phi);
|
||||
|
||||
// Map to cube (cube mapping)
|
||||
const absX = Math.abs(sx);
|
||||
const absY = Math.abs(sy);
|
||||
const absZ = Math.abs(sz);
|
||||
const max = Math.max(absX, absY, absZ);
|
||||
|
||||
points.push({
|
||||
x: (sx / max) * s,
|
||||
y: (sy / max) * s,
|
||||
z: (sz / max) * s,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const CUBE_POINTS = scaleShape(
|
||||
normalizeShape(generateCubePoints(150)),
|
||||
0.75,
|
||||
);
|
||||
35
src/components/onboarding/shapes/heart.ts
Normal file
35
src/components/onboarding/shapes/heart.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape } from './utils';
|
||||
|
||||
// Heart - starts from Fibonacci sphere, deforms into heart
|
||||
const generateHeartPoints = (scale: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi } = fibonacciPoint(i, N_POINTS);
|
||||
|
||||
// Use same angular coordinates as sphere
|
||||
const u = theta;
|
||||
const v = phi;
|
||||
const sinV = Math.sin(v);
|
||||
|
||||
// Heart surface with same angular correspondence
|
||||
const hx = sinV * (15 * Math.sin(u) - 4 * Math.sin(3 * u));
|
||||
const hz = 8 * Math.cos(v);
|
||||
const hy =
|
||||
sinV *
|
||||
(15 * Math.cos(u) -
|
||||
5 * Math.cos(2 * u) -
|
||||
2 * Math.cos(3 * u) -
|
||||
Math.cos(4 * u));
|
||||
|
||||
points.push({
|
||||
x: hx * scale * 0.06,
|
||||
y: -hy * scale * 0.06,
|
||||
z: hz * scale * 0.06,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const HEART_POINTS = normalizeShape(generateHeartPoints(120));
|
||||
28
src/components/onboarding/shapes/index.ts
Normal file
28
src/components/onboarding/shapes/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export { type Point3D } from './types';
|
||||
export { N_POINTS } from './constants';
|
||||
|
||||
import { N_POINTS } from './constants';
|
||||
import { STAR_POINTS } from './star'; // Welcome to Nuvio
|
||||
import { PLUGIN_POINTS } from './plugin'; // Powerful Addons
|
||||
import { SEARCH_POINTS } from './search'; // Smart Discovery
|
||||
import { HEART_POINTS } from './heart'; // Your Library (favorites)
|
||||
|
||||
// Array of all shapes - ordered to match onboarding slides
|
||||
export const ALL_SHAPES = [
|
||||
STAR_POINTS, // Slide 1: Welcome
|
||||
PLUGIN_POINTS, // Slide 2: Addons
|
||||
SEARCH_POINTS, // Slide 3: Discovery
|
||||
HEART_POINTS, // Slide 4: Library
|
||||
];
|
||||
|
||||
export const POINTS_ARRAY = new Array(N_POINTS).fill(0);
|
||||
|
||||
export const ALL_SHAPES_X = POINTS_ARRAY.map((_, pointIndex) =>
|
||||
ALL_SHAPES.map(shape => shape[pointIndex].x),
|
||||
);
|
||||
export const ALL_SHAPES_Y = POINTS_ARRAY.map((_, pointIndex) =>
|
||||
ALL_SHAPES.map(shape => shape[pointIndex].y),
|
||||
);
|
||||
export const ALL_SHAPES_Z = POINTS_ARRAY.map((_, pointIndex) =>
|
||||
ALL_SHAPES.map(shape => shape[pointIndex].z),
|
||||
);
|
||||
96
src/components/onboarding/shapes/plugin.ts
Normal file
96
src/components/onboarding/shapes/plugin.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// LEGO Brick shape - perfectly represents "Addons" or "Plugins"
|
||||
const generateLegoPoints = (): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
|
||||
// Dimensions
|
||||
const width = 160;
|
||||
const depth = 80;
|
||||
const height = 48;
|
||||
const studRadius = 12;
|
||||
const studHeight = 16;
|
||||
|
||||
// Distribute points: 70% body, 30% studs
|
||||
const bodyPoints = Math.floor(N_POINTS * 0.7);
|
||||
const studPoints = N_POINTS - bodyPoints;
|
||||
const pointsPerStud = Math.floor(studPoints / 8); // 8 studs (2x4 brick)
|
||||
|
||||
// 1. Main Brick Body (Rectangular Prism)
|
||||
for (let i = 0; i < bodyPoints; i++) {
|
||||
const t1 = Math.random();
|
||||
const t2 = Math.random();
|
||||
const t3 = Math.random();
|
||||
|
||||
// Create density concentration on edges for better definition
|
||||
const x = (Math.pow(t1, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * width / 2;
|
||||
const y = (Math.pow(t2, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * height / 2;
|
||||
const z = (Math.pow(t3, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * depth / 2;
|
||||
|
||||
// Snapping to faces to make it look solid
|
||||
const face = Math.floor(Math.random() * 6);
|
||||
let px = x, py = y, pz = z;
|
||||
|
||||
if (face === 0) px = width / 2;
|
||||
else if (face === 1) px = -width / 2;
|
||||
else if (face === 2) py = height / 2;
|
||||
else if (face === 3) py = -height / 2;
|
||||
else if (face === 4) pz = depth / 2;
|
||||
else if (face === 5) pz = -depth / 2;
|
||||
|
||||
// Add some random noise inside/surface
|
||||
if (Math.random() > 0.8) {
|
||||
points.push({ x: x, y: y, z: z });
|
||||
} else {
|
||||
points.push({ x: px, y: py, z: pz });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Studs (Cylinders on top)
|
||||
// 2x4 Grid positions
|
||||
const studPositions = [
|
||||
{ x: -width * 0.375, z: -depth * 0.25 }, { x: -width * 0.125, z: -depth * 0.25 },
|
||||
{ x: width * 0.125, z: -depth * 0.25 }, { x: width * 0.375, z: -depth * 0.25 },
|
||||
{ x: -width * 0.375, z: depth * 0.25 }, { x: -width * 0.125, z: depth * 0.25 },
|
||||
{ x: width * 0.125, z: depth * 0.25 }, { x: width * 0.375, z: depth * 0.25 },
|
||||
];
|
||||
|
||||
studPositions.forEach((pos, studIndex) => {
|
||||
for (let j = 0; j < pointsPerStud; j++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * studRadius;
|
||||
|
||||
// Top face of stud
|
||||
if (Math.random() > 0.5) {
|
||||
points.push({
|
||||
x: pos.x + r * Math.cos(angle),
|
||||
y: -height / 2 - studHeight, // Top
|
||||
z: pos.z + r * Math.sin(angle),
|
||||
});
|
||||
} else {
|
||||
// Side of stud
|
||||
const h = Math.random() * studHeight;
|
||||
points.push({
|
||||
x: pos.x + studRadius * Math.cos(angle),
|
||||
y: -height / 2 - h,
|
||||
z: pos.z + studRadius * Math.sin(angle),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// FILL remaining points to prevent "undefined" errors
|
||||
while (points.length < N_POINTS) {
|
||||
points.push(points[points.length - 1] || { x: 0, y: 0, z: 0 });
|
||||
}
|
||||
|
||||
// Slice to guarantee exact count
|
||||
return points.slice(0, N_POINTS);
|
||||
};
|
||||
|
||||
export const PLUGIN_POINTS = scaleShape(
|
||||
normalizeShape(generateLegoPoints()),
|
||||
0.4,
|
||||
);
|
||||
57
src/components/onboarding/shapes/search.ts
Normal file
57
src/components/onboarding/shapes/search.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Magnifying glass/search shape - for "Discovery" page
|
||||
const generateSearchPoints = (radius: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
const handleLength = radius * 0.8;
|
||||
const handleWidth = radius * 0.15;
|
||||
|
||||
// Split points between ring and handle
|
||||
const ringPoints = Math.floor(N_POINTS * 0.7);
|
||||
const handlePoints = N_POINTS - ringPoints;
|
||||
|
||||
// Create the circular ring (lens)
|
||||
for (let i = 0; i < ringPoints; i++) {
|
||||
const t = i / ringPoints;
|
||||
const mainAngle = t * Math.PI * 2;
|
||||
const tubeAngle = (i * 17) % 20 / 20 * Math.PI * 2; // Distribute around tube
|
||||
|
||||
const tubeRadius = radius * 0.12;
|
||||
const centerRadius = radius;
|
||||
|
||||
const cx = centerRadius * Math.cos(mainAngle);
|
||||
const cy = centerRadius * Math.sin(mainAngle);
|
||||
|
||||
points.push({
|
||||
x: cx + tubeRadius * Math.cos(tubeAngle) * Math.cos(mainAngle),
|
||||
y: cy + tubeRadius * Math.cos(tubeAngle) * Math.sin(mainAngle),
|
||||
z: tubeRadius * Math.sin(tubeAngle),
|
||||
});
|
||||
}
|
||||
|
||||
// Create the handle
|
||||
for (let i = 0; i < handlePoints; i++) {
|
||||
const t = i / handlePoints;
|
||||
const handleAngle = (i * 13) % 12 / 12 * Math.PI * 2;
|
||||
|
||||
// Handle position (extends from bottom-right of ring)
|
||||
const handleStart = radius * 0.7;
|
||||
const hx = handleStart + t * handleLength;
|
||||
const hy = handleStart + t * handleLength;
|
||||
|
||||
points.push({
|
||||
x: hx + handleWidth * Math.cos(handleAngle) * 0.3,
|
||||
y: hy + handleWidth * Math.cos(handleAngle) * 0.3,
|
||||
z: handleWidth * Math.sin(handleAngle),
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
export const SEARCH_POINTS = scaleShape(
|
||||
normalizeShape(generateSearchPoints(80)),
|
||||
1.0,
|
||||
);
|
||||
19
src/components/onboarding/shapes/sphere.ts
Normal file
19
src/components/onboarding/shapes/sphere.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape } from './utils';
|
||||
|
||||
// Sphere
|
||||
const generateSpherePoints = (radius: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi } = fibonacciPoint(i, N_POINTS);
|
||||
points.push({
|
||||
x: radius * Math.sin(phi) * Math.cos(theta),
|
||||
y: radius * Math.sin(phi) * Math.sin(theta),
|
||||
z: radius * Math.cos(phi),
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const SPHERE_POINTS = normalizeShape(generateSpherePoints(100));
|
||||
31
src/components/onboarding/shapes/star.ts
Normal file
31
src/components/onboarding/shapes/star.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Star shape - for "Welcome" page
|
||||
const generateStarPoints = (outerRadius: number, innerRadius: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
const numPoints = 5; // 5-pointed star
|
||||
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi, t } = fibonacciPoint(i, N_POINTS);
|
||||
|
||||
// Create star cross-section
|
||||
const angle = theta * numPoints;
|
||||
const radiusFactor = 0.5 + 0.5 * Math.cos(angle);
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * radiusFactor;
|
||||
|
||||
const sinPhi = Math.sin(phi);
|
||||
points.push({
|
||||
x: radius * sinPhi * Math.cos(theta),
|
||||
y: radius * sinPhi * Math.sin(theta),
|
||||
z: radius * Math.cos(phi) * 0.3, // Flatten z for star shape
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const STAR_POINTS = scaleShape(
|
||||
normalizeShape(generateStarPoints(100, 40)),
|
||||
0.9,
|
||||
);
|
||||
48
src/components/onboarding/shapes/torus.ts
Normal file
48
src/components/onboarding/shapes/torus.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Torus - uniform grid with same index correspondence
|
||||
const generateTorusPoints = (major: number, minor: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
|
||||
// Calculate approximate grid dimensions
|
||||
const ratio = major / minor;
|
||||
const minorSegments = Math.round(Math.sqrt(N_POINTS / ratio));
|
||||
const majorSegments = Math.round(N_POINTS / minorSegments);
|
||||
|
||||
let idx = 0;
|
||||
for (let i = 0; i < majorSegments && idx < N_POINTS; i++) {
|
||||
const u = (i / majorSegments) * Math.PI * 2;
|
||||
|
||||
for (let j = 0; j < minorSegments && idx < N_POINTS; j++) {
|
||||
const v = (j / minorSegments) * Math.PI * 2;
|
||||
|
||||
points.push({
|
||||
x: (major + minor * Math.cos(v)) * Math.cos(u),
|
||||
y: (major + minor * Math.cos(v)) * Math.sin(u),
|
||||
z: minor * Math.sin(v),
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill missing points if necessary
|
||||
while (points.length < N_POINTS) {
|
||||
const t = points.length / N_POINTS;
|
||||
const u = t * Math.PI * 2 * majorSegments;
|
||||
const v = t * Math.PI * 2 * minorSegments;
|
||||
points.push({
|
||||
x: (major + minor * Math.cos(v)) * Math.cos(u),
|
||||
y: (major + minor * Math.cos(v)) * Math.sin(u),
|
||||
z: minor * Math.sin(v),
|
||||
});
|
||||
}
|
||||
|
||||
return points.slice(0, N_POINTS);
|
||||
};
|
||||
|
||||
export const TORUS_POINTS = scaleShape(
|
||||
normalizeShape(generateTorusPoints(50, 25)),
|
||||
1.2,
|
||||
);
|
||||
1
src/components/onboarding/shapes/types.ts
Normal file
1
src/components/onboarding/shapes/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type Point3D = { x: number; y: number; z: number };
|
||||
54
src/components/onboarding/shapes/utils.ts
Normal file
54
src/components/onboarding/shapes/utils.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { GOLDEN_RATIO, TARGET_HEIGHT } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
|
||||
// Generate Fibonacci points on unit sphere, then map to shape
|
||||
export const fibonacciPoint = (
|
||||
i: number,
|
||||
total: number,
|
||||
): { theta: number; phi: number; t: number } => {
|
||||
const t = i / total;
|
||||
const theta = (2 * Math.PI * i) / GOLDEN_RATIO;
|
||||
const phi = Math.acos(1 - 2 * t);
|
||||
return { theta, phi, t };
|
||||
};
|
||||
|
||||
export const normalizeShape = (points: Point3D[]): Point3D[] => {
|
||||
// Find min/max for each axis
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity;
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity;
|
||||
let minZ = Infinity,
|
||||
maxZ = -Infinity;
|
||||
|
||||
for (const p of points) {
|
||||
minX = Math.min(minX, p.x);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
minZ = Math.min(minZ, p.z);
|
||||
maxZ = Math.max(maxZ, p.z);
|
||||
}
|
||||
|
||||
// Calculate current dimensions
|
||||
const currentHeight = maxY - minY;
|
||||
const scale = TARGET_HEIGHT / currentHeight;
|
||||
|
||||
// Center and scale uniformly
|
||||
const centerY = (minY + maxY) / 2;
|
||||
|
||||
return points.map(p => ({
|
||||
x: (p.x - (minX + maxX) / 2) * scale,
|
||||
y: (p.y - centerY) * scale,
|
||||
z: (p.z - (minZ + maxZ) / 2) * scale,
|
||||
}));
|
||||
};
|
||||
|
||||
// Additional scale for single shape
|
||||
export const scaleShape = (points: Point3D[], factor: number): Point3D[] => {
|
||||
return points.map(p => ({
|
||||
x: p.x * factor,
|
||||
y: p.y * factor,
|
||||
z: p.z * factor,
|
||||
}));
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue