mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
addes scrolltotop by clicking tab navigation buttons
This commit is contained in:
parent
d39a485d24
commit
95e7d44035
7 changed files with 206 additions and 10 deletions
57
src/contexts/ScrollToTopContext.tsx
Normal file
57
src/contexts/ScrollToTopContext.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import React, { createContext, useContext, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
type ScrollToTopListener = () => void;
|
||||
|
||||
interface ScrollToTopContextType {
|
||||
emitScrollToTop: (routeName: string) => void;
|
||||
subscribe: (routeName: string, listener: ScrollToTopListener) => () => void;
|
||||
}
|
||||
|
||||
const ScrollToTopContext = createContext<ScrollToTopContextType | null>(null);
|
||||
|
||||
export const ScrollToTopProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const listenersRef = useRef<Map<string, Set<ScrollToTopListener>>>(new Map());
|
||||
|
||||
const subscribe = useCallback((routeName: string, listener: ScrollToTopListener) => {
|
||||
if (!listenersRef.current.has(routeName)) {
|
||||
listenersRef.current.set(routeName, new Set());
|
||||
}
|
||||
listenersRef.current.get(routeName)!.add(listener);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
listenersRef.current.get(routeName)?.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const emitScrollToTop = useCallback((routeName: string) => {
|
||||
const listeners = listenersRef.current.get(routeName);
|
||||
if (listeners) {
|
||||
listeners.forEach(listener => listener());
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollToTopContext.Provider value={{ emitScrollToTop, subscribe }}>
|
||||
{children}
|
||||
</ScrollToTopContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useScrollToTop = (routeName: string, scrollToTop: () => void) => {
|
||||
const context = useContext(ScrollToTopContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!context) return;
|
||||
|
||||
const unsubscribe = context.subscribe(routeName, scrollToTop);
|
||||
return unsubscribe;
|
||||
}, [context, routeName, scrollToTop]);
|
||||
};
|
||||
|
||||
export const useScrollToTopEmitter = () => {
|
||||
const context = useContext(ScrollToTopContext);
|
||||
return context?.emitScrollToTop || (() => { });
|
||||
};
|
||||
|
||||
export default ScrollToTopContext;
|
||||
|
|
@ -16,6 +16,7 @@ import { Stream } from '../types/streams';
|
|||
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { PostHogProvider } from 'posthog-react-native';
|
||||
import { ScrollToTopProvider, useScrollToTopEmitter } from '../contexts/ScrollToTopContext';
|
||||
|
||||
// Optional iOS Glass effect (expo-glass-effect) with safe fallback
|
||||
let GlassViewComp: any = null;
|
||||
|
|
@ -581,6 +582,7 @@ const MainTabs = () => {
|
|||
const isIosTablet = Platform.OS === 'ios' && isTablet;
|
||||
const [hidden, setHidden] = React.useState(HeaderVisibility.isHidden());
|
||||
React.useEffect(() => HeaderVisibility.subscribe(setHidden), []);
|
||||
const emitScrollToTop = useScrollToTopEmitter();
|
||||
// Smooth animate header hide/show
|
||||
const headerAnim = React.useRef(new Animated.Value(0)).current; // 0: shown, 1: hidden
|
||||
React.useEffect(() => {
|
||||
|
|
@ -674,7 +676,10 @@ const MainTabs = () => {
|
|||
target: route.key,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
if (!isFocused && !event.defaultPrevented) {
|
||||
if (isFocused) {
|
||||
// Same tab pressed - emit scroll to top
|
||||
emitScrollToTop(route.name);
|
||||
} else if (!event.defaultPrevented) {
|
||||
props.navigation.navigate(route.name);
|
||||
}
|
||||
};
|
||||
|
|
@ -789,7 +794,10 @@ const MainTabs = () => {
|
|||
canPreventDefault: true,
|
||||
});
|
||||
|
||||
if (!isFocused && !event.defaultPrevented) {
|
||||
if (isFocused) {
|
||||
// Same tab pressed - emit scroll to top
|
||||
emitScrollToTop(route.name);
|
||||
} else if (!event.defaultPrevented) {
|
||||
props.navigation.navigate(route.name);
|
||||
}
|
||||
};
|
||||
|
|
@ -893,6 +901,13 @@ const MainTabs = () => {
|
|||
tabBarIcon: () => ({ sfSymbol: 'house' }),
|
||||
freezeOnBlur: true,
|
||||
}}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
if (navigation.isFocused()) {
|
||||
emitScrollToTop('Home');
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<IOSTab.Screen
|
||||
name="Library"
|
||||
|
|
@ -901,6 +916,13 @@ const MainTabs = () => {
|
|||
title: 'Library',
|
||||
tabBarIcon: () => ({ sfSymbol: 'heart' }),
|
||||
}}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
if (navigation.isFocused()) {
|
||||
emitScrollToTop('Library');
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<IOSTab.Screen
|
||||
name="Search"
|
||||
|
|
@ -909,6 +931,13 @@ const MainTabs = () => {
|
|||
title: 'Search',
|
||||
tabBarIcon: () => ({ sfSymbol: 'magnifyingglass' }),
|
||||
}}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
if (navigation.isFocused()) {
|
||||
emitScrollToTop('Search');
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{downloadsEnabled && (
|
||||
<IOSTab.Screen
|
||||
|
|
@ -918,6 +947,13 @@ const MainTabs = () => {
|
|||
title: 'Downloads',
|
||||
tabBarIcon: () => ({ sfSymbol: 'arrow.down.circle' }),
|
||||
}}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
if (navigation.isFocused()) {
|
||||
emitScrollToTop('Downloads');
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<IOSTab.Screen
|
||||
|
|
@ -927,6 +963,13 @@ const MainTabs = () => {
|
|||
title: 'Settings',
|
||||
tabBarIcon: () => ({ sfSymbol: 'gear' }),
|
||||
}}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
if (navigation.isFocused()) {
|
||||
emitScrollToTop('Settings');
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</IOSTab.Navigator>
|
||||
</View>
|
||||
|
|
@ -1612,9 +1655,11 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack
|
|||
host: "https://us.i.posthog.com",
|
||||
}}
|
||||
>
|
||||
<LoadingProvider>
|
||||
<InnerNavigator initialRouteName={initialRouteName} />
|
||||
</LoadingProvider>
|
||||
<ScrollToTopProvider>
|
||||
<LoadingProvider>
|
||||
<InnerNavigator initialRouteName={initialRouteName} />
|
||||
</LoadingProvider>
|
||||
</ScrollToTopProvider>
|
||||
</PostHogProvider>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import React, { useCallback, useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -35,6 +35,7 @@ import type { DownloadItem } from '../contexts/DownloadsContext';
|
|||
import { useToast } from '../contexts/ToastContext';
|
||||
import CustomAlert from '../components/CustomAlert';
|
||||
import ScreenHeader from '../components/common/ScreenHeader';
|
||||
import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
||||
|
||||
const { height, width } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
|
@ -355,6 +356,14 @@ const DownloadsScreen: React.FC = () => {
|
|||
const [showHelpAlert, setShowHelpAlert] = useState(false);
|
||||
const [showRemoveAlert, setShowRemoveAlert] = useState(false);
|
||||
const [pendingRemoveItem, setPendingRemoveItem] = useState<DownloadItem | null>(null);
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
// Scroll to top handler
|
||||
const scrollToTop = useCallback(() => {
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
}, []);
|
||||
|
||||
useScrollToTop('Downloads', scrollToTop);
|
||||
|
||||
// Filter downloads based on selected filter
|
||||
const filteredDownloads = useMemo(() => {
|
||||
|
|
@ -656,6 +665,7 @@ const DownloadsScreen: React.FC = () => {
|
|||
<EmptyDownloadsState navigation={navigation} />
|
||||
) : (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={filteredDownloads}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ import { useToast } from '../contexts/ToastContext';
|
|||
import FirstTimeWelcome from '../components/FirstTimeWelcome';
|
||||
import { HeaderVisibility } from '../contexts/HeaderVisibility';
|
||||
import { useTrailer } from '../contexts/TrailerContext';
|
||||
import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
||||
|
||||
// Constants
|
||||
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
||||
|
|
@ -137,6 +138,25 @@ const HomeScreen = () => {
|
|||
const totalCatalogsRef = useRef(0);
|
||||
const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory
|
||||
const insets = useSafeAreaInsets();
|
||||
const flashListRef = useRef<any>(null);
|
||||
|
||||
// Scroll to top handler - use scrollToIndex and retry to handle re-renders
|
||||
const scrollToTop = useCallback(() => {
|
||||
// First attempt
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
|
||||
// Retry after a short delay in case re-render interrupted the scroll
|
||||
setTimeout(() => {
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
}, 150);
|
||||
|
||||
// Final retry to ensure we're at the top
|
||||
setTimeout(() => {
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
useScrollToTop('Home', scrollToTop);
|
||||
|
||||
// Stabilize insets to prevent iOS layout shifts
|
||||
const [stableInsetsTop, setStableInsetsTop] = useState(insets.top);
|
||||
|
|
@ -890,6 +910,7 @@ const HomeScreen = () => {
|
|||
translucent
|
||||
/>
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
data={listData}
|
||||
renderItem={renderListItem}
|
||||
keyExtractor={keyExtractor}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { DeviceEventEmitter } from 'react-native';
|
||||
import { Share } from 'react-native';
|
||||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
|
|
@ -38,6 +38,7 @@ import TraktIcon from '../../assets/rating-icons/trakt.svg';
|
|||
import { traktService, TraktService, TraktImages } from '../services/traktService';
|
||||
import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
||||
|
||||
interface LibraryItem extends StreamingContent {
|
||||
progress?: number;
|
||||
|
|
@ -225,6 +226,14 @@ const LibraryScreen = () => {
|
|||
const insets = useSafeAreaInsets();
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const flashListRef = useRef<any>(null);
|
||||
|
||||
// Scroll to top handler
|
||||
const scrollToTop = useCallback(() => {
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
}, []);
|
||||
|
||||
useScrollToTop('Library', scrollToTop);
|
||||
|
||||
const {
|
||||
isAuthenticated: traktAuthenticated,
|
||||
|
|
@ -733,6 +742,7 @@ const LibraryScreen = () => {
|
|||
|
||||
return (
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
data={traktFolders}
|
||||
renderItem={({ item }) => renderTraktCollectionFolder({ folder: item })}
|
||||
keyExtractor={item => item.id}
|
||||
|
|
@ -774,6 +784,7 @@ const LibraryScreen = () => {
|
|||
|
||||
return (
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
data={folderItems}
|
||||
renderItem={({ item }) => renderTraktItem({ item })}
|
||||
keyExtractor={(item) => `${item.type}-${item.id}`}
|
||||
|
|
@ -874,6 +885,7 @@ const LibraryScreen = () => {
|
|||
|
||||
return (
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
data={filteredItems}
|
||||
renderItem={({ item }) => renderItem({ item: item as LibraryItem })}
|
||||
keyExtractor={item => item.id}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import LoadingSpinner from '../components/common/LoadingSpinner';
|
||||
import ScreenHeader from '../components/common/ScreenHeader';
|
||||
import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
|
|
@ -237,6 +238,14 @@ const SearchScreen = () => {
|
|||
const isInitialMount = useRef(true);
|
||||
// Track mount status for async operations
|
||||
const isMounted = useRef(true);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
// Scroll to top handler
|
||||
const scrollToTop = useCallback(() => {
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
}, []);
|
||||
|
||||
useScrollToTop('Search', scrollToTop);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
|
@ -994,6 +1003,7 @@ const SearchScreen = () => {
|
|||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollViewContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -40,6 +40,7 @@ import TraktIcon from '../components/icons/TraktIcon';
|
|||
import TMDBIcon from '../components/icons/TMDBIcon';
|
||||
import MDBListIcon from '../components/icons/MDBListIcon';
|
||||
import { campaignService } from '../services/campaignService';
|
||||
import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
|
@ -320,6 +321,17 @@ const SettingsScreen: React.FC = () => {
|
|||
const [displayDownloads, setDisplayDownloads] = useState<number | null>(null);
|
||||
const [isCountingUp, setIsCountingUp] = useState<boolean>(false);
|
||||
|
||||
// Scroll to top ref and handler
|
||||
const mobileScrollViewRef = useRef<ScrollView>(null);
|
||||
const tabletScrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const scrollToTop = useCallback(() => {
|
||||
mobileScrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
tabletScrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
}, []);
|
||||
|
||||
useScrollToTop('Settings', scrollToTop);
|
||||
|
||||
// Add a useEffect to check Trakt authentication status on focus
|
||||
useEffect(() => {
|
||||
// This will reload the Trakt auth status whenever the settings screen is focused
|
||||
|
|
@ -923,6 +935,7 @@ const SettingsScreen: React.FC = () => {
|
|||
}
|
||||
]}>
|
||||
<ScrollView
|
||||
ref={tabletScrollViewRef}
|
||||
style={styles.tabletScrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.tabletScrollContent}
|
||||
|
|
@ -1005,6 +1018,14 @@ const SettingsScreen: React.FC = () => {
|
|||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.brandLogoContainer}>
|
||||
<FastImage
|
||||
source={require('../../assets/nuviotext.png')}
|
||||
style={styles.brandLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Made with ❤️ by Tapframe and Friends
|
||||
|
|
@ -1040,6 +1061,7 @@ const SettingsScreen: React.FC = () => {
|
|||
|
||||
<View style={styles.contentContainer}>
|
||||
<ScrollView
|
||||
ref={mobileScrollViewRef}
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
|
|
@ -1131,6 +1153,14 @@ const SettingsScreen: React.FC = () => {
|
|||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.brandLogoContainer}>
|
||||
<FastImage
|
||||
source={require('../../assets/nuviotext.png')}
|
||||
style={styles.brandLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Made with ❤️ by Tapframe and friends
|
||||
|
|
@ -1368,7 +1398,7 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 0,
|
||||
marginBottom: 12,
|
||||
marginBottom: 48,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 13,
|
||||
|
|
@ -1437,12 +1467,23 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 0,
|
||||
marginBottom: 32,
|
||||
marginBottom: 16,
|
||||
},
|
||||
monkeyAnimation: {
|
||||
width: 180,
|
||||
height: 180,
|
||||
},
|
||||
brandLogoContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 0,
|
||||
marginBottom: 16,
|
||||
opacity: 0.8,
|
||||
},
|
||||
brandLogo: {
|
||||
width: 120,
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export default SettingsScreen;
|
||||
Loading…
Reference in a new issue