continue watching changes
|
|
@ -94,8 +94,8 @@ android {
|
|||
applicationId 'com.nuvio.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20
|
||||
versionName "1.2.5"
|
||||
versionCode 21
|
||||
versionName "1.2.6"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ android {
|
|||
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
def baseVersionCode = 20 // Current versionCode from defaultConfig
|
||||
def baseVersionCode = 21 // Current versionCode 21 from defaultConfig
|
||||
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
|
||||
def versionCode = baseVersionCode * 100 // Base multiplier
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 173 KiB |
|
|
@ -3,5 +3,5 @@
|
|||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
|
||||
<string name="expo_runtime_version">1.2.5</string>
|
||||
<string name="expo_runtime_version">1.2.6</string>
|
||||
</resources>
|
||||
8
app.json
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Nuvio",
|
||||
"slug": "nuvio",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"orientation": "default",
|
||||
"backgroundColor": "#020404",
|
||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||
"buildNumber": "20",
|
||||
"buildNumber": "21",
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
"WAKE_LOCK"
|
||||
],
|
||||
"package": "com.nuvio.app",
|
||||
"versionCode": 20,
|
||||
"versionCode": 21,
|
||||
"architectures": [
|
||||
"arm64-v8a",
|
||||
"armeabi-v7a",
|
||||
|
|
@ -95,6 +95,6 @@
|
|||
"fallbackToCacheTimeout": 30000,
|
||||
"url": "https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest"
|
||||
},
|
||||
"runtimeVersion": "1.2.5"
|
||||
"runtimeVersion": "1.2.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -460,7 +460,7 @@
|
|||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 1.2 MiB |
|
|
@ -19,7 +19,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.5</string>
|
||||
<string>1.2.6</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20</string>
|
||||
<string>21</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<key>EXUpdatesLaunchWaitMs</key>
|
||||
<integer>30000</integer>
|
||||
<key>EXUpdatesRuntimeVersion</key>
|
||||
<string>1.2.5</string>
|
||||
<string>1.2.6</string>
|
||||
<key>EXUpdatesURL</key>
|
||||
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
|
||||
</dict>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import * as Haptics from 'expo-haptics';
|
|||
import { TraktService } from '../../services/traktService';
|
||||
import { stremioService } from '../../services/stremioService';
|
||||
import { streamCacheService } from '../../services/streamCacheService';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import CustomAlert from '../../components/CustomAlert';
|
||||
|
||||
// Define interface for continue watching items
|
||||
|
|
@ -99,6 +100,7 @@ const isEpisodeReleased = (video: any): boolean => {
|
|||
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const [continueWatchingItems, setContinueWatchingItems] = useState<ContinueWatchingItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const appState = useRef(AppState.currentState);
|
||||
|
|
@ -656,6 +658,45 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
try {
|
||||
logger.log(`🎬 [ContinueWatching] User clicked on: ${item.name} (${item.type}:${item.id})`);
|
||||
|
||||
// Check if cached streams are enabled in settings
|
||||
if (!settings.useCachedStreams) {
|
||||
logger.log(`📺 [ContinueWatching] Cached streams disabled, navigating to ${settings.openMetadataScreenWhenCacheDisabled ? 'MetadataScreen' : 'StreamsScreen'} for ${item.name}`);
|
||||
|
||||
// Navigate based on the second setting
|
||||
if (settings.openMetadataScreenWhenCacheDisabled) {
|
||||
// Navigate to MetadataScreen
|
||||
if (item.type === 'series' && item.season && item.episode) {
|
||||
const episodeId = `${item.id}:${item.season}:${item.episode}`;
|
||||
navigation.navigate('Metadata', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId
|
||||
});
|
||||
} else {
|
||||
navigation.navigate('Metadata', {
|
||||
id: item.id,
|
||||
type: item.type
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Navigate to StreamsScreen
|
||||
if (item.type === 'series' && item.season && item.episode) {
|
||||
const episodeId = `${item.id}:${item.season}:${item.episode}`;
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId
|
||||
});
|
||||
} else {
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have a cached stream for this content
|
||||
const episodeId = item.type === 'series' && item.season && item.episode
|
||||
? `${item.id}:${item.season}:${item.episode}`
|
||||
|
|
@ -730,7 +771,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
});
|
||||
}
|
||||
}
|
||||
}, [navigation]);
|
||||
}, [navigation, settings.useCachedStreams, settings.openMetadataScreenWhenCacheDisabled]);
|
||||
|
||||
// Handle long press to delete (moved before renderContinueWatchingItem)
|
||||
const handleLongPress = useCallback((item: ContinueWatchingItem) => {
|
||||
|
|
|
|||
|
|
@ -335,7 +335,7 @@ const ActionButtons = memo(({
|
|||
return isWatched ? 'Play' : playButtonText;
|
||||
}, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]);
|
||||
|
||||
// Determine if we should show buttons in a single row (Play, Save, and one other button = 3 total)
|
||||
// Determine if we should show buttons in a single row (Play, Save, and optionally one other button)
|
||||
const hasAiChat = aiChatEnabled;
|
||||
const hasTraktCollection = isAuthenticated;
|
||||
const hasRatings = type === 'series';
|
||||
|
|
@ -343,16 +343,20 @@ const ActionButtons = memo(({
|
|||
// Count additional buttons (excluding Play and Save)
|
||||
const additionalButtonCount = (hasAiChat ? 1 : 0) + (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0);
|
||||
|
||||
// Show single row when there's exactly 1 additional button (3 total buttons)
|
||||
const shouldShowSingleRow = additionalButtonCount === 1;
|
||||
// Show single row when there are 0 additional buttons (2 total: Play + Save) or 1 additional button (3 total)
|
||||
const shouldShowSingleRow = additionalButtonCount <= 1;
|
||||
|
||||
return (
|
||||
<Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
|
||||
{shouldShowSingleRow ? (
|
||||
/* Single Row Layout - Play, Save, and one other button (3 total) */
|
||||
/* Single Row Layout - Play, Save, and optionally one other button (2-3 total) */
|
||||
<View style={styles.singleRowLayout}>
|
||||
<TouchableOpacity
|
||||
style={[playButtonStyle, isTablet && styles.tabletPlayButton, styles.singleRowPlayButton]}
|
||||
style={[
|
||||
playButtonStyle,
|
||||
isTablet && styles.tabletPlayButton,
|
||||
additionalButtonCount === 0 ? styles.singleRowPlayButtonFullWidth : styles.singleRowPlayButton
|
||||
]}
|
||||
onPress={handleShowStreams}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
|
|
@ -370,7 +374,12 @@ const ActionButtons = memo(({
|
|||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.infoButton, isTablet && styles.tabletInfoButton, styles.singleRowSaveButton]}
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.infoButton,
|
||||
isTablet && styles.tabletInfoButton,
|
||||
additionalButtonCount === 0 ? styles.singleRowSaveButtonFullWidth : styles.singleRowSaveButton
|
||||
]}
|
||||
onPress={handleSaveAction}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
|
|
@ -396,8 +405,8 @@ const ActionButtons = memo(({
|
|||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Third Button - AI Chat, Trakt Collection, or Ratings */}
|
||||
{hasAiChat && (
|
||||
{/* Third Button - AI Chat, Trakt Collection, or Ratings (only if available) */}
|
||||
{hasAiChat && additionalButtonCount === 1 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
|
||||
onPress={() => {
|
||||
|
|
@ -444,7 +453,7 @@ const ActionButtons = memo(({
|
|||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{hasTraktCollection && !hasAiChat && (
|
||||
{hasTraktCollection && !hasAiChat && additionalButtonCount === 1 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
|
||||
onPress={handleCollectionAction}
|
||||
|
|
@ -470,7 +479,7 @@ const ActionButtons = memo(({
|
|||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{hasRatings && !hasAiChat && !hasTraktCollection && (
|
||||
{hasRatings && !hasAiChat && !hasTraktCollection && additionalButtonCount === 1 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
|
||||
onPress={handleRatingsPress}
|
||||
|
|
@ -2152,6 +2161,14 @@ const styles = StyleSheet.create({
|
|||
borderRadius: isTablet ? 25 : 22,
|
||||
flex: 0,
|
||||
},
|
||||
singleRowPlayButtonFullWidth: {
|
||||
flex: 1,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
singleRowSaveButtonFullWidth: {
|
||||
flex: 1,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
primaryActionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ export interface AppSettings {
|
|||
useTmdbLocalizedMetadata: boolean; // Use TMDB localized metadata (titles, overviews) per tmdbLanguagePreference
|
||||
// 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
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
|
|
@ -137,6 +140,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
useTmdbLocalizedMetadata: false,
|
||||
// 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
|
||||
};
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'app_settings';
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import AISettingsScreen from '../screens/AISettingsScreen';
|
|||
import AIChatScreen from '../screens/AIChatScreen';
|
||||
import BackdropGalleryScreen from '../screens/BackdropGalleryScreen';
|
||||
import BackupScreen from '../screens/BackupScreen';
|
||||
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
|
||||
|
||||
// Stack navigator types
|
||||
export type RootStackParamList = {
|
||||
|
|
@ -173,6 +174,7 @@ export type RootStackParamList = {
|
|||
type: 'movie' | 'tv';
|
||||
title: string;
|
||||
};
|
||||
ContinueWatchingSettings: undefined;
|
||||
};
|
||||
|
||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
|
@ -1266,6 +1268,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ContinueWatchingSettings"
|
||||
component={ContinueWatchingSettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'default',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="HeroCatalogs"
|
||||
component={HeroCatalogsScreen}
|
||||
|
|
|
|||
309
src/screens/ContinueWatchingSettingsScreen.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Platform,
|
||||
Switch,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
||||
const ContinueWatchingSettingsScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
const [showSavedIndicator, setShowSavedIndicator] = useState(false);
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Prevent iOS entrance flicker by restoring a non-translucent StatusBar
|
||||
useEffect(() => {
|
||||
try {
|
||||
StatusBar.setTranslucent(false);
|
||||
StatusBar.setBackgroundColor(colors.darkBackground);
|
||||
StatusBar.setBarStyle('light-content');
|
||||
if (Platform.OS === 'ios') {
|
||||
StatusBar.setHidden(false);
|
||||
}
|
||||
} catch {}
|
||||
}, [colors.darkBackground]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigation.goBack();
|
||||
}, [navigation]);
|
||||
|
||||
// Fade in/out animation for the "Changes saved" indicator
|
||||
useEffect(() => {
|
||||
if (showSavedIndicator) {
|
||||
Animated.sequence([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.delay(1000),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true
|
||||
})
|
||||
]).start(() => setShowSavedIndicator(false));
|
||||
}
|
||||
}, [showSavedIndicator, fadeAnim]);
|
||||
|
||||
const handleUpdateSetting = useCallback(<K extends keyof typeof settings>(
|
||||
key: K,
|
||||
value: typeof settings[K]
|
||||
) => {
|
||||
updateSetting(key, value);
|
||||
setShowSavedIndicator(true);
|
||||
}, [updateSetting]);
|
||||
|
||||
const CustomSwitch = ({ value, onValueChange }: { value: boolean; onValueChange: (value: boolean) => void }) => (
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: colors.elevation2, true: colors.primary }}
|
||||
thumbColor={value ? colors.white : colors.mediumEmphasis}
|
||||
ios_backgroundColor={colors.elevation2}
|
||||
/>
|
||||
);
|
||||
|
||||
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,
|
||||
backgroundColor: colors.elevation1
|
||||
}
|
||||
]}>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={[styles.settingTitle, { color: colors.highEmphasis }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: colors.mediumEmphasis }]}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
<CustomSwitch value={value} onValueChange={onValueChange} />
|
||||
</View>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<View style={[styles.sectionHeader, { backgroundColor: colors.darkBackground }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.highEmphasis }]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { backgroundColor: colors.darkBackground }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={handleBack}
|
||||
>
|
||||
<MaterialIcons name="chevron-left" size={28} color={colors.primary} />
|
||||
<Text style={[styles.backText, { color: colors.primary }]}>Settings</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: colors.highEmphasis }]}>
|
||||
Continue Watching
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<SectionHeader title="PLAYBACK BEHAVIOR" />
|
||||
|
||||
<View style={[styles.settingsCard, { backgroundColor: colors.elevation1 }]}>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.infoCard, { backgroundColor: colors.elevation1 }]}>
|
||||
<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 }]}>
|
||||
• Streams are cached for 1 hour 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
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Saved indicator */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.savedIndicator,
|
||||
{
|
||||
backgroundColor: colors.primary,
|
||||
opacity: fadeAnim
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="check" size={20} color={colors.white} />
|
||||
<Text style={styles.savedText}>Changes saved</Text>
|
||||
</Animated.View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingTop: Platform.OS === 'ios' ? 0 : 12,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
backText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginLeft: 4,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
sectionHeader: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingTop: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
settingsCard: {
|
||||
marginHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
settingContent: {
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
infoCard: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
infoHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
savedIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
left: 16,
|
||||
right: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
},
|
||||
savedText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default ContinueWatchingSettingsScreen;
|
||||
|
|
@ -483,6 +483,14 @@ const SettingsScreen: React.FC = () => {
|
|||
icon="home"
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('HomeScreenSettings')}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Continue Watching"
|
||||
description="Cache and playback behavior"
|
||||
icon="play-circle"
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('ContinueWatchingSettings')}
|
||||
isLast={true}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1556,7 +1556,7 @@ export const StreamsScreen = () => {
|
|||
];
|
||||
}, [availableProviders, type, episodeStreams, groupedStreams, settings.streamDisplayMode]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null> = useMemo(() => {
|
||||
const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams;
|
||||
const installedAddons = stremioService.getInstalledAddons();
|
||||
|
||||
|
|
@ -1648,12 +1648,7 @@ export const StreamsScreen = () => {
|
|||
const isEmptyDueToQualityFilter = totalOriginalCount > 0 && totalStreamsCount === 0;
|
||||
|
||||
if (isEmptyDueToQualityFilter) {
|
||||
return [{
|
||||
title: 'Available Streams',
|
||||
addonId: 'grouped-all',
|
||||
data: [{ isEmptyPlaceholder: true } as any],
|
||||
isEmptyDueToQualityFilter: true
|
||||
}];
|
||||
return []; // Return empty array instead of showing placeholder
|
||||
}
|
||||
|
||||
// Combine streams: Addons first (unsorted), then sorted plugins
|
||||
|
|
@ -1780,12 +1775,7 @@ export const StreamsScreen = () => {
|
|||
}
|
||||
|
||||
if (isEmptyDueToQualityFilter) {
|
||||
return {
|
||||
title: addonName,
|
||||
addonId,
|
||||
data: [{ isEmptyPlaceholder: true } as any],
|
||||
isEmptyDueToQualityFilter
|
||||
};
|
||||
return null; // Return null to exclude this section completely
|
||||
}
|
||||
|
||||
let processedStreams = filteredStreams;
|
||||
|
|
@ -1861,7 +1851,7 @@ export const StreamsScreen = () => {
|
|||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
}).filter(Boolean); // Filter out null values
|
||||
}
|
||||
}, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, addonResponseOrder, settings.streamSortMode, selectedEpisode, metadata]);
|
||||
|
||||
|
|
@ -1869,11 +1859,11 @@ export const StreamsScreen = () => {
|
|||
React.useEffect(() => {
|
||||
console.log('🔍 [StreamsScreen] Final sections:', {
|
||||
sectionsCount: sections.length,
|
||||
sections: sections.map(s => ({
|
||||
title: s.title,
|
||||
addonId: s.addonId,
|
||||
dataCount: s.data?.length || 0,
|
||||
isEmptyDueToQualityFilter: s.isEmptyDueToQualityFilter
|
||||
sections: sections.filter(Boolean).map(s => ({
|
||||
title: s!.title,
|
||||
addonId: s!.addonId,
|
||||
dataCount: s!.data?.length || 0,
|
||||
isEmptyDueToQualityFilter: s!.isEmptyDueToQualityFilter
|
||||
}))
|
||||
});
|
||||
}, [sections]);
|
||||
|
|
@ -2206,15 +2196,15 @@ export const StreamsScreen = () => {
|
|||
scrollEventThrottle: 16,
|
||||
})}
|
||||
>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<View key={section.addonId || sectionIndex}>
|
||||
{sections.filter(Boolean).map((section, sectionIndex) => (
|
||||
<View key={section!.addonId || sectionIndex}>
|
||||
{/* Section Header */}
|
||||
{renderSectionHeader({ section })}
|
||||
{renderSectionHeader({ section: section! })}
|
||||
|
||||
{/* Stream Cards using FlatList */}
|
||||
{section.data && section.data.length > 0 ? (
|
||||
{section!.data && section!.data.length > 0 ? (
|
||||
<FlatList
|
||||
data={section.data}
|
||||
data={section!.data}
|
||||
keyExtractor={(item, index) => {
|
||||
if (item && item.url) {
|
||||
return `${item.url}-${sectionIndex}-${index}`;
|
||||
|
|
@ -2257,20 +2247,7 @@ export const StreamsScreen = () => {
|
|||
index,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
// Empty section placeholder
|
||||
<View style={styles.emptySectionContainer}>
|
||||
<View style={styles.emptySectionContent}>
|
||||
<MaterialIcons name="filter-list-off" size={32} color={colors.mediumEmphasis} />
|
||||
<Text style={[styles.emptySectionTitle, { color: colors.mediumEmphasis }]}>
|
||||
No streams available
|
||||
</Text>
|
||||
<Text style={[styles.emptySectionSubtitle, { color: colors.textMuted }]}>
|
||||
All streams were filtered by your quality settings
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
|
||||
|
|
@ -2805,28 +2782,6 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
fontSize: 11,
|
||||
fontWeight: '400',
|
||||
},
|
||||
emptySectionContainer: {
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 80,
|
||||
},
|
||||
emptySectionContent: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptySectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptySectionSubtitle: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default memo(StreamsScreen);
|
||||
|
|
|
|||
|
|
@ -92,14 +92,9 @@ class StreamCacheService {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Validate that the stream URL is still accessible (quick HEAD request)
|
||||
logger.log(`🔍 [StreamCache] Validating stream URL: ${cacheEntry.cachedStream.url}`);
|
||||
const isUrlValid = await this.validateStreamUrl(cacheEntry.cachedStream.url);
|
||||
if (!isUrlValid) {
|
||||
logger.log(`❌ [StreamCache] Stream URL invalid for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
|
||||
await this.removeCachedStream(id, type, episodeId);
|
||||
return null;
|
||||
}
|
||||
// Skip URL validation for now - many CDNs block HEAD requests
|
||||
// This was causing valid streams to be rejected
|
||||
logger.log(`🔍 [StreamCache] Skipping URL validation (CDN compatibility)`);
|
||||
|
||||
logger.log(`✅ [StreamCache] Using cached stream for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
|
||||
return cacheEntry.cachedStream;
|
||||
|
|
@ -131,7 +126,7 @@ class StreamCacheService {
|
|||
const cacheKeys = allKeys.filter(key => key.startsWith(CACHE_KEY_PREFIX));
|
||||
|
||||
for (const key of cacheKeys) {
|
||||
await storageService.removeItem(key);
|
||||
await AsyncStorage.removeItem(key);
|
||||
}
|
||||
|
||||
logger.log(`🧹 [StreamCache] Cleared ${cacheKeys.length} cached streams`);
|
||||
|
|
@ -173,8 +168,8 @@ class StreamCacheService {
|
|||
*/
|
||||
async getCacheInfo(): Promise<{ totalCached: number; expiredCount: number; validCount: number }> {
|
||||
try {
|
||||
const allKeys = await storageService.getAllKeys();
|
||||
const cacheKeys = allKeys.filter(key => key.startsWith(CACHE_KEY_PREFIX));
|
||||
const allKeys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = allKeys.filter((key: string) => key.startsWith(CACHE_KEY_PREFIX));
|
||||
|
||||
let expiredCount = 0;
|
||||
let validCount = 0;
|
||||
|
|
@ -182,7 +177,7 @@ class StreamCacheService {
|
|||
|
||||
for (const key of cacheKeys) {
|
||||
try {
|
||||
const cachedData = await storageService.getItem(key);
|
||||
const cachedData = await AsyncStorage.getItem(key);
|
||||
if (cachedData) {
|
||||
const cacheEntry: StreamCacheEntry = JSON.parse(cachedData);
|
||||
if (now > cacheEntry.expiresAt) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Single source of truth for the app version displayed in Settings
|
||||
// Update this when bumping app version
|
||||
|
||||
export const APP_VERSION = '1.2.5';
|
||||
export const APP_VERSION = '1.2.6';
|
||||
|
||||
export function getDisplayedAppVersion(): string {
|
||||
return APP_VERSION;
|
||||
|
|
|
|||