From 1727726223a978461261a008f47ced30bbeaf501 Mon Sep 17 00:00:00 2001 From: Kestas Venslauskas Date: Fri, 3 Jan 2025 01:27:02 +0200 Subject: [PATCH 1/7] feat: radioteka initial implementation --- App.tsx | 3 +- .../article/article/ListenCount.tsx | 7 +- .../tabScreen/radioteka/RadiotekaScreen.tsx | 43 ++++ .../components/hero/RadiotekaHero.tsx | 228 +++++++++++++++++ .../components/hero/RadiotekaHeroCarousel.tsx | 235 ++++++++++++++++++ .../radioteka/components/hero/mockData.ts | 32 +++ .../RadiotekaHorizontalCategoryList.tsx | 75 ++++++ .../RadiotekaHorizontalList.tsx | 151 +++++++++++ .../RadiotekaHorizontalSelectableList.tsx | 153 ++++++++++++ 9 files changed, 923 insertions(+), 4 deletions(-) create mode 100644 app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx create mode 100644 app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx create mode 100644 app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx create mode 100644 app/screens/main/tabScreen/radioteka/components/hero/mockData.ts create mode 100644 app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx create mode 100644 app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx create mode 100644 app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalSelectableList.tsx diff --git a/App.tsx b/App.tsx index 241f0eb..58b73ee 100644 --- a/App.tsx +++ b/App.tsx @@ -17,6 +17,7 @@ import {runArticleStorageMigration} from './app/state/article_storage_store'; import {runOnboardingStorageMigration} from './app/screens/main/useOnboardingLogic'; import {runFirebaseTopicSubsriptionMigration} from './app/util/useFirebaseTopicSubscription'; import {runSettingsStorageMigration} from './app/state/settings_store'; +import RadiotekaScreen from './app/screens/main/tabScreen/radioteka/RadiotekaScreen'; enableFreeze(true); const App: React.FC = () => { @@ -37,7 +38,7 @@ const App: React.FC = () => { - + diff --git a/app/components/article/article/ListenCount.tsx b/app/components/article/article/ListenCount.tsx index f28f12f..f0d7e42 100644 --- a/app/components/article/article/ListenCount.tsx +++ b/app/components/article/article/ListenCount.tsx @@ -10,12 +10,13 @@ interface ListenCountProps { style?: ViewStyle; visible?: boolean; article: Article; + count?: number; } -const ListenCount: React.FC = ({style, visible = true, article}) => { +const ListenCount: React.FC = ({style, visible = true, article, count}) => { const {colors} = useTheme(); - if ((article.is_audio || article.article_type === ARTICLE_TYPE_AUDIO) && visible) { + if ((count || article.is_audio || article.article_type === ARTICLE_TYPE_AUDIO) && visible) { return ( = ({style, visible = true, article ]}> - {article.read_count} + {count ?? article.read_count} ); diff --git a/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx b/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx new file mode 100644 index 0000000..251cbad --- /dev/null +++ b/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import {View, StyleSheet} from 'react-native'; +import RadiotekaHero from './components/hero/RadiotekaHero'; +import RadiotekaHorizontalSelectableList from './components/horizontal_list/RadiotekaHorizontalSelectableList'; +import RadiotekaHorizontalCategoryList from './components/horizontal_list/RadiotekaHorizontalCategoryList'; +import {ScrollView} from 'react-native-gesture-handler'; +import {useTheme} from '../../../../Theme'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {RadiotekaHeroCarousel} from './components/hero/RadiotekaHeroCarousel'; +import {MOCK_CAROUSEL_ITEMS} from './components/hero/mockData'; + +const RadiotekaScreen: React.FC = () => { + const theme = useTheme(); + const {bottom} = useSafeAreaInsets(); + return ( + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + +export default RadiotekaScreen; diff --git a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx new file mode 100644 index 0000000..affe8e0 --- /dev/null +++ b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx @@ -0,0 +1,228 @@ +import React, {useState, useEffect} from 'react'; +import {View, Image, TouchableOpacity, StyleSheet, Dimensions, ScrollView} from 'react-native'; +import Animated, {useAnimatedStyle, withSpring, useSharedValue} from 'react-native-reanimated'; +import {Text} from '../../../../../../components'; +import ThemeProvider from '../../../../../../theme/ThemeProvider'; +import {themeLight} from '../../../../../../Theme'; +import {IconPlay} from '../../../../../../components/svg'; + +const {height} = Dimensions.get('window'); +const width = Math.min(Dimensions.get('window').width * 0.32, 150); + +const RadiotekaHero: React.FC = () => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const podcastItems = [ + { + id: 1, + title: 'LRT Aktualijų studija', + subtitle: 'Aktualios politinės, ekonominės ir socialinės temos', + image: 'https://placeholder.com/300x300', + backgroundColor: '#4A1515', + }, + { + id: 2, + title: 'ŠVIESI ATEITIS', + subtitle: 'Pokalbiai apie technologijas ir inovacijas', + image: 'https://placeholder.com/300x300', + backgroundColor: '#1A1A3A', + }, + { + id: 3, + title: 'Žaidžiam žmogų', + subtitle: 'Psichologijos ir saviugdos laida', + image: 'https://placeholder.com/300x300', + backgroundColor: '#FF8C42', + }, + { + id: 4, + title: 'Ryto garsai', + subtitle: 'Rytinė muzikos ir pokalbių laida', + image: 'https://placeholder.com/300x300', + backgroundColor: '#2E8B57', + }, + { + id: 5, + title: 'Kultūros savaitė', + subtitle: 'Savaitės kultūros įvykių apžvalga', + image: 'https://placeholder.com/300x300', + backgroundColor: '#4B0082', + }, + { + id: 6, + title: 'Muzikinis pastišas', + subtitle: 'Įvairių muzikos stilių rinkinys', + image: 'https://placeholder.com/300x300', + backgroundColor: '#8B4513', + }, + { + id: 7, + title: 'Vakaro pasaka', + subtitle: 'Pasakos vaikams ir suaugusiems', + image: 'https://placeholder.com/300x300', + backgroundColor: '#483D8B', + }, + ]; + + const scaleValues = podcastItems.map(() => useSharedValue(1)); + + useEffect(() => { + // Reset all scales to 1 + scaleValues.forEach((scale, index) => { + scale.value = withSpring(index === selectedIndex ? 1.1 : 1); + }); + }, [selectedIndex]); + + const getAnimatedStyle = (index: number) => { + return useAnimatedStyle(() => { + return { + transform: [{scale: scaleValues[index].value}], + borderWidth: 2, + borderColor: index === selectedIndex ? '#FFFFFF' : 'transparent', + }; + }); + }; + + const handleItemPress = (index: number) => { + setSelectedIndex(index); + }; + + return ( + + + + + {podcastItems[selectedIndex].title} + + + + + + 53 min. + + {podcastItems[selectedIndex].title} + + {podcastItems[selectedIndex].subtitle} + + + + + Klausytis + + + + Daugiau + + + + + {podcastItems.map((item, index) => ( + handleItemPress(index)}> + + + + + ))} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + height: height, + justifyContent: 'space-between', + paddingTop: 80, + paddingBottom: 40, + }, + header: { + paddingHorizontal: 12, + }, + headerText: { + color: '#FFFFFF', + fontSize: 19, + }, + mainContent: { + justifyContent: 'center', + }, + mainContentText: { + paddingHorizontal: 12, + }, + duration: { + color: '#FFFFFF', + fontSize: 13, + opacity: 0.8, + alignSelf: 'flex-start', + borderRadius: 3, + backgroundColor: '#000000A0', + paddingVertical: 3, + paddingHorizontal: 6, + }, + title: { + color: '#FFFFFF', + fontSize: 32, + marginVertical: 10, + }, + subtitle: { + color: '#FFFFFF', + fontSize: 17, + marginBottom: 30, + paddingVertical: 12, + }, + buttonContainer: { + flexDirection: 'row', + gap: 10, + }, + playButton: { + backgroundColor: '#FFD600', + flexDirection: 'row', + gap: 12, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 8, + }, + playButtonText: { + color: '#000000', + fontSize: 16, + }, + moreButton: { + backgroundColor: '#FFFFFF', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 16, + paddingHorizontal: 24, + borderRadius: 8, + }, + moreButtonText: { + color: '#000000', + fontSize: 16, + }, + bottomScrollView: {}, + bottomList: { + flexDirection: 'row', + paddingVertical: 32, + paddingHorizontal: 18, + gap: 16, + }, + thumbnailContainer: { + width: width, + aspectRatio: 1, + borderRadius: 10, + overflow: 'hidden', + }, + thumbnail: { + width: '100%', + height: '100%', + }, +}); + +export default RadiotekaHero; diff --git a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx new file mode 100644 index 0000000..4c328bb --- /dev/null +++ b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx @@ -0,0 +1,235 @@ +import React, {useState, useRef} from 'react'; +import { + View, + StyleSheet, + FlatList, + Image, + ViewToken, + useWindowDimensions, + TouchableOpacity, +} from 'react-native'; +import {RadiotekaItem} from '../horizontal_list/RadiotekaHorizontalList'; +import Text from '../../../../../../components/text/Text'; +import {ChannelClassicIcon, IconPlay} from '../../../../../../components/svg'; +import FastImage from 'react-native-fast-image'; +import ListenCount from '../../../../../../components/article/article/ListenCount'; + +interface RadiotekaHeroCarouselProps { + items: RadiotekaItem[]; + onItemPress?: (item: RadiotekaItem) => void; +} + +export const RadiotekaHeroCarousel: React.FC = ({items, onItemPress}) => { + const [activeIndex, setActiveIndex] = useState(0); + const flatListRef = useRef(null); + const {width} = useWindowDimensions(); + + const onViewableItemsChanged = useRef(({viewableItems}: {viewableItems: ViewToken[]}) => { + if (viewableItems[0] && typeof viewableItems[0].index === 'number') { + setActiveIndex(viewableItems[0].index); + } + }).current; + + const viewabilityConfig = useRef({ + itemVisiblePercentThreshold: 50, + }).current; + + const renderItem = ({item}: {item: RadiotekaItem}) => ( + onItemPress?.(item)} activeOpacity={0.8}> + + + { + //TODO: Change to actual article instead count + } + + 1 val. 15 min. + + + + {item.category} + + + {item.title} + + {item.subtitle && ( + + {item.subtitle} + + )} + + + + Klausytis + + + + + ); + + return ( + + {/* Blurred Background */} + + + + + + {/* Logo */} + + + + + {/* Carousel */} + item.id} + /> + + {/* Pagination Dots */} + + {items.map((_, index) => ( + flatListRef.current?.scrollToIndex({index})}> + + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: 525, + overflow: 'hidden', + }, + backgroundContainer: { + position: 'absolute', + height: '100%', + overflow: 'hidden', + }, + backgroundImage: { + width: '100%', + height: '100%', + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + logoContainer: { + position: 'absolute', + top: 8, + right: 8, + zIndex: 1, + borderRadius: 6, + paddingVertical: 12, + paddingHorizontal: 16, + backgroundColor: '#fff', + }, + listenCount: { + position: 'absolute', + bottom: 8, + right: 8, + }, + slide: { + justifyContent: 'center', + }, + durationText: { + position: 'absolute', + top: 8, + left: 8, + backgroundColor: 'rgba(0, 0, 0, 0.4)', + color: '#FFFFFF', + paddingVertical: 4, + paddingHorizontal: 8, + borderRadius: 4, + fontSize: 12, + }, + cardContainer: { + alignSelf: 'center', + width: 194, + marginTop: 24, + aspectRatio: 1, + borderRadius: 8, + overflow: 'hidden', + backgroundColor: '#fff', + }, + imageBackground: { + flex: 1, + padding: 12, + }, + image: { + flex: 1, + aspectRatio: 1, + borderRadius: 8, + borderColor: '#fff', + borderWidth: 2, + }, + contentContainer: { + paddingTop: 24, + gap: 10, + marginLeft: 60, + marginRight: 60, + }, + category: { + fontSize: 14, + marginBottom: 6, + color: '#FFFFFF', + }, + title: { + fontSize: 24, + marginBottom: 8, + color: '#FFFFFF', + }, + subtitle: { + fontSize: 14, + opacity: 0.8, + color: '#FFFFFF', + }, + playButton: { + flexDirection: 'row', + backgroundColor: '#FFD600', + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 6, + alignSelf: 'flex-start', + alignItems: 'center', + gap: 8, + }, + playButtonText: { + color: '#000000', + fontSize: 13, + }, + pagination: { + flexDirection: 'row', + justifyContent: 'space-evenly', + alignItems: 'center', + position: 'absolute', + bottom: 24, + left: 60, + right: 60, + }, + paginationDot: { + width: 68, + height: 1, + backgroundColor: 'rgba(255, 255, 255, 0.5)', + marginHorizontal: 4, + }, + paginationDotActive: { + backgroundColor: '#FFFFFF', + width: 68, + height: 4, + }, +}); diff --git a/app/screens/main/tabScreen/radioteka/components/hero/mockData.ts b/app/screens/main/tabScreen/radioteka/components/hero/mockData.ts new file mode 100644 index 0000000..0cab6b7 --- /dev/null +++ b/app/screens/main/tabScreen/radioteka/components/hero/mockData.ts @@ -0,0 +1,32 @@ +import {RadiotekaItem} from '../horizontal_list/RadiotekaHorizontalList'; + +export const MOCK_CAROUSEL_ITEMS: RadiotekaItem[] = [ + { + id: '1', + category: 'LRT KLASIKA', + title: 'Viskas blogai', + subtitle: 'Naujametiniai pažadai', + imageUrl: 'https://picsum.photos/300/300?random=10', + }, + { + id: '2', + category: 'LRT KLASIKA', + title: 'Muzikinis pastišas', + subtitle: 'Įvairių muzikos stilių rinkinys', + imageUrl: 'https://picsum.photos/300/300?random=11', + }, + { + id: '3', + category: 'LRT KLASIKA', + title: 'Žaidžiam žmogų', + subtitle: 'Psichologijos ir saviugdos laida', + imageUrl: 'https://picsum.photos/300/300?random=12', + }, + { + id: '4', + category: 'LRT KLASIKA', + title: 'Ryto garsai', + subtitle: 'Rytinė muzikos ir pokalbių laida', + imageUrl: 'https://picsum.photos/300/300?random=13', + }, +]; diff --git a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx new file mode 100644 index 0000000..5e316f9 --- /dev/null +++ b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import {View, StyleSheet} from 'react-native'; +import Text from '../../../../../../components/text/Text'; +import RadiotekaHorizontalList, {RadiotekaItem} from './RadiotekaHorizontalList'; + +interface RadiotekaHorizontalCategoryListProps { + categoryTitle: string; + categorySubtitle: string; + data?: RadiotekaItem[]; + variation?: 'full' | 'minimal'; + onItemPress?: (item: RadiotekaItem) => void; +} + +const MOCK_DATA: RadiotekaItem[] = [ + { + id: '1', + category: 'Gyvenimo būdas', + title: 'Sugyvenimai', + imageUrl: 'https://picsum.photos/300/300?random=9', + }, + { + id: '2', + category: 'Gyvenimo būdas', + title: 'Žaidžiam žmogų', + imageUrl: 'https://picsum.photos/300/300?random=8', + }, + { + id: '3', + category: 'Test', + title: 'Test', + imageUrl: 'https://picsum.photos/300/300?random=5', + }, +]; + +const RadiotekaHorizontalCategoryList: React.FC = ({ + categoryTitle = 'GYVENIMO BŪDAS', + categorySubtitle = '#Gyvenimas', + data = MOCK_DATA, + onItemPress, + variation, +}) => { + return ( + + + + {categoryTitle} + + + {categorySubtitle} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + paddingHorizontal: 16, + paddingTop: 24, + }, + title: { + fontSize: 20, + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + opacity: 0.8, + }, +}); + +export default RadiotekaHorizontalCategoryList; diff --git a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx new file mode 100644 index 0000000..8067be8 --- /dev/null +++ b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx @@ -0,0 +1,151 @@ +import React, {useEffect, useRef} from 'react'; +import {View, StyleSheet, FlatList, TouchableOpacity, ImageBackground, Dimensions} from 'react-native'; +import Text from '../../../../../../components/text/Text'; +import {IconPlay} from '../../../../../../components/svg'; + +const CARD_WIDTH_FULL = Math.min(Dimensions.get('window').width * 0.5, 300); +const CARD_WIDTH_MINIMAL = Math.min(Dimensions.get('window').width * 0.33, 150); + +export type RadiotekaItem = { + id: string; + category: string; + title: string; + subtitle?: string; + imageUrl: string; +}; + +interface RadiotekaHorizontalListProps { + data: RadiotekaItem[]; + onItemPress?: (item: RadiotekaItem) => void; + variation?: 'full' | 'minimal'; +} + +const RadiotekaHorizontalList: React.FC = ({ + data, + onItemPress, + variation = 'full', +}) => { + const renderItem = ({item}: {item: RadiotekaItem}) => ( + onItemPress?.(item)} + activeOpacity={0.8}> + + + {variation === 'full' && ( + + + + Klausytis + + + )} + + + {variation === 'full' && ( + + + {item.category} + + + {item.title} + + + {item.subtitle} + + + )} + + ); + + const listRef = useRef(null); + + useEffect(() => { + if (listRef.current) { + listRef.current.scrollToOffset({offset: 0, animated: false}); + } + }, [data]); + + return ( + item.id} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); +}; + +const styles = StyleSheet.create({ + listContainer: { + paddingVertical: 24, + paddingHorizontal: 12, + gap: 16, + }, + card: { + width: CARD_WIDTH_FULL, + borderRadius: 8, + overflow: 'hidden', + }, + imageContainer: { + width: CARD_WIDTH_FULL, + aspectRatio: 1, + }, + imageContainerMinimal: { + width: CARD_WIDTH_MINIMAL, + aspectRatio: 1, + }, + imageBackground: { + flex: 1, + justifyContent: 'flex-end', + padding: 8, + }, + image: { + borderRadius: 8, + }, + contentContainer: { + flex: 1, + paddingVertical: 8, + }, + category: { + fontSize: 14, + marginBottom: 6, + }, + title: { + fontSize: 19, + marginBottom: 6, + }, + subtitle: { + fontSize: 14, + opacity: 0.8, + }, + playButton: { + flexDirection: 'row', + backgroundColor: '#FFD600', + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 6, + alignSelf: 'flex-start', + alignItems: 'center', + gap: 8, + marginTop: 16, + }, + playButtonText: { + color: '#000000', + fontSize: 13, + }, + minimalCard: { + width: CARD_WIDTH_MINIMAL, + aspectRatio: 1, + borderRadius: 8, + overflow: 'hidden', + }, +}); + +export default RadiotekaHorizontalList; diff --git a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalSelectableList.tsx b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalSelectableList.tsx new file mode 100644 index 0000000..695ede9 --- /dev/null +++ b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalSelectableList.tsx @@ -0,0 +1,153 @@ +import React, {useState} from 'react'; +import {View, TouchableOpacity, StyleSheet} from 'react-native'; +import Text from '../../../../../../components/text/Text'; +import RadiotekaHorizontalList, {RadiotekaItem} from './RadiotekaHorizontalList'; + +type SelectionType = 'latest' | 'popular'; + +const LATEST_MOCK_DATA: RadiotekaItem[] = [ + { + id: '1', + category: 'Žinios', + title: 'Žinios', + subtitle: 'Nebeliko kliūčių pradėti gamyklos „Rheinmetall" statybas Baisogaloje', + imageUrl: 'https://picsum.photos/300/300?random=1', + }, + { + id: '2', + category: 'Laidos apie muziką', + title: 'Garbanota banga', + subtitle: '≈ 14 ≈', + imageUrl: 'https://picsum.photos/300/300?random=2', + }, + { + id: '3', + category: 'Aktualijos', + title: 'Ryto garsai', + subtitle: 'Pokalbis su ekonomikos ekspertu apie euro zonos perspektyvas', + imageUrl: 'https://picsum.photos/300/300?random=3', + }, + { + id: '4', + category: 'Kultūra', + title: 'Kultūros savaitė', + subtitle: 'Vilniaus knygų mugės apžvalga ir įspūdžiai', + imageUrl: 'https://picsum.photos/300/300?random=4', + }, + { + id: '5', + category: 'Sportas', + title: 'Sporto pasaulis', + subtitle: 'Lietuvos krepšinio rinktinės pasiruošimas olimpiadai', + imageUrl: 'https://picsum.photos/300/300?random=5', + }, + { + id: '6', + category: 'Mokslas', + title: 'Mokslo sriuba', + subtitle: 'Naujausi mokslo atradimai ir technologijos', + imageUrl: 'https://picsum.photos/300/300?random=6', + }, +]; + +const POPULAR_MOCK_DATA = [ + { + id: '7', + category: 'Laidos', + title: 'Savaitės pokalbis', + subtitle: 'Interviu su Lietuvos kultūros premijos laureatu', + imageUrl: 'https://picsum.photos/300/300?random=7', + }, + { + id: '8', + category: 'Muzika', + title: 'Muzikos valanda', + subtitle: 'Klasikinės muzikos koncertų apžvalga', + imageUrl: 'https://picsum.photos/300/300?random=8', + }, + { + id: '9', + category: 'Dokumentika', + title: 'Istorijos vingiai', + subtitle: 'Dokumentinis pasakojimas apie Lietuvos partizanus', + imageUrl: 'https://picsum.photos/300/300?random=9', + }, + { + id: '10', + category: 'Politika', + title: 'Politikos akiračiai', + subtitle: 'Savaitės politinių įvykių analizė', + imageUrl: 'https://picsum.photos/300/300?random=10', + }, + { + id: '11', + category: 'Technologijos', + title: 'Skaitmeninė era', + subtitle: 'Dirbtinio intelekto vystymosi tendencijos', + imageUrl: 'https://picsum.photos/300/300?random=11', + }, + { + id: '12', + category: 'Sveikata', + title: 'Sveikatos kodas', + subtitle: 'Pokalbiai apie sveiką gyvenseną ir prevenciją', + imageUrl: 'https://picsum.photos/300/300?random=12', + }, +]; + +const RadiotekaHorizontalSelectableList: React.FC = () => { + const [selectedType, setSelectedType] = useState('latest'); + + return ( + + + setSelectedType('latest')}> + + Naujausi + + + setSelectedType('popular')}> + + Populiariausi + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 24, + flex: 1, + }, + buttonsContainer: { + flexDirection: 'row', + paddingHorizontal: 12, + gap: 12, + }, + button: { + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, + }, + selectedButton: { + backgroundColor: '#F5F5F5', + }, + buttonText: { + fontSize: 14, + }, +}); + +export default RadiotekaHorizontalSelectableList; From 9cdafcff029322df78706bcb0c53bb416101fefe Mon Sep 17 00:00:00 2001 From: Kestas Venslauskas Date: Mon, 27 Jan 2025 22:10:04 +0200 Subject: [PATCH 2/7] feat: map data to radioteka screen --- App.tsx | 4 +- Types.ts | 13 + app/api/Endpoints.ts | 4 + app/api/Types.ts | 140 ++++++++++- app/api/index.ts | 4 + app/screens/main/MainScreen.tsx | 8 +- .../tabScreen/radioteka/RadiotekaScreen.tsx | 235 +++++++++++++++--- .../components/hero/RadiotekaHero.tsx | 148 ++++++----- .../components/hero/RadiotekaHeroCarousel.tsx | 98 ++++---- .../RadiotekaHorizontalCategoryList.tsx | 64 +++-- .../RadiotekaHorizontalList.tsx | 85 +++---- .../RadiotekaHorizontalSelectableList.tsx | 18 +- app/state/article_store.ts | 51 +++- 13 files changed, 615 insertions(+), 257 deletions(-) diff --git a/App.tsx b/App.tsx index 58b73ee..5e060e3 100644 --- a/App.tsx +++ b/App.tsx @@ -17,7 +17,7 @@ import {runArticleStorageMigration} from './app/state/article_storage_store'; import {runOnboardingStorageMigration} from './app/screens/main/useOnboardingLogic'; import {runFirebaseTopicSubsriptionMigration} from './app/util/useFirebaseTopicSubscription'; import {runSettingsStorageMigration} from './app/state/settings_store'; -import RadiotekaScreen from './app/screens/main/tabScreen/radioteka/RadiotekaScreen'; + enableFreeze(true); const App: React.FC = () => { @@ -38,7 +38,7 @@ const App: React.FC = () => { - + diff --git a/Types.ts b/Types.ts index 6401e6a..11d8a42 100644 --- a/Types.ts +++ b/Types.ts @@ -29,6 +29,7 @@ export type Article = { read_count: number; summary?: string; media_duration?: string; + media_duration_sec?: number; photo_count?: number; photo_horizontal?: 1 | 0; photo_horizontal_small?: 1 | 0; @@ -50,4 +51,16 @@ export type Article = { badge_id: string | number | null; badge_class: 'badge-danger' | 'badge-primary' | 'badge-secondary' | 'badge-warning' | 'badge-info' | null; badge_title: string | null; + branch0_term?: string; + branch0_title?: string; + branch1_term?: string; + branch1_title?: string; + hero_photo?: { + w_h: string; + title: string; + img_path_prefix: string; + path: string; + author: string; + img_path_postfix: string; + }; }; diff --git a/app/api/Endpoints.ts b/app/api/Endpoints.ts index 75bf6f1..b36b2a1 100644 --- a/app/api/Endpoints.ts +++ b/app/api/Endpoints.ts @@ -132,6 +132,10 @@ export const audiotekaGet = () => { return `${BASE_URL}audioteka-home`; }; +export const radiotekaGet = () => { + return `${BASE_URL}audioteka-home/v2`; +}; + /** * Returns all channels TV program data for a week. */ diff --git a/app/api/Types.ts b/app/api/Types.ts index e79cf0d..e83e729 100644 --- a/app/api/Types.ts +++ b/app/api/Types.ts @@ -314,6 +314,131 @@ export type URLTypeExternalURL = { url: string; }; +export type RadiotekaResponse = RadiotekaTemplate[]; + +export type RadiotekaTemplate = + | RadiotekaTopArticlesBlock + | RadiotekaSlugArticlesBlock + | RadiotekaCategoryCollectionBlock + | RadiotekaCategoryBlock; + +export type RadiotekaTopArticlesBlock = { + widget_id: 21; + widget_name: string; + type: 'articles_block'; + template_id: 7; + data: { + articles_list: FeedArticle[]; + }; +}; + +export type RadiotekaCategoryBlock = { + template_id: 25 | 42 | 43; // 25 - hero style, 42, 43 - horizontal list + type: 'category'; + data: { + category_id: number; + category_url: string; + category_title: string; + articles_list: FeedArticle[]; + }; +}; + +export type RadiotekaSlugArticlesBlock = { + type: 'slug'; + template_id: 20; + data: { + template_id: 20; + slug_url: string; + articles_list: FeedArticle[]; + slug_title: string; + }; +}; + +export type RadiotekaCategoryCollectionBlock = { + type: 'audio_category_collection'; + template_id: 44 | 45 | 46; + data: { + description: RadiotekaCategoryDescription; + category_list: RadiotekaCategory[]; + }; +}; + +export type RadiotekaCategoryDescription = { + article_is_photogallery: 1 | 0 | null; + badge_id: any; + category_title: string; + article_authors: { + name: string; + slug: string; + }[]; + article_date: string; + article_url: string; + article_summary: string; + article_template: number; + is_audio_category_collection?: 1 | 0 | null; + article_type: number; + article_title_formated?: 1 | 0 | null; + category_id: number; + badge_class: any; + article_title: string; + badges_html: any; + paragraphs: any; + article_keywords: Keyword[]; + read_count: any; + article_id: number; + badge_title: any; + category_url: string; + text2speech_file_url?: null | string; +}; + +export type RadiotekaCategory = { + id: number; + lrt_id: number; + branch_info_ary?: { + branch_term: string; + branch_id: number; + branch_title: string; + }[]; + main_title: null; + is_children_category: null; + term: string; + related_channel_id: any; + lrt_description: string; + lrt_season_id: any; + lrt_code: string; + category_images: { + img1: { + img_path_postfix: string; + img_path: string; + img_path_prefix: string; + w_h: string; + }; + img2: { + img_path_postfix: string; + img_path: string; + img_path_prefix: string; + w_h: string; + }; + }; + main_category_id: any; + branch_info?: { + branch_level2?: { + branch_term: string; + branch_id: number; + branch_title: string; + }; + branch_level1?: { + branch_term: string; + branch_id: number; + branch_title: string; + }; + }; + lrt_show_id: number; + title: string; + //TODO: check if this is correct might be incosisntent type + LATEST_ITEM: ArticleContentMedia; +}; + export type AudiotekaResponse = AudiotekaTemplate[]; export type AudiotekaTemplate = @@ -598,10 +723,7 @@ export type ArticleContentDefault = { name: string; slug: string; }[]; - article_keywords: { - name: string; - slug: string; - }[]; + article_keywords: Keyword[]; category_id?: number; category_url?: string; category_title?: string; @@ -638,10 +760,7 @@ export type ArticleContentMedia = { name: string; slug: string; }[]; - keywords: { - name: string; - slug: string; - }[]; + keywords: Keyword[]; content: string; main_photo: ArticlePhotoType; 'n-18'?: 0 | 1; @@ -808,3 +927,8 @@ export interface LiveFeedArticle { item_date: string; title: string; } + +export type Keyword = { + name: string; + slug: string; +}; diff --git a/app/api/index.ts b/app/api/index.ts index d061bd3..babef1f 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -22,6 +22,7 @@ import { popularArticlesGet, programGet, putDailyQuestionVote, + radiotekaGet, searchArticles, weatherLocationsGet, } from './Endpoints'; @@ -45,6 +46,7 @@ import { OpusPlaylistResponse, PopularArticlesResponse, ProgramResponse, + RadiotekaResponse, SearchFilter, SearchResponse, SlugArticlesResponse, @@ -76,6 +78,8 @@ export const fetchProgramApi = () => get(programGet()); export const fetchAudiotekaApi = () => get(audiotekaGet()); +export const fetchRadiotekaApi = () => get(radiotekaGet()); + export const fetchNewestApi = (page: number, count: number, date_max?: string, not_id?: string) => get(newestArticlesGet(count, page, date_max, not_id)); diff --git a/app/screens/main/MainScreen.tsx b/app/screens/main/MainScreen.tsx index 7809989..e19d0df 100644 --- a/app/screens/main/MainScreen.tsx +++ b/app/screens/main/MainScreen.tsx @@ -3,7 +3,7 @@ import {View, Dimensions, StyleSheet} from 'react-native'; import {SceneRendererProps, TabView} from 'react-native-tab-view'; import {ActionButton, Logo} from '../../components'; import {IconDrawerMenu, IconSettings} from '../../components/svg'; -import {BorderlessButton, TouchableWithoutFeedback} from 'react-native-gesture-handler'; +import {TouchableWithoutFeedback} from 'react-native-gesture-handler'; import TabBar from './tabBar/TabBar'; import HomeScreen from './tabScreen/home/HomeScreen'; import TestScreen from '../testScreen/TestScreen'; @@ -11,7 +11,6 @@ import {EventRegister} from 'react-native-event-listeners'; import {EVENT_LOGO_PRESS, EVENT_OPEN_CATEGORY, EVENT_SELECT_CATEGORY_INDEX} from '../../constants'; import {useTheme} from '../../Theme'; import {SafeAreaView} from 'react-native-safe-area-context'; -import AudiotekaScreen from './tabScreen/audioteka/AudiotekaScreen'; import { ROUTE_TYPE_HOME, ROUTE_TYPE_AUDIOTEKA, @@ -29,6 +28,7 @@ import NotificationsModal from '../../components/notificationsModal/Notification import useOnboardingLogic from './useOnboardingLogic'; import {useNavigationStore} from '../../state/navigation_store'; import CategoryHomeScreen from './tabScreen/category/CategoryHomeScreen'; +import RadiotekaScreen from './tabScreen/radioteka/RadiotekaScreen'; type ScreenRouteProp = RouteProp; @@ -147,7 +147,7 @@ const MainScreen: React.FC> = ({navigation}) => { case ROUTE_TYPE_MEDIA: return ; case ROUTE_TYPE_AUDIOTEKA: - return ; + return ; case ROUTE_TYPE_CATEGORY: return route.hasHome ? ( > = ({navigation}) => { routes: state.routes, index: selectedTabIndex, }} - swipeEnabled={true} + swipeEnabled={state.routes[selectedTabIndex]?.type !== ROUTE_TYPE_AUDIOTEKA} renderScene={renderScene} renderTabBar={(tabBarProps) => } onIndexChange={setSelectedTabIndex} diff --git a/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx b/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx index 251cbad..70a21cf 100644 --- a/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx +++ b/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx @@ -1,43 +1,222 @@ -import React from 'react'; -import {View, StyleSheet} from 'react-native'; -import RadiotekaHero from './components/hero/RadiotekaHero'; -import RadiotekaHorizontalSelectableList from './components/horizontal_list/RadiotekaHorizontalSelectableList'; -import RadiotekaHorizontalCategoryList from './components/horizontal_list/RadiotekaHorizontalCategoryList'; -import {ScrollView} from 'react-native-gesture-handler'; +import React, {useCallback, useEffect, useRef} from 'react'; +import {View, RefreshControl, StyleSheet, StatusBar} from 'react-native'; +import {FlashList, ListRenderItemInfo} from '@shopify/flash-list'; +import {ScreenLoader} from '../../../../components'; +import {EVENT_LOGO_PRESS, ARTICLE_EXPIRE_DURATION} from '../../../../constants'; +import Gemius from 'react-native-gemius-plugin'; +import {EventRegister} from 'react-native-event-listeners'; import {useTheme} from '../../../../Theme'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import useAppStateCallback from '../../../../hooks/useAppStateCallback'; +import useNavigationAnalytics from '../../../../util/useNavigationAnalytics'; +import {ArticleState, useArticleStore} from '../../../../state/article_store'; +import {useShallow} from 'zustand/shallow'; +import Config from 'react-native-config'; +import {RadiotekaTemplate} from '../../../../api/Types'; +import RadiotekaHero from './components/hero/RadiotekaHero'; +import RadiotekaHorizontalCategoryList from './components/horizontal_list/RadiotekaHorizontalCategoryList'; +import {buildImageUri, IMG_SIZE_L} from '../../../../util/ImageUtil'; import {RadiotekaHeroCarousel} from './components/hero/RadiotekaHeroCarousel'; -import {MOCK_CAROUSEL_ITEMS} from './components/hero/mockData'; +import {useNavigation} from '@react-navigation/native'; +import {MainStackParamList} from '../../../../navigation/MainStack'; +import {StackNavigationProp} from '@react-navigation/stack'; + +const WIDGET_ID_HERO = 21; + +interface Props { + isCurrent: boolean; +} + +const selectRadiotekaScreenState = (state: ArticleState) => { + const block = state.radioteka; + return { + refreshing: block.isFetching && block.data.length > 0, + lastFetchTime: block.lastFetchTime, + data: block.data, + }; +}; + +const RadiotekaScreen: React.FC> = ({isCurrent}) => { + const listRef = useRef>(null); + const {colors, dark} = useTheme(); + + const navigation = useNavigation>(); + + const {fetchRadioteka} = useArticleStore.getState(); + const state = useArticleStore(useShallow(selectRadiotekaScreenState)); + const {refreshing, lastFetchTime, data} = state; + + useEffect(() => { + Gemius.sendPartialPageViewedEvent(Config.GEMIUS_VIEW_SCRIPT_ID, { + page: 'audioteka', + }); + }, []); + + useNavigationAnalytics({ + viewId: 'https://www.lrt.lt/radioteka', + title: 'Radioteka - LRT', + sections: ['Radioteka_home'], + }); + + useEffect(() => { + const listener = EventRegister.addEventListener(EVENT_LOGO_PRESS, (_data) => { + if (isCurrent) { + listRef.current?.scrollToIndex({ + animated: true, + index: 0, + }); + fetchRadioteka(); + } + }); + return () => { + EventRegister.removeEventListener(listener as string); + }; + }); + + const refresh = useCallback(() => { + if (!refreshing && Date.now() - lastFetchTime > ARTICLE_EXPIRE_DURATION) { + console.log('Radioteka data expired!'); + fetchRadioteka(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshing, state.lastFetchTime]); + + useEffect(() => { + if (isCurrent) { + refresh(); + } + }, [isCurrent, refresh]); + + useAppStateCallback( + useCallback(() => { + refresh(); + }, [refresh]), + ); + + const renderItem = useCallback((listItem: ListRenderItemInfo) => { + const {item} = listItem; + switch (item.type) { + case 'articles_block': { + if (item.widget_id === WIDGET_ID_HERO) { + return ; + } + break; + } + case 'slug': { + if (item.template_id === 20) { + return ( + ({ + title: a.title, + category: a.channel_title, + imageUrl: buildImageUri(IMG_SIZE_L, a.img_path_prefix, a.img_path_postfix), + }))} + onItemPress={(index) => { + console.log('item pressed', item.data.articles_list[index].title); + }} + /> + ); + } + break; + } + case 'audio_category_collection': { + return ( + ({ + title: c.title, + //TODO: need to check if this is correct + category: + c.branch_info?.branch_level2?.branch_title ?? c.branch_info?.branch_level1?.branch_title, + imageUrl: buildImageUri( + IMG_SIZE_L, + c.category_images.img1.img_path_prefix, + c.category_images.img1.img_path_postfix, + ), + }))} + onItemPress={(index) => { + console.log('item pressed', item.data.category_list[index].title); + }} + onKeywordPress={(keyword) => { + navigation.navigate('Slug', { + name: keyword.name, + slugUrl: keyword.slug, + }); + }} + /> + ); + } + case 'category': { + if (item.template_id === 25) { + return ; + } + if (item.template_id === 42 || item.template_id === 43) { + return ( + ({ + title: a.title, + category: a.channel_title, + imageUrl: buildImageUri(IMG_SIZE_L, a.img_path_prefix, a.img_path_postfix), + }))} + onItemPress={(index) => { + console.log('item pressed', item.data.articles_list[index].title); + }} + /> + ); + } + break; + } + default: { + // console.warn('Unknown list item: ', JSON.stringify(item, null, 4)); + return ; + } + } + return ; + }, []); -const RadiotekaScreen: React.FC = () => { - const theme = useTheme(); const {bottom} = useSafeAreaInsets(); + + if (data.length === 0) { + return ; + } + return ( - - - - - - - - + + + fetchRadioteka()} />} + data={data} + removeClippedSubviews={false} + estimatedFirstItemOffset={500} + estimatedItemSize={600} + keyExtractor={(item, index) => String(index) + String(item)} /> - - + + ); }; +export default RadiotekaScreen; + const styles = StyleSheet.create({ container: { flex: 1, }, }); - -export default RadiotekaScreen; diff --git a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx index affe8e0..e5df3a1 100644 --- a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx +++ b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx @@ -1,70 +1,29 @@ import React, {useState, useEffect} from 'react'; -import {View, Image, TouchableOpacity, StyleSheet, Dimensions, ScrollView} from 'react-native'; +import {View, TouchableOpacity, StyleSheet, Dimensions, ScrollView} from 'react-native'; import Animated, {useAnimatedStyle, withSpring, useSharedValue} from 'react-native-reanimated'; -import {Text} from '../../../../../../components'; +import {Text, TouchableDebounce} from '../../../../../../components'; import ThemeProvider from '../../../../../../theme/ThemeProvider'; import {themeLight} from '../../../../../../Theme'; import {IconPlay} from '../../../../../../components/svg'; +import {RadiotekaTopArticlesBlock} from '../../../../../../api/Types'; +import FastImage from 'react-native-fast-image'; +import {buildImageUri, IMG_SIZE_M, IMG_SIZE_XXL} from '../../../../../../util/ImageUtil'; +import LinearGradient from 'react-native-linear-gradient'; const {height} = Dimensions.get('window'); const width = Math.min(Dimensions.get('window').width * 0.32, 150); -const RadiotekaHero: React.FC = () => { +interface Props { + block: RadiotekaTopArticlesBlock; +} + +const RadiotekaHero: React.FC> = ({block}) => { const [selectedIndex, setSelectedIndex] = useState(0); - const podcastItems = [ - { - id: 1, - title: 'LRT Aktualijų studija', - subtitle: 'Aktualios politinės, ekonominės ir socialinės temos', - image: 'https://placeholder.com/300x300', - backgroundColor: '#4A1515', - }, - { - id: 2, - title: 'ŠVIESI ATEITIS', - subtitle: 'Pokalbiai apie technologijas ir inovacijas', - image: 'https://placeholder.com/300x300', - backgroundColor: '#1A1A3A', - }, - { - id: 3, - title: 'Žaidžiam žmogų', - subtitle: 'Psichologijos ir saviugdos laida', - image: 'https://placeholder.com/300x300', - backgroundColor: '#FF8C42', - }, - { - id: 4, - title: 'Ryto garsai', - subtitle: 'Rytinė muzikos ir pokalbių laida', - image: 'https://placeholder.com/300x300', - backgroundColor: '#2E8B57', - }, - { - id: 5, - title: 'Kultūros savaitė', - subtitle: 'Savaitės kultūros įvykių apžvalga', - image: 'https://placeholder.com/300x300', - backgroundColor: '#4B0082', - }, - { - id: 6, - title: 'Muzikinis pastišas', - subtitle: 'Įvairių muzikos stilių rinkinys', - image: 'https://placeholder.com/300x300', - backgroundColor: '#8B4513', - }, - { - id: 7, - title: 'Vakaro pasaka', - subtitle: 'Pasakos vaikams ir suaugusiems', - image: 'https://placeholder.com/300x300', - backgroundColor: '#483D8B', - }, - ]; + const {data} = block; + const articles = data.articles_list; - const scaleValues = podcastItems.map(() => useSharedValue(1)); + const scaleValues = articles?.map(() => useSharedValue(1)); useEffect(() => { // Reset all scales to 1 @@ -87,32 +46,58 @@ const RadiotekaHero: React.FC = () => { setSelectedIndex(index); }; + const imgUrl = buildImageUri( + IMG_SIZE_XXL, + articles[selectedIndex].hero_photo?.img_path_prefix ?? articles[selectedIndex].img_path_prefix, + articles[selectedIndex].hero_photo?.img_path_postfix ?? articles[selectedIndex].img_path_postfix, + ); + + const durationMinutes = Math.floor((articles[selectedIndex].media_duration_sec ?? 0) / 60); return ( - + + + + - {podcastItems[selectedIndex].title} + Radioteka rekomenduoja - 53 min. + {durationMinutes}min. - {podcastItems[selectedIndex].title} + {articles[selectedIndex].category_title} - {podcastItems[selectedIndex].subtitle} + {articles[selectedIndex].title} - + - Klausytis - + {/* Klausytis */} + - + Daugiau - + { showsHorizontalScrollIndicator={false} style={styles.bottomScrollView} contentContainerStyle={styles.bottomList}> - {podcastItems.map((item, index) => ( + {articles.map((item, index) => ( handleItemPress(index)}> - + ))} @@ -137,10 +131,11 @@ const RadiotekaHero: React.FC = () => { const styles = StyleSheet.create({ container: { flex: 1, - height: height, + height: height - 100, justifyContent: 'space-between', paddingTop: 80, paddingBottom: 40, + marginBottom: 64, }, header: { paddingHorizontal: 12, @@ -148,6 +143,7 @@ const styles = StyleSheet.create({ headerText: { color: '#FFFFFF', fontSize: 19, + textTransform: 'uppercase', }, mainContent: { justifyContent: 'center', @@ -163,11 +159,11 @@ const styles = StyleSheet.create({ borderRadius: 3, backgroundColor: '#000000A0', paddingVertical: 3, - paddingHorizontal: 6, + paddingHorizontal: 8, }, title: { color: '#FFFFFF', - fontSize: 32, + fontSize: 34, marginVertical: 10, }, subtitle: { @@ -187,24 +183,20 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', paddingVertical: 12, - paddingHorizontal: 24, borderRadius: 8, - }, - playButtonText: { - color: '#000000', - fontSize: 16, + aspectRatio: 1, }, moreButton: { backgroundColor: '#FFFFFF', alignItems: 'center', justifyContent: 'center', - paddingVertical: 16, - paddingHorizontal: 24, + paddingVertical: 12, + paddingHorizontal: 16, borderRadius: 8, }, moreButtonText: { color: '#000000', - fontSize: 16, + fontSize: 15, }, bottomScrollView: {}, bottomList: { diff --git a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx index 4c328bb..b6e2cdf 100644 --- a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx +++ b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx @@ -8,15 +8,19 @@ import { useWindowDimensions, TouchableOpacity, } from 'react-native'; -import {RadiotekaItem} from '../horizontal_list/RadiotekaHorizontalList'; import Text from '../../../../../../components/text/Text'; -import {ChannelClassicIcon, IconPlay} from '../../../../../../components/svg'; +import {IconPlay} from '../../../../../../components/svg'; import FastImage from 'react-native-fast-image'; import ListenCount from '../../../../../../components/article/article/ListenCount'; +import {TouchableDebounce} from '../../../../../../components'; +import {Article} from '../../../../../../../Types'; +import {buildImageUri, IMG_SIZE_L, IMG_SIZE_XL} from '../../../../../../util/ImageUtil'; +import {getIconForChannelById} from '../../../../../../util/UI'; +import LinearGradient from 'react-native-linear-gradient'; interface RadiotekaHeroCarouselProps { - items: RadiotekaItem[]; - onItemPress?: (item: RadiotekaItem) => void; + items: Article[]; + onItemPress?: (index: number) => void; } export const RadiotekaHeroCarousel: React.FC = ({items, onItemPress}) => { @@ -34,50 +38,61 @@ export const RadiotekaHeroCarousel: React.FC = ({ite itemVisiblePercentThreshold: 50, }).current; - const renderItem = ({item}: {item: RadiotekaItem}) => ( - onItemPress?.(item)} activeOpacity={0.8}> + const renderItem = ({item, index}: {item: Article; index: number}) => ( + onItemPress?.(index)}> - - { - //TODO: Change to actual article instead count - } - - 1 val. 15 min. + + + {Math.floor((item.media_duration_sec ?? 0) / 60)} min. - {item.category} + {item.branch0_title ?? item.branch1_title} + {item.category_title} + + {item.title} - {item.subtitle && ( - - {item.subtitle} - - )} - - Klausytis - - + + ); + + const imgUrl = buildImageUri( + IMG_SIZE_XL, + items[activeIndex]?.img_path_prefix, + items[activeIndex]?.img_path_postfix, ); return ( {/* Blurred Background */} - - + + {/* Logo */} - - - + { + + {getIconForChannelById(items[activeIndex].channel_id ?? 0, {height: 20})} + + } {/* Carousel */} = ({ite showsHorizontalScrollIndicator={false} onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={viewabilityConfig} - keyExtractor={(item) => item.id} + keyExtractor={(item, index) => `${index}`} /> {/* Pagination Dots */} @@ -112,8 +127,9 @@ export const RadiotekaHeroCarousel: React.FC = ({ite const styles = StyleSheet.create({ container: { - height: 525, + height: 560, overflow: 'hidden', + marginVertical: 48, }, backgroundContainer: { position: 'absolute', @@ -124,10 +140,6 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', }, - overlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - }, logoContainer: { position: 'absolute', top: 8, @@ -136,7 +148,7 @@ const styles = StyleSheet.create({ borderRadius: 6, paddingVertical: 12, paddingHorizontal: 16, - backgroundColor: '#fff', + backgroundColor: '#fafafa', }, listenCount: { position: 'absolute', @@ -159,7 +171,7 @@ const styles = StyleSheet.create({ }, cardContainer: { alignSelf: 'center', - width: 194, + width: 200, marginTop: 24, aspectRatio: 1, borderRadius: 8, @@ -168,14 +180,13 @@ const styles = StyleSheet.create({ }, imageBackground: { flex: 1, - padding: 12, }, image: { flex: 1, aspectRatio: 1, borderRadius: 8, borderColor: '#fff', - borderWidth: 2, + borderWidth: 1, }, contentContainer: { paddingTop: 24, @@ -194,24 +205,21 @@ const styles = StyleSheet.create({ color: '#FFFFFF', }, subtitle: { - fontSize: 14, - opacity: 0.8, + fontSize: 16, color: '#FFFFFF', }, playButton: { flexDirection: 'row', backgroundColor: '#FFD600', - paddingVertical: 8, - paddingHorizontal: 16, + paddingVertical: 12, borderRadius: 6, alignSelf: 'flex-start', alignItems: 'center', + justifyContent: 'center', gap: 8, + aspectRatio: 1, }, - playButtonText: { - color: '#000000', - fontSize: 13, - }, + pagination: { flexDirection: 'row', justifyContent: 'space-evenly', diff --git a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx index 5e316f9..0c390b4 100644 --- a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx +++ b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx @@ -1,55 +1,49 @@ import React from 'react'; import {View, StyleSheet} from 'react-native'; import Text from '../../../../../../components/text/Text'; -import RadiotekaHorizontalList, {RadiotekaItem} from './RadiotekaHorizontalList'; +import RadiotekaHorizontalList, {RadiotekaListItem} from './RadiotekaHorizontalList'; +import {Keyword} from '../../../../../../api/Types'; +import {TouchableDebounce} from '../../../../../../components'; +import {useTheme} from '../../../../../../Theme'; interface RadiotekaHorizontalCategoryListProps { categoryTitle: string; - categorySubtitle: string; - data?: RadiotekaItem[]; + keywords?: Keyword[]; variation?: 'full' | 'minimal'; - onItemPress?: (item: RadiotekaItem) => void; + items: RadiotekaListItem[]; + onItemPress?: (index: number) => void; + onKeywordPress?: (keyword: Keyword) => void; } -const MOCK_DATA: RadiotekaItem[] = [ - { - id: '1', - category: 'Gyvenimo būdas', - title: 'Sugyvenimai', - imageUrl: 'https://picsum.photos/300/300?random=9', - }, - { - id: '2', - category: 'Gyvenimo būdas', - title: 'Žaidžiam žmogų', - imageUrl: 'https://picsum.photos/300/300?random=8', - }, - { - id: '3', - category: 'Test', - title: 'Test', - imageUrl: 'https://picsum.photos/300/300?random=5', - }, -]; - const RadiotekaHorizontalCategoryList: React.FC = ({ - categoryTitle = 'GYVENIMO BŪDAS', - categorySubtitle = '#Gyvenimas', - data = MOCK_DATA, + categoryTitle, + keywords, + items, onItemPress, + onKeywordPress, variation, }) => { + const {colors} = useTheme(); return ( + {categoryTitle} - - {categorySubtitle} - + {keywords && ( + + {keywords.map((k) => ( + onKeywordPress?.(k)}> + + #{k.name} + + + ))} + + )} - + ); }; @@ -57,17 +51,19 @@ const RadiotekaHorizontalCategoryList: React.FC void; + items: RadiotekaListItem[]; + onItemPress?: (index: number) => void; variation?: 'full' | 'minimal'; } const RadiotekaHorizontalList: React.FC = ({ - data, + items, onItemPress, variation = 'full', }) => { - const renderItem = ({item}: {item: RadiotekaItem}) => ( - ( + onItemPress?.(item)} + onPress={() => onItemPress?.(index)} activeOpacity={0.8}> - + + {variation === 'full' && ( - + - - Klausytis - - + )} - + {variation === 'full' && ( - + {item.category} - + {item.title} - - {item.subtitle} - )} - + ); const listRef = useRef(null); @@ -67,14 +64,14 @@ const RadiotekaHorizontalList: React.FC = ({ if (listRef.current) { listRef.current.scrollToOffset({offset: 0, animated: false}); } - }, [data]); + }, [items]); return ( item.id} + keyExtractor={(_item, index) => `${index}`} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.listContainer} @@ -91,11 +88,12 @@ const styles = StyleSheet.create({ card: { width: CARD_WIDTH_FULL, borderRadius: 8, - overflow: 'hidden', + overflow: 'visible', }, imageContainer: { width: CARD_WIDTH_FULL, aspectRatio: 1, + overflow: 'visible', }, imageContainerMinimal: { width: CARD_WIDTH_MINIMAL, @@ -107,7 +105,18 @@ const styles = StyleSheet.create({ padding: 8, }, image: { + ...StyleSheet.absoluteFillObject, borderRadius: 8, + borderWidth: 1, + borderColor: '#FFFFFF', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.22, + shadowRadius: 2.22, + elevation: 3, }, contentContainer: { flex: 1, @@ -121,24 +130,16 @@ const styles = StyleSheet.create({ fontSize: 19, marginBottom: 6, }, - subtitle: { - fontSize: 14, - opacity: 0.8, - }, + playButton: { flexDirection: 'row', backgroundColor: '#FFD600', - paddingVertical: 8, - paddingHorizontal: 16, + paddingVertical: 12, + paddingHorizontal: 12, borderRadius: 6, alignSelf: 'flex-start', alignItems: 'center', gap: 8, - marginTop: 16, - }, - playButtonText: { - color: '#000000', - fontSize: 13, }, minimalCard: { width: CARD_WIDTH_MINIMAL, diff --git a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalSelectableList.tsx b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalSelectableList.tsx index 695ede9..ef283fc 100644 --- a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalSelectableList.tsx +++ b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalSelectableList.tsx @@ -1,51 +1,45 @@ import React, {useState} from 'react'; import {View, TouchableOpacity, StyleSheet} from 'react-native'; import Text from '../../../../../../components/text/Text'; -import RadiotekaHorizontalList, {RadiotekaItem} from './RadiotekaHorizontalList'; +import RadiotekaHorizontalList from './RadiotekaHorizontalList'; type SelectionType = 'latest' | 'popular'; -const LATEST_MOCK_DATA: RadiotekaItem[] = [ +const LATEST_MOCK_DATA = [ { id: '1', category: 'Žinios', title: 'Žinios', - subtitle: 'Nebeliko kliūčių pradėti gamyklos „Rheinmetall" statybas Baisogaloje', imageUrl: 'https://picsum.photos/300/300?random=1', }, { id: '2', category: 'Laidos apie muziką', title: 'Garbanota banga', - subtitle: '≈ 14 ≈', imageUrl: 'https://picsum.photos/300/300?random=2', }, { id: '3', category: 'Aktualijos', title: 'Ryto garsai', - subtitle: 'Pokalbis su ekonomikos ekspertu apie euro zonos perspektyvas', imageUrl: 'https://picsum.photos/300/300?random=3', }, { id: '4', category: 'Kultūra', title: 'Kultūros savaitė', - subtitle: 'Vilniaus knygų mugės apžvalga ir įspūdžiai', imageUrl: 'https://picsum.photos/300/300?random=4', }, { id: '5', category: 'Sportas', title: 'Sporto pasaulis', - subtitle: 'Lietuvos krepšinio rinktinės pasiruošimas olimpiadai', imageUrl: 'https://picsum.photos/300/300?random=5', }, { id: '6', category: 'Mokslas', title: 'Mokslo sriuba', - subtitle: 'Naujausi mokslo atradimai ir technologijos', imageUrl: 'https://picsum.photos/300/300?random=6', }, ]; @@ -55,42 +49,36 @@ const POPULAR_MOCK_DATA = [ id: '7', category: 'Laidos', title: 'Savaitės pokalbis', - subtitle: 'Interviu su Lietuvos kultūros premijos laureatu', imageUrl: 'https://picsum.photos/300/300?random=7', }, { id: '8', category: 'Muzika', title: 'Muzikos valanda', - subtitle: 'Klasikinės muzikos koncertų apžvalga', imageUrl: 'https://picsum.photos/300/300?random=8', }, { id: '9', category: 'Dokumentika', title: 'Istorijos vingiai', - subtitle: 'Dokumentinis pasakojimas apie Lietuvos partizanus', imageUrl: 'https://picsum.photos/300/300?random=9', }, { id: '10', category: 'Politika', title: 'Politikos akiračiai', - subtitle: 'Savaitės politinių įvykių analizė', imageUrl: 'https://picsum.photos/300/300?random=10', }, { id: '11', category: 'Technologijos', title: 'Skaitmeninė era', - subtitle: 'Dirbtinio intelekto vystymosi tendencijos', imageUrl: 'https://picsum.photos/300/300?random=11', }, { id: '12', category: 'Sveikata', title: 'Sveikatos kodas', - subtitle: 'Pokalbiai apie sveiką gyvenseną ir prevenciją', imageUrl: 'https://picsum.photos/300/300?random=12', }, ]; @@ -122,7 +110,7 @@ const RadiotekaHorizontalSelectableList: React.FC = () => { - + ); }; diff --git a/app/state/article_store.ts b/app/state/article_store.ts index ae1538e..7a58dc9 100644 --- a/app/state/article_store.ts +++ b/app/state/article_store.ts @@ -1,7 +1,14 @@ import {create} from 'zustand'; import {produce} from 'immer'; import {Article} from '../../Types'; -import {AudiotekaResponse, HomeBlockChannels, HomeBlockType, LiveChannel, TVChannel} from '../api/Types'; +import { + AudiotekaResponse, + HomeBlockChannels, + HomeBlockType, + LiveChannel, + RadiotekaResponse, + TVChannel, +} from '../api/Types'; import { fetchAudiotekaApi, fetchCategoryApi, @@ -10,6 +17,7 @@ import { fetchMediatekaApi, fetchNewestApi, fetchPopularApi, + fetchRadiotekaApi, } from '../api'; import {formatArticles} from '../util/articleFormatters'; @@ -33,6 +41,10 @@ type AudiotekaState = { data: AudiotekaResponse; } & BaseBlockState; +type RadiotekaState = { + data: RadiotekaResponse; +} & BaseBlockState; + export type PagingState = { title: string; articles: Article[][]; @@ -63,6 +75,7 @@ export type ArticleState = { home: HomeState; mediateka: HomeState; audioteka: AudiotekaState; + radioteka: RadiotekaState; advancedCategories: {[key: number]: CategoryHomeState}; categories: {[key: number]: CategoryState}; @@ -75,6 +88,7 @@ type ArticleActions = { fetchHome: () => void; fetchMediateka: () => void; fetchAudioteka: () => void; + fetchRadioteka: () => void; fetchPopular: (page: number, count: number, withOverride?: boolean) => void; fetchNewest: ( page: number, @@ -115,6 +129,12 @@ const initialState: ArticleState = { lastFetchTime: 0, data: [], }, + radioteka: { + isFetching: false, + isError: false, + lastFetchTime: 0, + data: [], + }, advancedCategories: {}, newest: { title: '', @@ -240,11 +260,39 @@ export const useArticleStore = create((set, get) => ({ ); } }, + fetchRadioteka: async () => { + set( + produce((state: ArticleState) => { + state.radioteka.isFetching = true; + state.radioteka.isError = false; + }), + ); + try { + const data = await fetchRadiotekaApi(); + set({ + radioteka: { + data, + isFetching: false, + isError: false, + lastFetchTime: Date.now(), + }, + }); + } catch (e) { + console.log('Fetch radioteka error', e); + set( + produce((state: ArticleState) => { + state.radioteka.isFetching = false; + state.radioteka.isError = true; + }), + ); + } + }, fetchPopular: async (page: number, count: number, withOverride?: boolean) => { set( produce((state: ArticleState) => { state.popular.isFetching = true; state.popular.isError = false; + state.popular.isRefreshing = withOverride ?? initialCategoryState.isRefreshing; }), ); try { @@ -284,6 +332,7 @@ export const useArticleStore = create((set, get) => ({ produce((state: ArticleState) => { state.newest.isFetching = true; state.newest.isError = false; + state.newest.isRefreshing = withOverride ?? initialCategoryState.isRefreshing; }), ); try { From 83e35cdb744b6692e183aa0a6ac51e697e4d54cd Mon Sep 17 00:00:00 2001 From: Kestas Venslauskas Date: Sat, 1 Feb 2025 16:39:48 +0200 Subject: [PATCH 3/7] feat: text-2-speech player update --- .../videoComponent/context/PlayerContext.ts | 1 + .../videoComponent/context/PlayerProvider.tsx | 3 +- app/screens/article/ArticleCompositor.ts | 22 ++---------- .../article/ArticleContentComponent.tsx | 35 ++----------------- app/screens/article/header/Header.tsx | 30 ++++++++++------ 5 files changed, 28 insertions(+), 63 deletions(-) diff --git a/app/components/videoComponent/context/PlayerContext.ts b/app/components/videoComponent/context/PlayerContext.ts index 4a79484..9bee868 100644 --- a/app/components/videoComponent/context/PlayerContext.ts +++ b/app/components/videoComponent/context/PlayerContext.ts @@ -17,6 +17,7 @@ export type MediaBaseData = { }; export type PlayerContextType = { + mediaData?: MediaBaseData; setMediaData: (data: MediaBaseData) => void; close: () => void; }; diff --git a/app/components/videoComponent/context/PlayerProvider.tsx b/app/components/videoComponent/context/PlayerProvider.tsx index 7312fcd..a0e100d 100644 --- a/app/components/videoComponent/context/PlayerProvider.tsx +++ b/app/components/videoComponent/context/PlayerProvider.tsx @@ -89,13 +89,14 @@ const PlayerProvider: React.FC> = (props) => { const context: PlayerContextType = useMemo( () => ({ + mediaData: currentMedia, setMediaData: (data: MediaBaseData) => { console.log('setMediaData', data); setCurrentMedia(data); }, close: handleClose, }), - [setCurrentMedia, handleClose], + [currentMedia, setCurrentMedia, handleClose], ); return ( diff --git a/app/screens/article/ArticleCompositor.ts b/app/screens/article/ArticleCompositor.ts index c8439d7..5373e47 100644 --- a/app/screens/article/ArticleCompositor.ts +++ b/app/screens/article/ArticleCompositor.ts @@ -15,7 +15,6 @@ export const TYPE_PARAGRAPH = 'content_paragraph'; export const TYPE_VIDEO = 'content_video'; export const TYPE_AUDIO = 'content_audio'; export const TYPE_AUDIO_CONTENT = 'content_audio_content'; -export const TYPE_TEXT_TO_SPEECH = 'content_text2speech'; export type ArticleContentItemType = { type: @@ -27,8 +26,7 @@ export type ArticleContentItemType = { | typeof TYPE_PARAGRAPH | typeof TYPE_VIDEO | typeof TYPE_AUDIO - | typeof TYPE_AUDIO_CONTENT - | typeof TYPE_TEXT_TO_SPEECH; + | typeof TYPE_AUDIO_CONTENT; data: any; }; @@ -44,9 +42,7 @@ const composeDefault = (article: ArticleContentDefault) => { const data = []; data.push(getHeaderData(article)); data.push(getMainPhoto(article)); - if (article.text2speech_file_url) { - data.push(getTextToSpeech(article)); - } + if (article.article_summary) { data.push(getSummary(article)); } @@ -104,7 +100,7 @@ const getHeaderData = (article: ArticleContent): ArticleContentItemType => { subtitle: isDefaultArticle(article) ? article.article_subtitle : article.subtitle, facebookReactions: isDefaultArticle(article) ? article.reactions_count : undefined, author: author, - text2SpeechEnabled: isDefaultArticle(article) ? Boolean(article.text2speech_file_url) : false, + text2SpeechUrl: isDefaultArticle(article) ? article.text2speech_file_url : undefined, }, }; }; @@ -204,15 +200,3 @@ const getAudio = (article: ArticleContentMedia): ArticleContentItemType => { }, }; }; - -const getTextToSpeech = (article: ArticleContentDefault): ArticleContentItemType => { - return { - type: TYPE_TEXT_TO_SPEECH, - data: { - title: article.article_title, - cover: article.main_photo, - streamUri: article.text2speech_file_url, - mediaId: article.text2speech_file_url, - }, - }; -}; diff --git a/app/screens/article/ArticleContentComponent.tsx b/app/screens/article/ArticleContentComponent.tsx index 19818c5..345ef5e 100644 --- a/app/screens/article/ArticleContentComponent.tsx +++ b/app/screens/article/ArticleContentComponent.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {View, Animated, StyleSheet, ListRenderItemInfo, useWindowDimensions} from 'react-native'; import Header from './header/Header'; import {getSmallestDim} from '../../util/UI'; @@ -12,7 +12,6 @@ import { TYPE_GALLERY, TYPE_VIDEO, TYPE_AUDIO, - TYPE_TEXT_TO_SPEECH, ArticleContentItemType, TYPE_AUDIO_CONTENT, TYPE_KEYWORDS, @@ -36,8 +35,6 @@ interface Props { } const ArticleContentComponent: React.FC> = ({article, itemPressHandler}) => { - const [isTextToSpeechPlaying, setTextToSpeechPlaying] = useState(false); - const {width: screenWidth} = useWindowDimensions(); const contentWidth = screenWidth - 12 * 2; @@ -51,19 +48,9 @@ const ArticleContentComponent: React.FC> = ({arti switch (type) { case TYPE_HEADER: { - return ( -
- ); + return
; } case TYPE_MAIN_PHOTO: { - if (isTextToSpeechPlaying) { - //We will render text2Speech component instead - return null; - } return ( > = ({arti case TYPE_AUDIO_CONTENT: { return ; } - case TYPE_TEXT_TO_SPEECH: { - if (!isTextToSpeechPlaying) { - return null; - } else { - return ( - - - - ); - } - } case TYPE_KEYWORDS: { return ; } @@ -126,7 +97,7 @@ const ArticleContentComponent: React.FC> = ({arti } } }, - [contentWidth, isTextToSpeechPlaying, itemPressHandler], + [contentWidth, itemPressHandler], ); const appBarHeight = useAppBarHeight(); diff --git a/app/screens/article/header/Header.tsx b/app/screens/article/header/Header.tsx index b94cb60..222c73e 100644 --- a/app/screens/article/header/Header.tsx +++ b/app/screens/article/header/Header.tsx @@ -4,6 +4,8 @@ import {FacebookReactions, Text, TouchableDebounce} from '../../../components'; import {useTheme} from '../../../Theme'; import {IconVolume} from '../../../components/svg'; import {checkEqual} from '../../../util/LodashEqualityCheck'; +import {useMediaPlayer} from '../../../components/videoComponent/context/useMediaPlayer'; +import {MediaType} from '../../../components/videoComponent/context/PlayerContext'; interface Props { category: string; @@ -12,11 +14,7 @@ interface Props { subtitle?: string; facebookReactions?: string; author: string; - - //Text 2 speech params. Maybe move it elsewhere later on? - text2SpeechEnabled: boolean; - isText2SpeechPlaying: boolean; - onPlayStateChange: (play: boolean) => void; + text2SpeechUrl?: string; } const ArticleHeader: React.FC> = ({ @@ -26,17 +24,27 @@ const ArticleHeader: React.FC> = ({ date, facebookReactions, subtitle, - text2SpeechEnabled, - isText2SpeechPlaying, - onPlayStateChange, + text2SpeechUrl, }) => { const {colors} = useTheme(); + const {setMediaData, mediaData, close} = useMediaPlayer(); + const isText2SpeechPlaying = mediaData?.uri === text2SpeechUrl; + const text2SpeechPlayHander = useCallback(() => { - onPlayStateChange(!isText2SpeechPlaying); - }, [isText2SpeechPlaying, onPlayStateChange]); + if (isText2SpeechPlaying) { + close(); + } else { + setMediaData({ + isLiveStream: false, + mediaType: MediaType.AUDIO, + title: title, + uri: text2SpeechUrl!, + }); + } + }, [isText2SpeechPlaying]); - const text2SpeechComponent = text2SpeechEnabled ? ( + const text2SpeechComponent = Boolean(text2SpeechUrl) ? ( Date: Sun, 2 Feb 2025 13:35:12 +0200 Subject: [PATCH 4/7] feat: radioteka podcast screen implementation --- ...oheight-webview-npm-1.6.5-c3f5e8ed07.patch | 25 +- app/Theme.ts | 7 +- app/api/Endpoints.ts | 8 + app/api/Types.ts | 40 +++ app/api/index.ts | 9 + .../paragraph/ArticleParagraph.tsx | 13 +- app/components/svg/ic_player_close.js | 15 ++ app/components/svg/ic_player_next.js | 15 ++ app/components/svg/ic_player_pause_v2.js | 15 ++ app/components/svg/ic_player_play_v2.js | 15 ++ app/components/svg/ic_player_previous.js | 15 ++ app/components/svg/index.js | 5 + .../videoComponent/MediaControls.tsx | 24 +- .../videoComponent/PlayerSeekBar.tsx | 239 ++++++++++++++++++ .../videoComponent/TheoMediaPlayer.tsx | 47 +--- .../videoComponent/context/PlayerProvider.tsx | 130 ++-------- .../miniPlayerAudio/MiniPlayerAudio.tsx | 195 ++++++++++++++ .../miniPlayerVideo/MiniPlayerVideo.tsx | 97 +++++++ app/navigation/MainStack.tsx | 4 + app/screens/article/ArticleCompositor.ts | 2 + app/screens/article/header/Header.tsx | 3 + app/screens/channel/ChannelComponent.tsx | 2 +- app/screens/index.ts | 1 + .../tabScreen/audioteka/AudiotekaScreen.tsx | 10 +- .../tabScreen/category/CategoryHomeScreen.tsx | 7 +- .../main/tabScreen/home/HomeScreen.tsx | 14 +- .../tabScreen/radioteka/RadiotekaScreen.tsx | 61 ++++- .../components/hero/RadiotekaHero.tsx | 37 ++- .../components/hero/RadiotekaHeroCarousel.tsx | 109 ++++---- .../radioteka/components/hero/mockData.ts | 32 --- .../RadiotekaHorizontalCategoryList.tsx | 9 +- .../RadiotekaHorizontalList.tsx | 7 +- .../radioteka/hooks/useArticlePlayer.ts | 36 +++ app/screens/podcast/PodcastScreen.tsx | 131 ++++++++++ app/screens/podcast/about/PodcastAbout.tsx | 143 +++++++++++ .../podcast/episode/PodcastEpisode.tsx | 75 ++++++ .../PodcastRecommendations.tsx | 66 +++++ .../recommendations/useRecommendations.ts | 27 ++ package.json | 5 +- 39 files changed, 1383 insertions(+), 312 deletions(-) rename patches/react-native-autoheight-webview+1.6.5.patch => .yarn/patches/react-native-autoheight-webview-npm-1.6.5-c3f5e8ed07.patch (69%) create mode 100644 app/components/svg/ic_player_close.js create mode 100644 app/components/svg/ic_player_next.js create mode 100644 app/components/svg/ic_player_pause_v2.js create mode 100644 app/components/svg/ic_player_play_v2.js create mode 100644 app/components/svg/ic_player_previous.js create mode 100644 app/components/videoComponent/PlayerSeekBar.tsx create mode 100644 app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx create mode 100644 app/components/videoComponent/context/miniPlayerVideo/MiniPlayerVideo.tsx delete mode 100644 app/screens/main/tabScreen/radioteka/components/hero/mockData.ts create mode 100644 app/screens/main/tabScreen/radioteka/hooks/useArticlePlayer.ts create mode 100644 app/screens/podcast/PodcastScreen.tsx create mode 100644 app/screens/podcast/about/PodcastAbout.tsx create mode 100644 app/screens/podcast/episode/PodcastEpisode.tsx create mode 100644 app/screens/podcast/recommendations/PodcastRecommendations.tsx create mode 100644 app/screens/podcast/recommendations/useRecommendations.ts diff --git a/patches/react-native-autoheight-webview+1.6.5.patch b/.yarn/patches/react-native-autoheight-webview-npm-1.6.5-c3f5e8ed07.patch similarity index 69% rename from patches/react-native-autoheight-webview+1.6.5.patch rename to .yarn/patches/react-native-autoheight-webview-npm-1.6.5-c3f5e8ed07.patch index 3147e59..3ce26d0 100644 --- a/patches/react-native-autoheight-webview+1.6.5.patch +++ b/.yarn/patches/react-native-autoheight-webview-npm-1.6.5-c3f5e8ed07.patch @@ -1,7 +1,7 @@ -diff --git a/node_modules/react-native-autoheight-webview/autoHeightWebView/index.js b/node_modules/react-native-autoheight-webview/autoHeightWebView/index.js -index 3d0dc75..7138615 100644 ---- a/node_modules/react-native-autoheight-webview/autoHeightWebView/index.js -+++ b/node_modules/react-native-autoheight-webview/autoHeightWebView/index.js +diff --git a/autoHeightWebView/index.js b/autoHeightWebView/index.js +index 3d0dc752d665d51a1bc4197aede9aa0db7ea114a..c35cbf75b9be4d95b7df3925bb922349cbc393a0 100644 +--- a/autoHeightWebView/index.js ++++ b/autoHeightWebView/index.js @@ -1,6 +1,6 @@ import React, {useState, useEffect, forwardRef} from 'react'; @@ -20,16 +20,21 @@ index 3d0dc75..7138615 100644 const AutoHeightWebView = React.memo( forwardRef((props, ref) => { const { -@@ -44,9 +47,10 @@ const AutoHeightWebView = React.memo( +@@ -44,11 +47,12 @@ const AutoHeightWebView = React.memo( scrollEnabledWithZoomedin && setScrollable(!!zoomedin); const {height: previousHeight, width: previousWidth} = size; - isSizeChanged({height, previousHeight, width, previousWidth}) && +- setSize({ +- height, +- width, +- }); + const newHeight = MAX_HEIGHT !== -1 ? Math.min(height, MAX_HEIGHT) : height; + isSizeChanged({height: newHeight, previousHeight, width, previousWidth}) && - setSize({ -- height, -+ height: newHeight, - width, - }); ++ setSize({ ++ height: newHeight, ++ width, ++ }); } catch (error) { + onMessage && onMessage(event); + } diff --git a/app/Theme.ts b/app/Theme.ts index 4b3a4cc..d749df2 100644 --- a/app/Theme.ts +++ b/app/Theme.ts @@ -86,8 +86,8 @@ export const strings: Dictionary = { about: 'Apie LRT', contacts: 'Kontaktai', feeback: 'Pranešk apie klaidą', - about_episode: 'APIE EPIZODĄ', - about_show: 'APIE LAIDĄ', + about_episode: 'Apie epizodą', + about_show: 'Apie laidą', previous_songs: 'ANKSTESNI', no_search_results: 'Deja, rezultatų pagal šią užklausą rasti nepavyko.', error_no_connection: 'Tinklo sutrikimas', @@ -169,6 +169,7 @@ type ColorScheme = { lightGreyBackground: '#F9F9F9'; dailyQuestionProgress: string; epikaGreen: '#58EB52'; + playerIcons: 'rgb(217, 32, 83)'; }; const colorsLight: ColorScheme = { @@ -212,6 +213,7 @@ const colorsLight: ColorScheme = { lightGreyBackground: '#F9F9F9', ripple: '#99999940', epikaGreen: '#58EB52', + playerIcons: 'rgb(217, 32, 83)', }; const colorsDark: ColorScheme = { @@ -255,6 +257,7 @@ const colorsDark: ColorScheme = { ripple: '#99999940', dailyQuestionProgress: '#36414f', epikaGreen: '#58EB52', + playerIcons: 'rgb(217, 32, 83)', }; //#endregion diff --git a/app/api/Endpoints.ts b/app/api/Endpoints.ts index b36b2a1..3dbf1bf 100644 --- a/app/api/Endpoints.ts +++ b/app/api/Endpoints.ts @@ -175,6 +175,14 @@ export const liveFeedGet = (id: string | number, count: number, order: 'desc' | return `${BASE_URL}get-feed-items/${id}?count=${count}&order=${order}`; }; +export const articleRecommendations = (articleId: number | string) => { + return `https://peach.ebu.io/api/v1/ltlrt/similar?article_id=${articleId}`; +}; + +export const articlesByIds = (ids: string[]) => { + return `https://www.lrt.lt/api/json/search?ids=${ids.join(',')}`; +}; + export const carPlaylistNewestGet = () => 'https://www.lrt.lt/static/carplay/naujausi.json'; export const carPlaylistPopularGet = () => 'https://www.lrt.lt/static/carplay/pop.json'; diff --git a/app/api/Types.ts b/app/api/Types.ts index e83e729..4efc0fe 100644 --- a/app/api/Types.ts +++ b/app/api/Types.ts @@ -736,6 +736,7 @@ export type ArticleContentDefault = { }[]; 'n-18'?: 0 | 1; is_video?: 0 | 1; + read_count?: number; }; export type ArticleContentMedia = { @@ -766,6 +767,8 @@ export type ArticleContentMedia = { 'n-18'?: 0 | 1; is_video?: 0 | 1; is_audio?: 0 | 1; + read_count?: number; + media_duration: string; }; export const isMediaArticle = (article?: ArticleContent): article is ArticleContentMedia => { @@ -932,3 +935,40 @@ export type Keyword = { name: string; slug: string; }; + +export type ArticleRecommendationsResponse = { + result?: { + items?: { + id: string; + score: number; + }[]; + id: string; + }; +}; + +export type ArticleSearchItem = { + id_pos: number; + age_restriction?: string; + title: string; + year_interval: number; + is_epika: 0 | 1; + is_series: 0 | 1; + badges_html?: string; + subtitle?: string; + id: number; + date: string; + epika_valid_days?: string; + season_url: string; + photo: string; + article_category_id: number; + img_w_h: string; + category_id: number; + category_title: string; + is_movie: 0 | 1; + photo_id: number; + url: string; +}; + +export type ArticlesByIdsResponse = { + items: ArticleSearchItem[]; +}; diff --git a/app/api/index.ts b/app/api/index.ts index babef1f..ad08264 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -1,5 +1,7 @@ import { articleGet, + articleRecommendations, + articlesByIds, articlesGetByTag, audiotekaGet, carPlaylistCategoryGet, @@ -29,6 +31,8 @@ import { import {get, put} from './HttpClient'; import { ArticleContentResponse, + ArticleRecommendationsResponse, + ArticlesByIdsResponse, AudiotekaResponse, CarPlayCategoryResponse, CarPlayPodcastsResponse, @@ -105,6 +109,11 @@ export const fetchOpusPlaylist = () => get(opusPlaylistGet export const fetchLiveFeed = (id: string | number, count: number, order: 'asc' | 'desc') => get(liveFeedGet(id, count, order)); +export const fetchArticleRecommendations = (articleId: number | string) => + get(articleRecommendations(articleId)); + +export const fetchArticlesByIds = (ids: string[]) => get(articlesByIds(ids)); + export const setDailyQuestionVote = (questionId: number | string, choiceId: number | string) => put(putDailyQuestionVote(questionId, choiceId)); diff --git a/app/components/articleParagraphs/paragraph/ArticleParagraph.tsx b/app/components/articleParagraphs/paragraph/ArticleParagraph.tsx index a307d1d..e5e1a3d 100644 --- a/app/components/articleParagraphs/paragraph/ArticleParagraph.tsx +++ b/app/components/articleParagraphs/paragraph/ArticleParagraph.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import {View, StyleSheet} from 'react-native'; import HTMLRenderer from '../../htmlRenderer/HTMLRenderer'; interface Props { @@ -8,17 +7,7 @@ interface Props { } const ArticleParagraph: React.FC> = ({htmlText = '', textSize}) => { - return ( - - - - ); + return ; }; export default ArticleParagraph; - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, -}); diff --git a/app/components/svg/ic_player_close.js b/app/components/svg/ic_player_close.js new file mode 100644 index 0000000..0f9dc62 --- /dev/null +++ b/app/components/svg/ic_player_close.js @@ -0,0 +1,15 @@ +import * as React from 'react'; +import Svg, {Path} from 'react-native-svg'; + +function SvgComponent(props) { + return ( + + + + ); +} + +export default SvgComponent; diff --git a/app/components/svg/ic_player_next.js b/app/components/svg/ic_player_next.js new file mode 100644 index 0000000..5c76b96 --- /dev/null +++ b/app/components/svg/ic_player_next.js @@ -0,0 +1,15 @@ +import * as React from 'react'; +import Svg, {Path} from 'react-native-svg'; + +function SvgComponent(props) { + return ( + + + + ); +} + +export default SvgComponent; diff --git a/app/components/svg/ic_player_pause_v2.js b/app/components/svg/ic_player_pause_v2.js new file mode 100644 index 0000000..78f4b8b --- /dev/null +++ b/app/components/svg/ic_player_pause_v2.js @@ -0,0 +1,15 @@ +import * as React from 'react'; +import Svg, {Path} from 'react-native-svg'; + +function SvgComponent(props) { + return ( + + + + ); +} + +export default SvgComponent; diff --git a/app/components/svg/ic_player_play_v2.js b/app/components/svg/ic_player_play_v2.js new file mode 100644 index 0000000..2ee3210 --- /dev/null +++ b/app/components/svg/ic_player_play_v2.js @@ -0,0 +1,15 @@ +import * as React from 'react'; +import Svg, {Path} from 'react-native-svg'; + +function SvgComponent(props) { + return ( + + + + ); +} + +export default SvgComponent; diff --git a/app/components/svg/ic_player_previous.js b/app/components/svg/ic_player_previous.js new file mode 100644 index 0000000..7aff050 --- /dev/null +++ b/app/components/svg/ic_player_previous.js @@ -0,0 +1,15 @@ +import * as React from 'react'; +import Svg, {Path} from 'react-native-svg'; + +function SvgComponent(props) { + return ( + + + + ); +} + +export default SvgComponent; diff --git a/app/components/svg/index.js b/app/components/svg/index.js index 8890936..e8fbc52 100644 --- a/app/components/svg/index.js +++ b/app/components/svg/index.js @@ -33,11 +33,16 @@ export {default as Icon404} from './ic_404'; export {default as IconPlay} from './ic_play'; export {default as IconSubtitles} from './ic_subtitles'; export {default as IconPlayerPlay} from './ic_player_play'; +export {default as IconPlayerPlayV2} from './ic_player_play_v2'; export {default as IconPlayerPause} from './ic_player_pause'; +export {default as IconPlayerPauseV2} from './ic_player_pause_v2'; export {default as IconPlayerVolume} from './ic_player_volume'; export {default as IconPlayerMute} from './ic_player_mute'; export {default as IconPlayerRewind} from './ic_player_rewind'; export {default as IconPlayerForward} from './ic_player_forward'; +export {default as IconPlayerPrevious} from './ic_player_previous'; +export {default as IconPlayerNext} from './ic_player_next'; +export {default as IconPlayerClose} from './ic_player_close'; export {default as IconFullscreen} from './ic_fullscreen'; export {default as IconPhotoCamera} from './ic_photo_camera'; export {default as IconScreenError} from './ic_screen_error'; diff --git a/app/components/videoComponent/MediaControls.tsx b/app/components/videoComponent/MediaControls.tsx index ac0b6f9..cebf902 100644 --- a/app/components/videoComponent/MediaControls.tsx +++ b/app/components/videoComponent/MediaControls.tsx @@ -17,14 +17,15 @@ import { IconFullscreen, IconPlayerForward, IconPlayerMute, - IconPlayerPause, - IconPlayerPlay, + IconPlayerPauseV2, + IconPlayerPlayV2, IconPlayerRewind, IconPlayerVolume, } from '../svg'; import TextComponent from '../text/Text'; import {CastButton} from 'react-native-google-cast'; import TouchableDebounce from '../touchableDebounce/TouchableDebounce'; +import {useTheme} from '../../Theme'; const CONTROLS_TIMEOUT_MS = 2500; @@ -107,6 +108,9 @@ const MediaControls: React.FC> = ({ const seekPanResponder = useRef(); const seekerWidth = useRef(0); + const offset = useRef(0); + + const {colors} = useTheme(); const isMiniPlayer = isMini && !isFullScreen; const isLiveStream = isNaN(mediaDuration) || !isFinite(mediaDuration) || mediaDuration <= 0; @@ -153,18 +157,24 @@ const MediaControls: React.FC> = ({ onShouldBlockNativeResponder: () => true, onPanResponderGrant: (event, _gestureState) => { + const {pageX, locationX} = event.nativeEvent; + offset.current = pageX - locationX; setPosition(event.nativeEvent.locationX); setScrubbing(true); resetControlsTimeout(); }, onPanResponderMove: (event, _gestureState) => { - setPosition(event.nativeEvent.locationX); + const {pageX} = event.nativeEvent; + const positionX = pageX - offset.current; + setPosition(positionX); resetControlsTimeout(); }, onPanResponderRelease: (event, _gestureState) => { - const newPosition = setPosition(event.nativeEvent.locationX); + const {pageX} = event.nativeEvent; + const positionX = pageX - offset.current; + const newPosition = setPosition(positionX); const normalizedPosition = Math.max(0, Math.min(seekerWidth.current, newPosition)); const percentage = normalizedPosition / seekerWidth.current; const newTime = (seekerEnd - seekerStart) * percentage; @@ -230,9 +240,9 @@ const MediaControls: React.FC> = ({ hitSlop={HIT_SLOP} activeOpacity={0.6}> {isPaused ? ( - + ) : ( - + )} ), @@ -339,6 +349,7 @@ const MediaControls: React.FC> = ({ styles.seekBar_fill, { width: position, + backgroundColor: colors.playerIcons, }, ]} pointerEvents={'none'} @@ -504,7 +515,6 @@ const styles = StyleSheet.create({ width: '100%', }, seekBar_fill: { - backgroundColor: '#DD0000', height: 5, }, activityIndicator: { diff --git a/app/components/videoComponent/PlayerSeekBar.tsx b/app/components/videoComponent/PlayerSeekBar.tsx new file mode 100644 index 0000000..a211c22 --- /dev/null +++ b/app/components/videoComponent/PlayerSeekBar.tsx @@ -0,0 +1,239 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {PanResponder, PanResponderInstance, StyleSheet, View, ViewStyle} from 'react-native'; +import {LoadedMetadataEvent, PlayerEventType, THEOplayer, TimeUpdateEvent} from 'react-native-theoplayer'; +import {useTheme} from '../../Theme'; +import TextComponent from '../text/Text'; + +const formatTimeElapsed = (currentTime: number, duration: number) => { + const time = Math.floor(Math.min(Math.max(currentTime / 1000, 0), duration / 1000)); + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + const result = `${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; + return result; +}; + +const formatLiveStreamTime = (time: number, maxTime: number) => { + const currentTime = Math.max(0, maxTime - time) / 1000; + const minutes = Math.floor(currentTime / 60); + const seconds = Math.floor(currentTime % 60); + const result = `-${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; + return result; +}; + +const formatTimeTotal = (duration: number) => { + const time = Math.max(duration / 1000, 0); + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + const result = `${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; + return result; +}; + +interface Props { + style?: ViewStyle; + player: THEOplayer; +} + +const PlayerSekBar: React.FC> = ({style, player}) => { + const [isLoading, setIsLoading] = useState(true); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [seekerPosition, setSeekerPosition] = useState(0); + const [scrubbing, setScrubbing] = useState(false); + + const {colors} = useTheme(); + + const seekPanResponder = useRef(); + + const offset = useRef(0); + const seekerWidth = useRef(0); + + const seekerStart = player.seekable[0]?.start ?? 1; + const seekerEnd = player.seekable[0]?.end ?? 1; + + const isLiveStream = isNaN(duration) || !isFinite(duration) || duration <= 0; + const isSeekableMoreThanMinute = seekerEnd - seekerStart > 60 * 1000; + const showSeeker = (!isLiveStream || isSeekableMoreThanMinute) && !isNaN(seekerPosition); + + const setPosition = useCallback((position: number) => { + const pos = Math.max(0, Math.min(seekerWidth.current, position)); + setSeekerPosition(pos); + return pos; + }, []); + + useEffect(() => { + if (player) { + const onTimeUpdateHandler = (e: TimeUpdateEvent) => { + setCurrentTime(e.currentTime); + }; + + const onLoadedMetaDataHandler = (e: LoadedMetadataEvent) => { + const duration = e.duration === Infinity ? 0 : e.duration; + setDuration(duration); + }; + + const onLoadedDataHandler = (_: any) => { + setIsLoading(false); + }; + + player.addEventListener(PlayerEventType.TIME_UPDATE, onTimeUpdateHandler); + player.addEventListener(PlayerEventType.LOADED_METADATA, onLoadedMetaDataHandler); + player.addEventListener(PlayerEventType.LOADED_DATA, onLoadedDataHandler); + + return () => { + player.removeEventListener(PlayerEventType.TIME_UPDATE, onTimeUpdateHandler); + player.removeEventListener(PlayerEventType.LOADED_METADATA, onLoadedMetaDataHandler); + player.removeEventListener(PlayerEventType.LOADED_DATA, onLoadedDataHandler); + }; + } + }, [player]); + + useEffect(() => { + if (!scrubbing) { + const normalizedPosition = (currentTime - seekerStart) / (seekerEnd - seekerStart); + setPosition(normalizedPosition * seekerWidth.current); + } + }, [currentTime, seekerStart, seekerEnd, scrubbing]); + + seekPanResponder.current = useMemo(() => { + return PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onShouldBlockNativeResponder: () => true, + onPanResponderTerminationRequest: () => true, + + onPanResponderTerminate: (evt, gestureState) => { + console.log('onPanResponderTerminate', evt, gestureState); + }, + + onPanResponderGrant: (event, _gestureState) => { + const {pageX, locationX} = event.nativeEvent; + offset.current = pageX - locationX; + setPosition(locationX); + setScrubbing(true); + }, + + onPanResponderMove: (event, _gestureState) => { + const {pageX} = event.nativeEvent; + const positionX = pageX - offset.current; + setPosition(positionX); + }, + + onPanResponderRelease: (event, _gestureState) => { + const {pageX} = event.nativeEvent; + const positionX = pageX - offset.current; + const newPosition = setPosition(positionX); + const normalizedPosition = Math.max(0, Math.min(seekerWidth.current, newPosition)); + const percentage = normalizedPosition / seekerWidth.current; + const newTime = (seekerEnd - seekerStart) * percentage; + player.currentTime = newTime; + setTimeout(() => { + setScrubbing(false); + }, 300); + }, + }); + }, [seekerStart, seekerEnd, setPosition, player]); + + const TimerControl = useCallback( + ({time}: {time: string}) => ( + + {time} + + ), + [], + ); + + if (isLoading) { + return ; + } + + if (!showSeeker) { + return ( + + + {isLiveStream ? ( + gyvai + ) : null} + + + ); + } + + return ( + + {isLiveStream ? null : } + + (seekerWidth.current = event.nativeEvent.layout.width)} + pointerEvents="none"> + + + + {isLiveStream ? ( + + ) : ( + + )} + + ); +}; + +export default PlayerSekBar; + +const styles = StyleSheet.create({ + root: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + height: 24, + }, + seekBar_container: { + flex: 1, + marginHorizontal: 4, + marginVertical: 6, + justifyContent: 'center', + }, + seekBar_track: { + borderRadius: 2, + height: 4, + overflow: 'hidden', + backgroundColor: '#EEE', + width: '100%', + }, + seekBar_fill: { + height: 5, + }, + timerText: { + flexGrow: 0, + fontSize: 11, + letterSpacing: 0.8, + }, + liveText: { + flexGrow: 0, + fontSize: 12, + color: '#000', + letterSpacing: 0.8, + textTransform: 'uppercase', + }, +}); diff --git a/app/components/videoComponent/TheoMediaPlayer.tsx b/app/components/videoComponent/TheoMediaPlayer.tsx index b0977fa..2ae7f64 100644 --- a/app/components/videoComponent/TheoMediaPlayer.tsx +++ b/app/components/videoComponent/TheoMediaPlayer.tsx @@ -25,7 +25,6 @@ import useChromecast from './useChromecast'; import {MediaPlayerState} from 'react-native-google-cast'; import {useTheme} from '../../Theme'; import usePlayerLanguage from './usePlayerLanguage'; -import {EventRegister} from 'react-native-event-listeners'; import FastImage from 'react-native-fast-image'; import {VideoTextTrack} from '../../api/Types'; import usePlayerSubtitles from './usePlayerSubtitles'; @@ -33,8 +32,6 @@ import {useSettingsStore} from '../../state/settings_store'; import crashlytics from '@react-native-firebase/crashlytics'; import Config from 'react-native-config'; -export type PlayerAction = 'togglePlay' | 'setFullScreen'; - interface Props { mediaType: MediaType; streamUri: string; @@ -45,7 +42,6 @@ interface Props { startTime?: number; isMini?: boolean; minifyEnabled?: boolean; - uuid?: string; controls?: boolean; loop?: boolean; aspectRatio?: number; @@ -53,6 +49,7 @@ interface Props { tracks?: VideoTextTrack[]; onError?: (e?: any) => void; onEnded?: () => void; + onPlayerReadyCallback?: (player: THEOplayer) => void; } const config: PlayerConfiguration = { @@ -106,7 +103,7 @@ const makeSource = ( const TheoMediaPlayer: React.FC> = ({ streamUri, - mediaType, + mediaType = MediaType.VIDEO, title, poster = VIDEO_DEFAULT_BACKGROUND_IMAGE, autoStart, @@ -116,12 +113,12 @@ const TheoMediaPlayer: React.FC> = ({ isMini = false, loop = false, minifyEnabled = true, - uuid, controls = true, aspectRatio = 16 / 9, backgroundAudioEnabled = true, onError, onEnded, + onPlayerReadyCallback, }) => { const [player, setPlayer] = useState(); const [duration, setDuration] = useState(0); @@ -218,6 +215,13 @@ const TheoMediaPlayer: React.FC> = ({ [], ); + const onSeekHandler = useCallback( + (player: THEOplayer) => (_: Event) => { + trackSeek(streamUri, player.currentTime / 1000); + }, + [], + ); + const onPlayHandler = useCallback( (player: THEOplayer) => (_: Event) => { if (player?.currentTime) { @@ -297,7 +301,7 @@ const TheoMediaPlayer: React.FC> = ({ // player.addEventListener(PlayerEventType.PLAYING, console.log); player.addEventListener(PlayerEventType.PLAY, onPlayHandler(player)); player.addEventListener(PlayerEventType.PAUSE, onPauseHandler(player)); - // player.addEventListener(PlayerEventType.SEEKING, console.log); + player.addEventListener(PlayerEventType.SEEKED, onSeekHandler(player)); // player.addEventListener(PlayerEventType.ENDED, onEndedHandler(player)); // player.addEventListener(PlayerEventType.ENDED, console.log); @@ -326,6 +330,7 @@ const TheoMediaPlayer: React.FC> = ({ // console.log('targetVideoQuality:', player.targetVideoQuality); //player.playbackRate = 1.5; //player.selectedVideoTrack = player.videoTracks[0]; + onPlayerReadyCallback?.(player); }; useEffect(() => { @@ -367,7 +372,6 @@ const TheoMediaPlayer: React.FC> = ({ const _seekControl = useCallback( (time: number) => { if (player) { - trackSeek(streamUri, time); player.currentTime = time * 1000; client?.seek({position: time}); } @@ -379,7 +383,6 @@ const TheoMediaPlayer: React.FC> = ({ (time: number) => { if (player) { const newTime = player.currentTime + time * 1000; - trackSeek(streamUri, newTime / 1000); player.currentTime = newTime; client?.seek({position: time, relative: true}); } @@ -387,28 +390,6 @@ const TheoMediaPlayer: React.FC> = ({ [player, client], ); - useEffect(() => { - if (!uuid) { - return; - } - - const listener = EventRegister.addEventListener(uuid, (action: PlayerAction) => { - crashlytics().log('TheoMediaPlayer: Received event bus action: ' + action); - switch (action) { - case 'togglePlay': - _playPauseControl(); - break; - case 'setFullScreen': - _fullScreenControl(); - break; - } - }); - - return () => { - EventRegister.removeEventListener(listener as string); - }; - }, [uuid, _playPauseControl, _fullScreenControl]); - const {LanguageButton, LanguageMenu} = usePlayerLanguage({player: player}); const {SubtitlesButton, SubtitlesMenu} = usePlayerSubtitles({player: player}); @@ -418,7 +399,7 @@ const TheoMediaPlayer: React.FC> = ({ <> {isLoading && ( - + )} {!isLoading && player ? ( @@ -427,7 +408,7 @@ const TheoMediaPlayer: React.FC> = ({ ) : null} diff --git a/app/components/videoComponent/context/PlayerProvider.tsx b/app/components/videoComponent/context/PlayerProvider.tsx index a0e100d..294c61d 100644 --- a/app/components/videoComponent/context/PlayerProvider.tsx +++ b/app/components/videoComponent/context/PlayerProvider.tsx @@ -1,91 +1,35 @@ -import React, {useCallback, useMemo, useRef, useState} from 'react'; -import {StyleSheet, View} from 'react-native'; +import React, {useCallback, useMemo, useState} from 'react'; import {MediaBaseData, MediaType, PlayerContext, PlayerContextType} from './PlayerContext'; -import TheoMediaPlayer, {PlayerAction} from '../TheoMediaPlayer'; -import TouchableDebounce from '../../touchableDebounce/TouchableDebounce'; -import {IconClose} from '../../svg'; import {useTheme} from '../../../Theme'; -import Text from '../../text/Text'; -import {uniqueId} from 'lodash'; -import {EventRegister} from 'react-native-event-listeners'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import MiniPlayerVideo from './miniPlayerVideo/MiniPlayerVideo'; +import MiniPlayerAudio from './miniPlayerAudio/MiniPlayerAudio'; +import {View} from 'react-native'; const PlayerProvider: React.FC> = (props) => { const [currentMedia, setCurrentMedia] = useState(); - const uuid = useRef(uniqueId('player-')); - const {colors, dark} = useTheme(); - const {bottom} = useSafeAreaInsets(); + const {colors} = useTheme(); const handleClose = useCallback(() => { setCurrentMedia(undefined); }, []); - const handleFullScreen = useCallback(() => { - const action: PlayerAction = 'setFullScreen'; - EventRegister.emit(uuid.current, action); - }, [uuid]); - const renderMiniPlayer = useCallback(() => { if (!currentMedia) { return null; } return ( - - - - - - - - {currentMedia.title} - - - - - - - - - - + + {currentMedia.mediaType === MediaType.VIDEO ? : } + ); - }, [uuid, colors, dark, handleFullScreen, currentMedia]); + }, [colors, currentMedia]); const context: PlayerContextType = useMemo( () => ({ @@ -108,47 +52,3 @@ const PlayerProvider: React.FC> = (props) => { }; export default PlayerProvider; - -const BORDER_WIDTH = 0; - -const styles = StyleSheet.create({ - videoContainerPortrait: { - borderWidth: BORDER_WIDTH, - borderColor: 'white', - overflow: 'hidden', - borderRadius: 8, - //Aspect ratio 16:9 - width: 240 - BORDER_WIDTH * 2, - height: 135, - // aspectRatio: 16 / 9, - - elevation: 5, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 0, - }, - shadowOpacity: 0.27, - shadowRadius: 4.65, - }, - videoContainer: { - overflow: 'hidden', - //Aspect ratio 16:9 - height: '100%', - aspectRatio: 16 / 9, - }, - closeButtonContainer: { - // width: 24, - // height: 28, - flexDirection: 'row', - borderWidth: StyleSheet.hairlineWidth, - borderColor: 'black', - gap: 8, - paddingVertical: 4, - paddingHorizontal: 8, - borderRadius: 100, - marginBottom: 4, - justifyContent: 'center', - alignItems: 'center', - }, -}); diff --git a/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx b/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx new file mode 100644 index 0000000..7b405a3 --- /dev/null +++ b/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx @@ -0,0 +1,195 @@ +import {useCallback, useEffect, useState} from 'react'; +import {PlayerEventType, THEOplayer} from 'react-native-theoplayer'; +import {useTheme} from '../../../../Theme'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {StyleSheet, View} from 'react-native'; +import TheoMediaPlayer from '../../TheoMediaPlayer'; +import TouchableDebounce from '../../../touchableDebounce/TouchableDebounce'; +import { + IconPlayerClose, + IconPlayerForward, + IconPlayerNext, + IconPlayerPauseV2, + IconPlayerPlayV2, + IconPlayerPrevious, + IconPlayerRewind, +} from '../../../svg'; +import {useMediaPlayer} from '../useMediaPlayer'; +import PlayerSekBar from '../../PlayerSeekBar'; + +const PLAYER_HEIGHT = 80; +const PADDING = 8; +const BORDER_RADIUS = 8; + +const MiniPlayerAudio: React.FC> = (props) => { + const [player, setPlayer] = useState(); + const [isPlaying, setPlaying] = useState(false); + + const {mediaData, close} = useMediaPlayer(); + const {colors} = useTheme(); + const {bottom} = useSafeAreaInsets(); + + useEffect(() => { + if (player) { + const onPlay = () => { + setPlaying(true); + }; + + const onPause = () => { + setPlaying(false); + }; + + player.addEventListener(PlayerEventType.PLAY, onPlay); + player.addEventListener(PlayerEventType.PAUSE, onPause); + return () => { + player.removeEventListener(PlayerEventType.PLAY, onPlay); + player.removeEventListener(PlayerEventType.PAUSE, onPause); + }; + } + }, [player]); + + const handlePlayPause = useCallback(() => { + if (player) { + if (player.paused) { + player.play(); + } else { + player.pause(); + } + } + }, [player]); + + const handleSeekBy = useCallback( + (seconds: number) => { + if (player) { + const newTime = player.currentTime + seconds * 1000; + player.currentTime = newTime; + } + }, + [player], + ); + + if (!mediaData) { + return null; + } + + return ( + + + + + + + + + handleSeekBy(-10)} hitSlop={12}> + + + handleSeekBy(-10)} hitSlop={12}> + + + + {isPlaying ? ( + + ) : ( + + )} + + handleSeekBy(10)} hitSlop={12}> + + + handleSeekBy(10)} hitSlop={12}> + + + + {player ? : null} + + + + + + + + + + ); +}; + +export default MiniPlayerAudio; + +const styles = StyleSheet.create({ + layout: { + height: PLAYER_HEIGHT, + flexDirection: 'row', + paddingEnd: PADDING, + zIndex: 4, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 4, + }, + videoContainer: { + overflow: 'hidden', + borderRadius: BORDER_RADIUS, + height: PLAYER_HEIGHT - PADDING * 2, + aspectRatio: 1, + }, + closeButttonContainer: { + position: 'absolute', + width: 24, + height: 24, + top: -8, + left: -8, + zIndex: 5, + backgroundColor: '#fff', + borderRadius: 12, + padding: 4, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, + }, +}); diff --git a/app/components/videoComponent/context/miniPlayerVideo/MiniPlayerVideo.tsx b/app/components/videoComponent/context/miniPlayerVideo/MiniPlayerVideo.tsx new file mode 100644 index 0000000..d55d6c1 --- /dev/null +++ b/app/components/videoComponent/context/miniPlayerVideo/MiniPlayerVideo.tsx @@ -0,0 +1,97 @@ +import {useCallback, useRef} from 'react'; +import {PresentationMode, THEOplayer} from 'react-native-theoplayer'; +import {useTheme} from '../../../../Theme'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import TheoMediaPlayer from '../../TheoMediaPlayer'; +import TouchableDebounce from '../../../touchableDebounce/TouchableDebounce'; +import {IconClose} from '../../../svg'; +import {useMediaPlayer} from '../useMediaPlayer'; + +const MiniPlayerVideo: React.FC> = (props) => { + const playerRef = useRef(); + + const {mediaData, close} = useMediaPlayer(); + const {colors} = useTheme(); + const {bottom} = useSafeAreaInsets(); + + const handleFullScreen = useCallback(() => { + if (playerRef.current) { + playerRef.current!.presentationMode = PresentationMode.fullscreen; + } + }, []); + + if (!mediaData) { + return null; + } + + return ( + + + + { + playerRef.current = player; + }} + /> + + + + + {mediaData.title} + + + + + + + + + ); +}; + +export default MiniPlayerVideo; + +const styles = StyleSheet.create({ + layout: { + height: 56, + // borderBottomWidth: StyleSheet.hairlineWidth, + flexDirection: 'row', + alignItems: 'center', + paddingEnd: 8, + zIndex: 4, + gap: 10, + shadowColor: '#00000066', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.27, + shadowRadius: 3.65, + }, + videoContainer: { + overflow: 'hidden', + height: '100%', + aspectRatio: 16 / 9, + }, +}); diff --git a/app/navigation/MainStack.tsx b/app/navigation/MainStack.tsx index 9fdd803..98d2086 100644 --- a/app/navigation/MainStack.tsx +++ b/app/navigation/MainStack.tsx @@ -58,6 +58,9 @@ export type MainStackParamList = { initialIndex: number; articles: Article[]; }; + Podcast: { + articleId: number; + }; }; const Stack = createNativeStackNavigator(); @@ -194,6 +197,7 @@ export default () => { + { title: isDefaultArticle(article) ? article.article_title : article.title, subtitle: isDefaultArticle(article) ? article.article_subtitle : article.subtitle, facebookReactions: isDefaultArticle(article) ? article.reactions_count : undefined, + image: buildArticleImageUri(IMG_SIZE_S, article.main_photo.path), author: author, text2SpeechUrl: isDefaultArticle(article) ? article.text2speech_file_url : undefined, }, diff --git a/app/screens/article/header/Header.tsx b/app/screens/article/header/Header.tsx index 222c73e..f0fd94d 100644 --- a/app/screens/article/header/Header.tsx +++ b/app/screens/article/header/Header.tsx @@ -12,6 +12,7 @@ interface Props { date?: string; title: string; subtitle?: string; + image?: string; facebookReactions?: string; author: string; text2SpeechUrl?: string; @@ -23,6 +24,7 @@ const ArticleHeader: React.FC> = ({ title, date, facebookReactions, + image, subtitle, text2SpeechUrl, }) => { @@ -40,6 +42,7 @@ const ArticleHeader: React.FC> = ({ mediaType: MediaType.AUDIO, title: title, uri: text2SpeechUrl!, + poster: image, }); } }, [isText2SpeechPlaying]); diff --git a/app/screens/channel/ChannelComponent.tsx b/app/screens/channel/ChannelComponent.tsx index 4b5409f..3a33e83 100644 --- a/app/screens/channel/ChannelComponent.tsx +++ b/app/screens/channel/ChannelComponent.tsx @@ -109,7 +109,7 @@ const ChannelComponent: React.FC> = ({ > = ({isCurrent}) } }, []); + const extraData = useMemo(() => ({lastFetchTime: lastFetchTime}), [lastFetchTime]); + if (data.length === 0) { return ; } @@ -154,14 +156,12 @@ const AudiotekaScreen: React.FC> = ({isCurrent}) showsVerticalScrollIndicator={false} //style={styles.container} ref={listRef} - extraData={{ - lastFetchTime: lastFetchTime, - }} + extraData={extraData} renderItem={renderItem} refreshControl={ fetchAudioteka()} />} data={data} removeClippedSubviews={false} - estimatedFirstItemOffset={500} + estimatedFirstItemOffset={800} estimatedItemSize={400} // windowSize={4} // updateCellsBatchingPeriod={20} diff --git a/app/screens/main/tabScreen/category/CategoryHomeScreen.tsx b/app/screens/main/tabScreen/category/CategoryHomeScreen.tsx index b49fb5d..a2e931d 100644 --- a/app/screens/main/tabScreen/category/CategoryHomeScreen.tsx +++ b/app/screens/main/tabScreen/category/CategoryHomeScreen.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {View, StyleSheet, RefreshControl} from 'react-native'; import { ArticleRow, @@ -213,6 +213,7 @@ const CategoryHomeScreen: React.FC> = ({isCurrent const keyExtractor = useCallback((item: HomeBlockType, index: number) => `${index}-${item.type}`, []); const insets = useSafeAreaInsets(); + const extraData = useMemo(() => ({lastFetchTime: lastFetchTime}), [lastFetchTime]); if (!items.length) { return ; @@ -225,9 +226,7 @@ const CategoryHomeScreen: React.FC> = ({isCurrent contentContainerStyle={{paddingBottom: insets.bottom}} ref={listRef} ListHeaderComponent={renderTitle()} - extraData={{ - lastFetchTime: lastFetchTime, - }} + extraData={extraData} renderItem={renderItem} refreshControl={ fetchCategoryHome(id, true)} /> diff --git a/app/screens/main/tabScreen/home/HomeScreen.tsx b/app/screens/main/tabScreen/home/HomeScreen.tsx index bd36b3f..9a2f3c6 100644 --- a/app/screens/main/tabScreen/home/HomeScreen.tsx +++ b/app/screens/main/tabScreen/home/HomeScreen.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {View, StyleSheet, StatusBar, RefreshControl} from 'react-native'; import { ArticleRow, @@ -197,6 +197,7 @@ const HomeScreen: React.FC> = ({isCurrent, type}) const insets = useSafeAreaInsets(); const keyExtractor = useCallback((item: HomeBlockType, index: number) => `${index}-${item.type}`, []); + const extraData = useMemo(() => ({lastFetchTime: lastFetchTime}), [lastFetchTime]); if (items.length === 0) { return ; @@ -212,22 +213,15 @@ const HomeScreen: React.FC> = ({isCurrent, type}) } ListHeaderComponent={renderForecast()} data={items} removeClippedSubviews={false} - estimatedItemSize={350} - // windowSize={6} - // updateCellsBatchingPeriod={20} - // maxToRenderPerBatch={4} - // initialNumToRender={8} + estimatedItemSize={400} keyExtractor={keyExtractor} /> diff --git a/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx b/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx index 70a21cf..8b57ce6 100644 --- a/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx +++ b/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {View, RefreshControl, StyleSheet, StatusBar} from 'react-native'; import {FlashList, ListRenderItemInfo} from '@shopify/flash-list'; import {ScreenLoader} from '../../../../components'; @@ -21,6 +21,7 @@ import {RadiotekaHeroCarousel} from './components/hero/RadiotekaHeroCarousel'; import {useNavigation} from '@react-navigation/native'; import {MainStackParamList} from '../../../../navigation/MainStack'; import {StackNavigationProp} from '@react-navigation/stack'; +import {useArticlePlayer} from './hooks/useArticlePlayer'; const WIDGET_ID_HERO = 21; @@ -44,6 +45,9 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) const navigation = useNavigation>(); const {fetchRadioteka} = useArticleStore.getState(); + + const {playArticle} = useArticlePlayer(); + const state = useArticleStore(useShallow(selectRadiotekaScreenState)); const {refreshing, lastFetchTime, data} = state; @@ -72,7 +76,7 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) return () => { EventRegister.removeEventListener(listener as string); }; - }); + }, [isCurrent]); const refresh = useCallback(() => { if (!refreshing && Date.now() - lastFetchTime > ARTICLE_EXPIRE_DURATION) { @@ -99,7 +103,16 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) switch (item.type) { case 'articles_block': { if (item.widget_id === WIDGET_ID_HERO) { - return ; + return ( + { + navigation.push('Podcast', { + articleId: article.id, + }); + }} + /> + ); } break; } @@ -114,7 +127,12 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) imageUrl: buildImageUri(IMG_SIZE_L, a.img_path_prefix, a.img_path_postfix), }))} onItemPress={(index) => { - console.log('item pressed', item.data.articles_list[index].title); + navigation.push('Podcast', { + articleId: item.data.articles_list[index].id, + }); + }} + onItemPlayPress={(index) => { + playArticle(item.data.articles_list[index].id); }} /> ); @@ -139,7 +157,12 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) ), }))} onItemPress={(index) => { - console.log('item pressed', item.data.category_list[index].title); + navigation.push('Podcast', { + articleId: item.data.category_list[index].LATEST_ITEM.id, + }); + }} + onItemPlayPress={(index) => { + playArticle(item.data.category_list[index].LATEST_ITEM.id); }} onKeywordPress={(keyword) => { navigation.navigate('Slug', { @@ -152,7 +175,19 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) } case 'category': { if (item.template_id === 25) { - return ; + return ( + { + navigation.push('Podcast', { + articleId: item.data.articles_list[index].id, + }); + }} + onItemPlayPress={(index) => { + playArticle(item.data.articles_list[index].id); + }} + /> + ); } if (item.template_id === 42 || item.template_id === 43) { return ( @@ -164,7 +199,12 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) imageUrl: buildImageUri(IMG_SIZE_L, a.img_path_prefix, a.img_path_postfix), }))} onItemPress={(index) => { - console.log('item pressed', item.data.articles_list[index].title); + navigation.push('Podcast', { + articleId: item.data.articles_list[index].id, + }); + }} + onItemPlayPress={(index) => { + playArticle(item.data.articles_list[index].id); }} /> ); @@ -180,6 +220,7 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) }, []); const {bottom} = useSafeAreaInsets(); + const extraData = useMemo(() => ({lastFetchTime: lastFetchTime}), [lastFetchTime]); if (data.length === 0) { return ; @@ -196,16 +237,14 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) fetchRadioteka()} />} data={data} removeClippedSubviews={false} estimatedFirstItemOffset={500} - estimatedItemSize={600} + estimatedItemSize={300} keyExtractor={(item, index) => String(index) + String(item)} /> diff --git a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx index e5df3a1..1e9fec5 100644 --- a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx +++ b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx @@ -1,5 +1,5 @@ -import React, {useState, useEffect} from 'react'; -import {View, TouchableOpacity, StyleSheet, Dimensions, ScrollView} from 'react-native'; +import React, {useState, useEffect, useCallback} from 'react'; +import {View, StyleSheet, Dimensions, ScrollView} from 'react-native'; import Animated, {useAnimatedStyle, withSpring, useSharedValue} from 'react-native-reanimated'; import {Text, TouchableDebounce} from '../../../../../../components'; import ThemeProvider from '../../../../../../theme/ThemeProvider'; @@ -9,21 +9,25 @@ import {RadiotekaTopArticlesBlock} from '../../../../../../api/Types'; import FastImage from 'react-native-fast-image'; import {buildImageUri, IMG_SIZE_M, IMG_SIZE_XXL} from '../../../../../../util/ImageUtil'; import LinearGradient from 'react-native-linear-gradient'; +import {useArticlePlayer} from '../../hooks/useArticlePlayer'; +import {Article} from '../../../../../../../Types'; const {height} = Dimensions.get('window'); const width = Math.min(Dimensions.get('window').width * 0.32, 150); interface Props { block: RadiotekaTopArticlesBlock; + onArticlePress: (article: Article) => void; } -const RadiotekaHero: React.FC> = ({block}) => { +const RadiotekaHero: React.FC> = ({block, onArticlePress}) => { const [selectedIndex, setSelectedIndex] = useState(0); const {data} = block; const articles = data.articles_list; const scaleValues = articles?.map(() => useSharedValue(1)); + const {playArticle} = useArticlePlayer(); useEffect(() => { // Reset all scales to 1 @@ -42,9 +46,9 @@ const RadiotekaHero: React.FC> = ({block}) => { }); }; - const handleItemPress = (index: number) => { + const handleItemPress = useCallback((index: number) => { setSelectedIndex(index); - }; + }, []); const imgUrl = buildImageUri( IMG_SIZE_XXL, @@ -88,14 +92,20 @@ const RadiotekaHero: React.FC> = ({block}) => { {articles[selectedIndex].category_title} {articles[selectedIndex].title} - - + { + playArticle(articles[selectedIndex].id); + }}> - {/* Klausytis */} - + { + onArticlePress?.(articles[selectedIndex]); + }}> Daugiau @@ -106,7 +116,7 @@ const RadiotekaHero: React.FC> = ({block}) => { style={styles.bottomScrollView} contentContainerStyle={styles.bottomList}> {articles.map((item, index) => ( - handleItemPress(index)}> + handleItemPress(index)}> > = ({block}) => { style={styles.thumbnail} /> - + ))} @@ -133,7 +143,7 @@ const styles = StyleSheet.create({ flex: 1, height: height - 100, justifyContent: 'space-between', - paddingTop: 80, + paddingTop: 32, paddingBottom: 40, marginBottom: 64, }, @@ -143,6 +153,9 @@ const styles = StyleSheet.create({ headerText: { color: '#FFFFFF', fontSize: 19, + textShadowColor: '#00000088', + textShadowRadius: 8, + textShadowOffset: {width: 2, height: 1}, textTransform: 'uppercase', }, mainContent: { diff --git a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx index b6e2cdf..b8daef4 100644 --- a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx +++ b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx @@ -1,13 +1,5 @@ import React, {useState, useRef} from 'react'; -import { - View, - StyleSheet, - FlatList, - Image, - ViewToken, - useWindowDimensions, - TouchableOpacity, -} from 'react-native'; +import {View, StyleSheet, FlatList, Image, ViewToken, useWindowDimensions} from 'react-native'; import Text from '../../../../../../components/text/Text'; import {IconPlay} from '../../../../../../components/svg'; import FastImage from 'react-native-fast-image'; @@ -17,13 +9,20 @@ import {Article} from '../../../../../../../Types'; import {buildImageUri, IMG_SIZE_L, IMG_SIZE_XL} from '../../../../../../util/ImageUtil'; import {getIconForChannelById} from '../../../../../../util/UI'; import LinearGradient from 'react-native-linear-gradient'; +import ThemeProvider from '../../../../../../theme/ThemeProvider'; +import {themeLight} from '../../../../../../Theme'; interface RadiotekaHeroCarouselProps { items: Article[]; onItemPress?: (index: number) => void; + onItemPlayPress?: (index: number) => void; } -export const RadiotekaHeroCarousel: React.FC = ({items, onItemPress}) => { +export const RadiotekaHeroCarousel: React.FC = ({ + items, + onItemPress, + onItemPlayPress, +}) => { const [activeIndex, setActiveIndex] = useState(0); const flatListRef = useRef(null); const {width} = useWindowDimensions(); @@ -61,9 +60,9 @@ export const RadiotekaHeroCarousel: React.FC = ({ite {item.title} - + onItemPlayPress?.(index)}> - + ); @@ -75,53 +74,55 @@ export const RadiotekaHeroCarousel: React.FC = ({ite ); return ( - - {/* Blurred Background */} - - - - - - {/* Logo */} - { - - {getIconForChannelById(items[activeIndex].channel_id ?? 0, {height: 20})} + + + {/* Blurred Background */} + + - } - {/* Carousel */} - `${index}`} - /> + + {/* Logo */} + { + + {getIconForChannelById(items[activeIndex].channel_id ?? 0, {height: 20})} + + } + + {/* Carousel */} + `${index}`} + /> - {/* Pagination Dots */} - - {items.map((_, index) => ( - flatListRef.current?.scrollToIndex({index})}> - + {items.map((_, index) => ( + - - ))} + onPress={() => flatListRef.current?.scrollToIndex({index})}> + + + ))} + - + ); }; diff --git a/app/screens/main/tabScreen/radioteka/components/hero/mockData.ts b/app/screens/main/tabScreen/radioteka/components/hero/mockData.ts deleted file mode 100644 index 0cab6b7..0000000 --- a/app/screens/main/tabScreen/radioteka/components/hero/mockData.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {RadiotekaItem} from '../horizontal_list/RadiotekaHorizontalList'; - -export const MOCK_CAROUSEL_ITEMS: RadiotekaItem[] = [ - { - id: '1', - category: 'LRT KLASIKA', - title: 'Viskas blogai', - subtitle: 'Naujametiniai pažadai', - imageUrl: 'https://picsum.photos/300/300?random=10', - }, - { - id: '2', - category: 'LRT KLASIKA', - title: 'Muzikinis pastišas', - subtitle: 'Įvairių muzikos stilių rinkinys', - imageUrl: 'https://picsum.photos/300/300?random=11', - }, - { - id: '3', - category: 'LRT KLASIKA', - title: 'Žaidžiam žmogų', - subtitle: 'Psichologijos ir saviugdos laida', - imageUrl: 'https://picsum.photos/300/300?random=12', - }, - { - id: '4', - category: 'LRT KLASIKA', - title: 'Ryto garsai', - subtitle: 'Rytinė muzikos ir pokalbių laida', - imageUrl: 'https://picsum.photos/300/300?random=13', - }, -]; diff --git a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx index 0c390b4..bdb34b4 100644 --- a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx +++ b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalCategoryList.tsx @@ -12,6 +12,7 @@ interface RadiotekaHorizontalCategoryListProps { variation?: 'full' | 'minimal'; items: RadiotekaListItem[]; onItemPress?: (index: number) => void; + onItemPlayPress?: (index: number) => void; onKeywordPress?: (keyword: Keyword) => void; } @@ -20,6 +21,7 @@ const RadiotekaHorizontalCategoryList: React.FC { @@ -43,7 +45,12 @@ const RadiotekaHorizontalCategoryList: React.FC )} - + ); }; diff --git a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx index eab37bb..5a7dc86 100644 --- a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx +++ b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx @@ -17,12 +17,14 @@ export type RadiotekaListItem = { interface RadiotekaHorizontalListProps { items: RadiotekaListItem[]; onItemPress?: (index: number) => void; + onItemPlayPress?: (index: number) => void; variation?: 'full' | 'minimal'; } const RadiotekaHorizontalList: React.FC = ({ items, onItemPress, + onItemPlayPress, variation = 'full', }) => { const renderItem = ({item, index}: {item: RadiotekaListItem; index: number}) => ( @@ -39,7 +41,7 @@ const RadiotekaHorizontalList: React.FC = ({ style={styles.image} /> {variation === 'full' && ( - + onItemPlayPress?.(index)}> )} @@ -114,9 +116,10 @@ const styles = StyleSheet.create({ width: 0, height: 1, }, + backgroundColor: '#888', shadowOpacity: 0.22, shadowRadius: 2.22, - elevation: 3, + elevation: 2, }, contentContainer: { flex: 1, diff --git a/app/screens/main/tabScreen/radioteka/hooks/useArticlePlayer.ts b/app/screens/main/tabScreen/radioteka/hooks/useArticlePlayer.ts new file mode 100644 index 0000000..182f079 --- /dev/null +++ b/app/screens/main/tabScreen/radioteka/hooks/useArticlePlayer.ts @@ -0,0 +1,36 @@ +import {useCallback} from 'react'; +import {useMediaPlayer} from '../../../../../components/videoComponent/context/useMediaPlayer'; +import {MediaType} from '../../../../../components/videoComponent/context/PlayerContext'; +import {isMediaArticle} from '../../../../../api/Types'; +import {fetchArticle} from '../../../../../api'; +import {buildArticleImageUri, IMG_SIZE_M} from '../../../../../util/ImageUtil'; + +export const useArticlePlayer = () => { + const {setMediaData} = useMediaPlayer(); + + const playArticle = useCallback( + async (articleId: number) => { + try { + const response = await fetchArticle(articleId); + const article = response.article; + + if (!isMediaArticle(article)) { + throw new Error('Invalid article type'); + } + + setMediaData({ + uri: article.stream_url, + title: article.title, + poster: buildArticleImageUri(IMG_SIZE_M, article.main_photo.path), + mediaType: article.is_video ? MediaType.VIDEO : MediaType.AUDIO, + isLiveStream: false, + }); + } catch (error) { + console.error('Failed to play article:', error); + } + }, + [setMediaData], + ); + + return {playArticle}; +}; diff --git a/app/screens/podcast/PodcastScreen.tsx b/app/screens/podcast/PodcastScreen.tsx new file mode 100644 index 0000000..388b64a --- /dev/null +++ b/app/screens/podcast/PodcastScreen.tsx @@ -0,0 +1,131 @@ +import React, {useCallback, useEffect} from 'react'; +import {StyleSheet, View} from 'react-native'; + +import {RouteProp} from '@react-navigation/native'; +import {StackNavigationProp} from '@react-navigation/stack'; +import {MainStackParamList} from '../../navigation/MainStack'; +import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; +import {AdultContentWarning, ScreenError, ScreenLoader} from '../../components'; +import useArticleScreenState from '../article/useArticleScreenState'; +import {useTheme} from '../../Theme'; +import PodcastAbout from './about/PodcastAbout'; +import {ArticleContentMedia} from '../../api/Types'; +import {ScrollView} from 'react-native-gesture-handler'; +import FastImage from 'react-native-fast-image'; +import {buildArticleImageUri, IMG_SIZE_XL} from '../../util/ImageUtil'; +import PodcastEpisode from './episode/PodcastEpisode'; +import PodcastRecommendations from './recommendations/PodcastRecommendations'; + +type ScreenRouteProp = RouteProp; +type ScreenNavigationProp = StackNavigationProp; + +type Props = { + route: ScreenRouteProp; + navigation: ScreenNavigationProp; +}; + +const PodcastScreen: React.FC> = ({navigation, route}) => { + const {articleId} = route.params; + + const [{article, loadingState}, acceptAdultContent] = useArticleScreenState(articleId); + const {strings, colors} = useTheme(); + + useEffect(() => { + navigation.setOptions({ + headerTitle: article?.category_title ?? '', + }); + }, [article, navigation]); + + const {bottom} = useSafeAreaInsets(); + + const adultContentAcceptHandler = useCallback(() => { + acceptAdultContent(true); + }, [acceptAdultContent]); + + const adultContentDeclineHandler = useCallback(() => { + acceptAdultContent(false); + }, [acceptAdultContent]); + + switch (loadingState) { + case 'loading': { + return ( + + + + ); + } + case 'error': { + return ( + + + + ); + } + case 'adult-content-warning': { + return ( + + + + + + ); + } + case 'ready': { + return ( + <> + {article && ( + + + + + + + + + + + )} + + ); + } + default: { + return ( + + + + ); + } + } +}; + +export default PodcastScreen; + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + centerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + imageContainer: { + padding: 10, + }, +}); diff --git a/app/screens/podcast/about/PodcastAbout.tsx b/app/screens/podcast/about/PodcastAbout.tsx new file mode 100644 index 0000000..e1299be --- /dev/null +++ b/app/screens/podcast/about/PodcastAbout.tsx @@ -0,0 +1,143 @@ +import React, {PropsWithChildren, useCallback, useState} from 'react'; +import {ArticleContentMedia} from '../../../api/Types'; +import {StyleSheet, View} from 'react-native'; +import {useTheme} from '../../../Theme'; +import TextComponent from '../../../components/text/Text'; +import Divider from '../../../components/divider/Divider'; +import {IconAudioReadCount} from '../../../components/svg'; +import {TouchableDebounce} from '../../../components'; +import FastImage from 'react-native-fast-image'; +import {buildArticleImageUri, IMG_SIZE_M} from '../../../util/ImageUtil'; +import ArticleParagraph from '../../../components/articleParagraphs/paragraph/ArticleParagraph'; +import ArticleKeywords from '../../article/keywords/ArticleKeywords'; + +interface Props { + article: ArticleContentMedia; +} + +const getMediaDurationMinutes = (mediaDuration?: string) => { + if (!mediaDuration) { + return '0'; + } + const splits = mediaDuration.split(':'); + if (splits.length > 1) { + return splits[1]; + } + return '0'; +}; + +type ContentType = 'episode' | 'show'; + +const PodcastAbout: React.FC> = ({article}) => { + const [selectedContent, setSelectedContent] = useState('episode'); + + const {strings, colors} = useTheme(); + + const onEpisodePressHandler = useCallback(() => { + setSelectedContent('episode'); + }, []); + + const onShowPressHandler = useCallback(() => { + setSelectedContent('show'); + }, []); + + const Tab = useCallback( + ({label, selected}: {label: string; selected: boolean}) => ( + + {label} + + ), + [], + ); + + const EpisodeInfo = useCallback(() => { + return ( + <> + + + + {article.read_count} + + + + {getMediaDurationMinutes(article.media_duration)} min. + + + + + ); + }, [article]); + + const ShowInfo = useCallback(() => { + return ( + <> + + + + + + + + ); + }, [article]); + + return ( + + + + + + + + + + + {selectedContent === 'episode' ? : } + + + + + ); +}; + +export default PodcastAbout; + +const styles = StyleSheet.create({ + root: { + paddingHorizontal: 12, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + }, + tab: { + padding: 10, + borderRadius: 4, + }, + tabLabel: { + fontSize: 13, + }, + caption: { + fontSize: 12, + }, + podcastDetailsImage: { + width: 100, + height: 100, + marginTop: 12, + borderRadius: 4, + borderColor: '#FFF', + borderWidth: 2, + }, +}); diff --git a/app/screens/podcast/episode/PodcastEpisode.tsx b/app/screens/podcast/episode/PodcastEpisode.tsx new file mode 100644 index 0000000..b174f23 --- /dev/null +++ b/app/screens/podcast/episode/PodcastEpisode.tsx @@ -0,0 +1,75 @@ +import {PropsWithChildren, useCallback} from 'react'; +import {ArticleContentMedia} from '../../../api/Types'; +import {StyleSheet, View} from 'react-native'; +import {Text, TouchableDebounce} from '../../../components'; +import {IconPlay} from '../../../components/svg'; +import {useMediaPlayer} from '../../../components/videoComponent/context/useMediaPlayer'; +import {buildArticleImageUri, IMG_SIZE_M} from '../../../util/ImageUtil'; +import {MediaType} from '../../../components/videoComponent/context/PlayerContext'; +import {useTheme} from '../../../Theme'; + +interface Props { + article: ArticleContentMedia; +} + +const PodcastEpisode: React.FC> = ({article}) => { + const {setMediaData} = useMediaPlayer(); + + const {colors} = useTheme(); + + const play = useCallback(() => { + setMediaData({ + uri: article.stream_url, + title: article.title, + poster: buildArticleImageUri(IMG_SIZE_M, article.main_photo.path), + mediaType: article.is_video ? MediaType.VIDEO : MediaType.AUDIO, + isLiveStream: false, + }); + }, [setMediaData, article]); + + return ( + + + + + + + {article.category_title} + {article.date} + + {article.title} + + + ); +}; + +export default PodcastEpisode; + +const styles = StyleSheet.create({ + root: { + flex: 1, + alignItems: 'center', + borderRadius: 16, + padding: 16, + gap: 16, + margin: 12, + flexDirection: 'row', + overflow: 'hidden', + }, + playButton: { + backgroundColor: '#FFD600', + flexDirection: 'row', + width: 40, + gap: 12, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + aspectRatio: 1, + }, + title: { + fontSize: 15, + }, + caption: { + fontSize: 12, + }, +}); diff --git a/app/screens/podcast/recommendations/PodcastRecommendations.tsx b/app/screens/podcast/recommendations/PodcastRecommendations.tsx new file mode 100644 index 0000000..ff14775 --- /dev/null +++ b/app/screens/podcast/recommendations/PodcastRecommendations.tsx @@ -0,0 +1,66 @@ +import {StyleSheet, View} from 'react-native'; +import {Text} from '../../../components'; +import useRecomendations from './useRecommendations'; +import RadiotekaHorizontalList from '../../main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList'; +import {useArticlePlayer} from '../../main/tabScreen/radioteka/hooks/useArticlePlayer'; +import {buildArticleImageUri, IMG_SIZE_M} from '../../../util/ImageUtil'; +import {useNavigation} from '@react-navigation/native'; +import {StackNavigationProp} from '@react-navigation/stack'; +import {MainStackParamList} from '../../../navigation/MainStack'; +import {useMemo} from 'react'; + +interface Props { + articleId: number; +} + +const PodcastRecommendations: React.FC> = ({articleId}) => { + const recommendations = useRecomendations(articleId); + + const navigation = useNavigation>(); + + const {playArticle} = useArticlePlayer(); + + const items = useMemo(() => { + return recommendations.items.map((item) => ({ + title: item.title, + category: item.category_title, + imageUrl: buildArticleImageUri(IMG_SIZE_M, item.photo)!, + })); + }, [recommendations.items]); + + if (items.length === 0) { + return null; + } + + return ( + + + Kiti taip pat klausė + + { + playArticle(recommendations.items[index].id); + }} + onItemPress={(index) => { + navigation.navigate('Podcast', {articleId: recommendations.items[index].id}); + }} + /> + + ); +}; + +export default PodcastRecommendations; + +const styles = StyleSheet.create({ + root: { + paddingTop: 24, + paddingBottom: 100, + gap: 12, + }, + title: { + fontSize: 20, + paddingHorizontal: 12, + textTransform: 'uppercase', + }, +}); diff --git a/app/screens/podcast/recommendations/useRecommendations.ts b/app/screens/podcast/recommendations/useRecommendations.ts new file mode 100644 index 0000000..92bcdb9 --- /dev/null +++ b/app/screens/podcast/recommendations/useRecommendations.ts @@ -0,0 +1,27 @@ +import {useEffect, useState} from 'react'; +import {fetchArticleRecommendations, fetchArticlesByIds} from '../../../api'; +import {ArticleSearchItem} from '../../../api/Types'; + +const useRecomendations = (articleId: number) => { + const [items, setItems] = useState([]); + + useEffect(() => { + if (items.length > 0) { + return; + } + + fetchArticleRecommendations(articleId).then((response) => { + if (response.result?.items && response.result.items.length > 0) { + fetchArticlesByIds(response.result.items.map((item) => item.id)).then((response) => { + setItems(response.items); + }); + } + }); + }, []); + + return { + items, + }; +}; + +export default useRecomendations; diff --git a/package.json b/package.json index e327a62..158c2be 100644 --- a/package.json +++ b/package.json @@ -101,5 +101,8 @@ "node" ] }, - "packageManager": "yarn@3.6.4" + "packageManager": "yarn@3.6.4", + "resolutions": { + "react-native-autoheight-webview@^1.6.5": "patch:react-native-autoheight-webview@npm%3A1.6.5#./.yarn/patches/react-native-autoheight-webview-npm-1.6.5-c3f5e8ed07.patch" + } } From 913e90a6a41391eec02183ee0a3a38ef3d08f361 Mon Sep 17 00:00:00 2001 From: Kestas Venslauskas Date: Sat, 8 Feb 2025 14:14:16 +0200 Subject: [PATCH 5/7] feat: add episode selection to podcast screen --- app/api/Types.ts | 10 +- app/api/index.ts | 2 +- .../miniPlayerAudio/MiniPlayerAudio.tsx | 8 +- .../article/audioContent/AudioContent.tsx | 2 - .../article/liveFeed/ArticleLiveFeed.tsx | 5 +- app/screens/bookmarks/BookmarksScreen.tsx | 8 +- app/screens/history/HistoryScreen.tsx | 8 +- .../components/category/CategoryBlock.tsx | 2 +- .../components/newest/NewestBlockCategory.tsx | 2 +- .../components/popular/PopularBlock.tsx | 2 +- .../components/topArticle/TopArticle.tsx | 2 +- .../tabScreen/category/CategoryHomeScreen.tsx | 8 +- .../main/tabScreen/home/HomeScreen.tsx | 8 +- .../ArticlesListByDateBlock.tsx | 6 +- .../CategoryArticlesBlock.tsx | 6 +- .../FeedArticlesBlock/FeedArticlesBlock.tsx | 6 +- .../SlugArticlesBlock/SlugArticlesBlock.tsx | 6 +- .../TopArticlesBlock/TopArticlesBlock.tsx | 6 +- .../home/blocks/TopFeedBlock/TopFeedBlock.tsx | 6 +- .../components/hero/RadiotekaHero.tsx | 22 +--- .../components/hero/RadiotekaHeroCarousel.tsx | 18 +-- .../RadiotekaHorizontalList.tsx | 19 +-- .../components/play_button/play_button.tsx | 32 +++++ .../simple/SimpleArticleScreenContent.tsx | 8 +- app/screens/podcast/PodcastScreen.tsx | 11 +- app/screens/podcast/about/PodcastAbout.tsx | 4 +- .../podcast/episode/PodcastEpisode.tsx | 18 +-- .../PodcastEpisodeSelection.tsx | 64 ++++++++++ .../episodeSelection/PodcastEpisodesModal.tsx | 116 ++++++++++++++++++ .../podcast/episodeSelection/useEpisodes.ts | 25 ++++ .../recommendations/useRecommendations.ts | 2 +- app/screens/search/SearchScreen.tsx | 8 +- app/screens/slug/SlugScreen.tsx | 8 +- app/state/article_storage_store.ts | 3 + 34 files changed, 352 insertions(+), 109 deletions(-) create mode 100644 app/screens/main/tabScreen/radioteka/components/play_button/play_button.tsx create mode 100644 app/screens/podcast/episodeSelection/PodcastEpisodeSelection.tsx create mode 100644 app/screens/podcast/episodeSelection/PodcastEpisodesModal.tsx create mode 100644 app/screens/podcast/episodeSelection/useEpisodes.ts diff --git a/app/api/Types.ts b/app/api/Types.ts index 4efc0fe..cd1bd94 100644 --- a/app/api/Types.ts +++ b/app/api/Types.ts @@ -696,20 +696,12 @@ export type CarPlayPodcastItem = { }; export type CarPlayCategoryResponse = { - articles: CarPlayCategoryItem[]; + articles: FeedArticle[]; category_info: any; page: number; next_page: number | null; }; -export type CarPlayCategoryItem = { - id: number; - title: string; - img_path_postfix: string; - img_path_prefix: string; - item_date: string; -}; - export type ArticleContentDefault = { article_id: number; article_title: string; diff --git a/app/api/index.ts b/app/api/index.ts index ad08264..6380225 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -129,7 +129,7 @@ export const fetchCarRecommendedPlaylist = () => get(carPlayl export const fetchCarPodcasts = (count: number) => get(carPlaylistPodcastsGet(count)); -export const fetchCarCategoryPlaylist = (id: string | number) => +export const fetchCategoryPlaylist = (id: string | number) => get(carPlaylistCategoryGet(id)); export const fetchCarLivePlaylist = () => get(carPlaylistLiveGet()); diff --git a/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx b/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx index 7b405a3..3bd93e6 100644 --- a/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx +++ b/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx @@ -115,9 +115,7 @@ const MiniPlayerAudio: React.FC> = (props) => { alignItems: 'center', justifyContent: 'space-between', }}> - handleSeekBy(-10)} hitSlop={12}> - - + handleSeekBy(-10)} hitSlop={12}> @@ -131,9 +129,7 @@ const MiniPlayerAudio: React.FC> = (props) => { handleSeekBy(10)} hitSlop={12}> - handleSeekBy(10)} hitSlop={12}> - - + {player ? : null} diff --git a/app/screens/article/audioContent/AudioContent.tsx b/app/screens/article/audioContent/AudioContent.tsx index 6310d1b..9aae965 100644 --- a/app/screens/article/audioContent/AudioContent.tsx +++ b/app/screens/article/audioContent/AudioContent.tsx @@ -31,8 +31,6 @@ const AudioContent: React.FC = ({about_episode, about_show, i setSelectedContent('show'); }, []); - console.log('selected: ', selectedContent); - return ( diff --git a/app/screens/article/liveFeed/ArticleLiveFeed.tsx b/app/screens/article/liveFeed/ArticleLiveFeed.tsx index 068e198..284ce3b 100644 --- a/app/screens/article/liveFeed/ArticleLiveFeed.tsx +++ b/app/screens/article/liveFeed/ArticleLiveFeed.tsx @@ -83,7 +83,10 @@ const ArticleLiveFeed: React.FC> = ({id}) => { {item.articles?.map((article) => ( - navigation.push('Article', {articleId: article.id})}> + { + navigation.push('Article', {articleId: article.id}); + }}> ))} diff --git a/app/screens/bookmarks/BookmarksScreen.tsx b/app/screens/bookmarks/BookmarksScreen.tsx index 7d01269..cfe76ca 100644 --- a/app/screens/bookmarks/BookmarksScreen.tsx +++ b/app/screens/bookmarks/BookmarksScreen.tsx @@ -36,7 +36,13 @@ const BookmarksScreen: React.FC> = ({navigation}) return ( navigation.push('Article', {articleId: article.id})} + onArticlePress={(article) => { + if (article.is_audio) { + navigation.push('Podcast', {articleId: article.id}); + } else { + navigation.push('Article', {articleId: article.id}); + } + }} /> ); }; diff --git a/app/screens/history/HistoryScreen.tsx b/app/screens/history/HistoryScreen.tsx index 5bb28c2..5bcd1b7 100644 --- a/app/screens/history/HistoryScreen.tsx +++ b/app/screens/history/HistoryScreen.tsx @@ -42,7 +42,13 @@ const HistoryScreen: React.FC> = ({navigation}) = return ( navigation.push('Article', {articleId: article.id})} + onArticlePress={(article) => { + if (article.is_audio) { + navigation.push('Podcast', {articleId: article.id}); + } else { + navigation.push('Article', {articleId: article.id}); + } + }} /> ); }; diff --git a/app/screens/main/tabScreen/audioteka/components/category/CategoryBlock.tsx b/app/screens/main/tabScreen/audioteka/components/category/CategoryBlock.tsx index e6e3deb..3c35da3 100644 --- a/app/screens/main/tabScreen/audioteka/components/category/CategoryBlock.tsx +++ b/app/screens/main/tabScreen/audioteka/components/category/CategoryBlock.tsx @@ -24,7 +24,7 @@ const CategoryBlock: React.FC> = ({data}) => { const onArticlePressHandler = useCallback( (article: Article) => { - navigation.navigate('Article', {articleId: article.id}); + navigation.navigate('Podcast', {articleId: article.id}); }, [navigation], ); diff --git a/app/screens/main/tabScreen/audioteka/components/newest/NewestBlockCategory.tsx b/app/screens/main/tabScreen/audioteka/components/newest/NewestBlockCategory.tsx index 69e2c90..e1169de 100644 --- a/app/screens/main/tabScreen/audioteka/components/newest/NewestBlockCategory.tsx +++ b/app/screens/main/tabScreen/audioteka/components/newest/NewestBlockCategory.tsx @@ -17,7 +17,7 @@ const NewestBlockCategory: React.FC> = ({data}) = const onArticlePressHandler = useCallback( (article: Article) => { - navigation.navigate('Article', {articleId: article.id}); + navigation.navigate('Podcast', {articleId: article.id}); }, [navigation], ); diff --git a/app/screens/main/tabScreen/audioteka/components/popular/PopularBlock.tsx b/app/screens/main/tabScreen/audioteka/components/popular/PopularBlock.tsx index f2a7eb9..16e6784 100644 --- a/app/screens/main/tabScreen/audioteka/components/popular/PopularBlock.tsx +++ b/app/screens/main/tabScreen/audioteka/components/popular/PopularBlock.tsx @@ -20,7 +20,7 @@ const PopularBlock: React.FC> = ({data}) => { { - navigation.navigate('Article', {articleId: article.id}); + navigation.navigate('Podcast', {articleId: article.id}); }} /> diff --git a/app/screens/main/tabScreen/audioteka/components/topArticle/TopArticle.tsx b/app/screens/main/tabScreen/audioteka/components/topArticle/TopArticle.tsx index d8366fd..5dd02d2 100644 --- a/app/screens/main/tabScreen/audioteka/components/topArticle/TopArticle.tsx +++ b/app/screens/main/tabScreen/audioteka/components/topArticle/TopArticle.tsx @@ -21,7 +21,7 @@ const TopArticle: React.FC = ({article}) => { const navigation = useNavigation>(); const onPressHandler = useCallback(() => { - navigation.navigate('Article', {articleId: article.id}); + navigation.navigate('Podcast', {articleId: article.id}); }, [article.id, navigation]); return ( diff --git a/app/screens/main/tabScreen/category/CategoryHomeScreen.tsx b/app/screens/main/tabScreen/category/CategoryHomeScreen.tsx index a2e931d..c948607 100644 --- a/app/screens/main/tabScreen/category/CategoryHomeScreen.tsx +++ b/app/screens/main/tabScreen/category/CategoryHomeScreen.tsx @@ -129,7 +129,13 @@ const CategoryHomeScreen: React.FC> = ({isCurrent return ( navigation.navigate('Article', {articleId: article.id})} + onArticlePress={(article) => { + if (article.is_audio) { + navigation.navigate('Podcast', {articleId: article.id}); + } else { + navigation.navigate('Article', {articleId: article.id}); + } + }} /> ); } diff --git a/app/screens/main/tabScreen/home/HomeScreen.tsx b/app/screens/main/tabScreen/home/HomeScreen.tsx index 9a2f3c6..602d72b 100644 --- a/app/screens/main/tabScreen/home/HomeScreen.tsx +++ b/app/screens/main/tabScreen/home/HomeScreen.tsx @@ -131,7 +131,13 @@ const HomeScreen: React.FC> = ({isCurrent, type}) return ( navigation.navigate('Article', {articleId: article.id})} + onArticlePress={(article) => { + if (article.is_audio) { + navigation.navigate('Podcast', {articleId: article.id}); + } else { + navigation.navigate('Article', {articleId: article.id}); + } + }} /> ); } diff --git a/app/screens/main/tabScreen/home/blocks/ArticlesListByDateBlock/ArticlesListByDateBlock.tsx b/app/screens/main/tabScreen/home/blocks/ArticlesListByDateBlock/ArticlesListByDateBlock.tsx index e0d4edd..70b4201 100644 --- a/app/screens/main/tabScreen/home/blocks/ArticlesListByDateBlock/ArticlesListByDateBlock.tsx +++ b/app/screens/main/tabScreen/home/blocks/ArticlesListByDateBlock/ArticlesListByDateBlock.tsx @@ -32,7 +32,11 @@ const ArticlesListByDateBlock: React.FC = ({ const articlePressHandler = useCallback( (article: Article) => { - navigation.navigate('Article', {articleId: article.id}); + if (article.is_audio) { + navigation.navigate('Podcast', {articleId: article.id}); + } else { + navigation.navigate('Article', {articleId: article.id}); + } }, [navigation], ); diff --git a/app/screens/main/tabScreen/home/blocks/CategoryArticlesBlock/CategoryArticlesBlock.tsx b/app/screens/main/tabScreen/home/blocks/CategoryArticlesBlock/CategoryArticlesBlock.tsx index 7bbfd9c..8d6156d 100644 --- a/app/screens/main/tabScreen/home/blocks/CategoryArticlesBlock/CategoryArticlesBlock.tsx +++ b/app/screens/main/tabScreen/home/blocks/CategoryArticlesBlock/CategoryArticlesBlock.tsx @@ -26,7 +26,11 @@ const CategoryArticlesBlock: React.FC = ({block}) => const articlePressHandler = useCallback( (article: Article) => { - navigation.navigate('Article', {articleId: article.id}); + if (article.is_audio) { + navigation.navigate('Podcast', {articleId: article.id}); + } else { + navigation.navigate('Article', {articleId: article.id}); + } }, [navigation], ); diff --git a/app/screens/main/tabScreen/home/blocks/FeedArticlesBlock/FeedArticlesBlock.tsx b/app/screens/main/tabScreen/home/blocks/FeedArticlesBlock/FeedArticlesBlock.tsx index 1251ac9..7ee5338 100644 --- a/app/screens/main/tabScreen/home/blocks/FeedArticlesBlock/FeedArticlesBlock.tsx +++ b/app/screens/main/tabScreen/home/blocks/FeedArticlesBlock/FeedArticlesBlock.tsx @@ -21,7 +21,11 @@ const FeedArticlesBlock: React.FC = ({block}) => { const articlePressHandler = useCallback( (article: Article) => { - navigation.navigate('Article', {articleId: article.id}); + if (article.is_audio) { + navigation.navigate('Podcast', {articleId: article.id}); + } else { + navigation.navigate('Article', {articleId: article.id}); + } }, [navigation], ); diff --git a/app/screens/main/tabScreen/home/blocks/SlugArticlesBlock/SlugArticlesBlock.tsx b/app/screens/main/tabScreen/home/blocks/SlugArticlesBlock/SlugArticlesBlock.tsx index d80f15b..8cc8896 100644 --- a/app/screens/main/tabScreen/home/blocks/SlugArticlesBlock/SlugArticlesBlock.tsx +++ b/app/screens/main/tabScreen/home/blocks/SlugArticlesBlock/SlugArticlesBlock.tsx @@ -33,7 +33,11 @@ const SlugArticlesBlock: React.FC = ({block}) => { const articlePressHandler = useCallback( (article: Article) => { - navigation.navigate('Article', {articleId: article.id}); + if (article.is_audio) { + navigation.navigate('Podcast', {articleId: article.id}); + } else { + navigation.navigate('Article', {articleId: article.id}); + } }, [navigation], ); diff --git a/app/screens/main/tabScreen/home/blocks/TopArticlesBlock/TopArticlesBlock.tsx b/app/screens/main/tabScreen/home/blocks/TopArticlesBlock/TopArticlesBlock.tsx index ced845c..444fc93 100644 --- a/app/screens/main/tabScreen/home/blocks/TopArticlesBlock/TopArticlesBlock.tsx +++ b/app/screens/main/tabScreen/home/blocks/TopArticlesBlock/TopArticlesBlock.tsx @@ -19,7 +19,11 @@ const TopArticlesBlock: React.FC = ({block}) => { const articlePressHandler = useCallback( (article: Article) => { - navigation.navigate('Article', {articleId: article.id}); + if (article.is_audio) { + navigation.navigate('Podcast', {articleId: article.id}); + } else { + navigation.navigate('Article', {articleId: article.id}); + } }, [navigation], ); diff --git a/app/screens/main/tabScreen/home/blocks/TopFeedBlock/TopFeedBlock.tsx b/app/screens/main/tabScreen/home/blocks/TopFeedBlock/TopFeedBlock.tsx index 9f6a03e..f1b071c 100644 --- a/app/screens/main/tabScreen/home/blocks/TopFeedBlock/TopFeedBlock.tsx +++ b/app/screens/main/tabScreen/home/blocks/TopFeedBlock/TopFeedBlock.tsx @@ -22,7 +22,11 @@ const TopFeedBlock: React.FC = ({block}) => { const articlePressHandler = useCallback( (article: Article) => { - navigation.navigate('Article', {articleId: article.id}); + if (article.is_audio) { + navigation.navigate('Podcast', {articleId: article.id}); + } else { + navigation.navigate('Article', {articleId: article.id}); + } }, [navigation], ); diff --git a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx index 1e9fec5..9ff9b80 100644 --- a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx +++ b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHero.tsx @@ -4,13 +4,13 @@ import Animated, {useAnimatedStyle, withSpring, useSharedValue} from 'react-nati import {Text, TouchableDebounce} from '../../../../../../components'; import ThemeProvider from '../../../../../../theme/ThemeProvider'; import {themeLight} from '../../../../../../Theme'; -import {IconPlay} from '../../../../../../components/svg'; import {RadiotekaTopArticlesBlock} from '../../../../../../api/Types'; import FastImage from 'react-native-fast-image'; import {buildImageUri, IMG_SIZE_M, IMG_SIZE_XXL} from '../../../../../../util/ImageUtil'; import LinearGradient from 'react-native-linear-gradient'; import {useArticlePlayer} from '../../hooks/useArticlePlayer'; import {Article} from '../../../../../../../Types'; +import PlayButton from '../play_button/play_button'; const {height} = Dimensions.get('window'); const width = Math.min(Dimensions.get('window').width * 0.32, 150); @@ -93,14 +93,7 @@ const RadiotekaHero: React.FC> = ({block, onArtic {articles[selectedIndex].title} - { - playArticle(articles[selectedIndex].id); - }}> - - - + playArticle(articles[selectedIndex].id)} /> { @@ -189,16 +182,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', gap: 10, }, - playButton: { - backgroundColor: '#FFD600', - flexDirection: 'row', - gap: 12, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - borderRadius: 8, - aspectRatio: 1, - }, + moreButton: { backgroundColor: '#FFFFFF', alignItems: 'center', diff --git a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx index b8daef4..a3c0d45 100644 --- a/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx +++ b/app/screens/main/tabScreen/radioteka/components/hero/RadiotekaHeroCarousel.tsx @@ -1,7 +1,6 @@ import React, {useState, useRef} from 'react'; import {View, StyleSheet, FlatList, Image, ViewToken, useWindowDimensions} from 'react-native'; import Text from '../../../../../../components/text/Text'; -import {IconPlay} from '../../../../../../components/svg'; import FastImage from 'react-native-fast-image'; import ListenCount from '../../../../../../components/article/article/ListenCount'; import {TouchableDebounce} from '../../../../../../components'; @@ -11,6 +10,7 @@ import {getIconForChannelById} from '../../../../../../util/UI'; import LinearGradient from 'react-native-linear-gradient'; import ThemeProvider from '../../../../../../theme/ThemeProvider'; import {themeLight} from '../../../../../../Theme'; +import PlayButton from '../play_button/play_button'; interface RadiotekaHeroCarouselProps { items: Article[]; @@ -60,9 +60,7 @@ export const RadiotekaHeroCarousel: React.FC = ({ {item.title} - onItemPlayPress?.(index)}> - - + onItemPlayPress?.(index)} /> ); @@ -209,18 +207,6 @@ const styles = StyleSheet.create({ fontSize: 16, color: '#FFFFFF', }, - playButton: { - flexDirection: 'row', - backgroundColor: '#FFD600', - paddingVertical: 12, - borderRadius: 6, - alignSelf: 'flex-start', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - aspectRatio: 1, - }, - pagination: { flexDirection: 'row', justifyContent: 'space-evenly', diff --git a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx index 5a7dc86..75db76d 100644 --- a/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx +++ b/app/screens/main/tabScreen/radioteka/components/horizontal_list/RadiotekaHorizontalList.tsx @@ -1,9 +1,9 @@ import React, {useEffect, useRef} from 'react'; import {View, StyleSheet, FlatList, Dimensions} from 'react-native'; import Text from '../../../../../../components/text/Text'; -import {IconPlay} from '../../../../../../components/svg'; import {TouchableDebounce} from '../../../../../../components'; import FastImage from 'react-native-fast-image'; +import PlayButton from '../play_button/play_button'; const CARD_WIDTH_FULL = Math.min(Dimensions.get('window').width * 0.5, 300); const CARD_WIDTH_MINIMAL = Math.min(Dimensions.get('window').width * 0.33, 150); @@ -40,11 +40,7 @@ const RadiotekaHorizontalList: React.FC = ({ }} style={styles.image} /> - {variation === 'full' && ( - onItemPlayPress?.(index)}> - - - )} + {variation === 'full' && onItemPlayPress?.(index)} />} {variation === 'full' && ( @@ -133,17 +129,6 @@ const styles = StyleSheet.create({ fontSize: 19, marginBottom: 6, }, - - playButton: { - flexDirection: 'row', - backgroundColor: '#FFD600', - paddingVertical: 12, - paddingHorizontal: 12, - borderRadius: 6, - alignSelf: 'flex-start', - alignItems: 'center', - gap: 8, - }, minimalCard: { width: CARD_WIDTH_MINIMAL, aspectRatio: 1, diff --git a/app/screens/main/tabScreen/radioteka/components/play_button/play_button.tsx b/app/screens/main/tabScreen/radioteka/components/play_button/play_button.tsx new file mode 100644 index 0000000..477711a --- /dev/null +++ b/app/screens/main/tabScreen/radioteka/components/play_button/play_button.tsx @@ -0,0 +1,32 @@ +import {PropsWithChildren} from 'react'; +import {StyleSheet, ViewStyle} from 'react-native'; +import {TouchableDebounce} from '../../../../../../components'; +import {IconPlay} from '../../../../../../components/svg'; + +interface Props { + style?: ViewStyle; + onPress: () => void; +} + +const PlayButton: React.FC> = ({style, onPress}) => { + return ( + + + + ); +}; + +export default PlayButton; + +const styles = StyleSheet.create({ + playButton: { + backgroundColor: '#FFD600', + flexDirection: 'row', + width: 40, + paddingLeft: 3, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + aspectRatio: 1, + }, +}); diff --git a/app/screens/main/tabScreen/simple/SimpleArticleScreenContent.tsx b/app/screens/main/tabScreen/simple/SimpleArticleScreenContent.tsx index 2eaa0cc..e2666ba 100644 --- a/app/screens/main/tabScreen/simple/SimpleArticleScreenContent.tsx +++ b/app/screens/main/tabScreen/simple/SimpleArticleScreenContent.tsx @@ -51,7 +51,13 @@ const SimpleArticleScreenContent: React.FC> = ({ }, [isRefreshing]); const openArticleHandler = useCallback( - (article: Article) => navigation.push('Article', {articleId: article.id}), + (article: Article) => { + if (article.is_audio) { + navigation.push('Podcast', {articleId: article.id}); + } else { + navigation.push('Article', {articleId: article.id}); + } + }, [navigation], ); diff --git a/app/screens/podcast/PodcastScreen.tsx b/app/screens/podcast/PodcastScreen.tsx index 388b64a..593178d 100644 --- a/app/screens/podcast/PodcastScreen.tsx +++ b/app/screens/podcast/PodcastScreen.tsx @@ -12,9 +12,11 @@ import PodcastAbout from './about/PodcastAbout'; import {ArticleContentMedia} from '../../api/Types'; import {ScrollView} from 'react-native-gesture-handler'; import FastImage from 'react-native-fast-image'; -import {buildArticleImageUri, IMG_SIZE_XL} from '../../util/ImageUtil'; +import {buildArticleImageUri, IMG_SIZE_XL, IMG_SIZE_XXL} from '../../util/ImageUtil'; import PodcastEpisode from './episode/PodcastEpisode'; import PodcastRecommendations from './recommendations/PodcastRecommendations'; +import PodcastEpisodeSelection from './episodeSelection/PodcastEpisodeSelection'; +import useArticleAnalytics from '../article/useArticleAnalytics'; type ScreenRouteProp = RouteProp; type ScreenNavigationProp = StackNavigationProp; @@ -30,6 +32,8 @@ const PodcastScreen: React.FC> = ({navigation, ro const [{article, loadingState}, acceptAdultContent] = useArticleScreenState(articleId); const {strings, colors} = useTheme(); + useArticleAnalytics({article}); + useEffect(() => { navigation.setOptions({ headerTitle: article?.category_title ?? '', @@ -80,7 +84,8 @@ const PodcastScreen: React.FC> = ({navigation, ro - + + > = ({navigation, ro borderColor: '#fff', }} source={{ - uri: buildArticleImageUri(IMG_SIZE_XL, article.main_photo?.path), + uri: buildArticleImageUri(IMG_SIZE_XXL, article.main_photo?.path), }} /> diff --git a/app/screens/podcast/about/PodcastAbout.tsx b/app/screens/podcast/about/PodcastAbout.tsx index e1299be..afc8bd5 100644 --- a/app/screens/podcast/about/PodcastAbout.tsx +++ b/app/screens/podcast/about/PodcastAbout.tsx @@ -70,7 +70,9 @@ const PodcastAbout: React.FC> = ({article}) => { {getMediaDurationMinutes(article.media_duration)} min. - + + + ); }, [article]); diff --git a/app/screens/podcast/episode/PodcastEpisode.tsx b/app/screens/podcast/episode/PodcastEpisode.tsx index b174f23..92a1e37 100644 --- a/app/screens/podcast/episode/PodcastEpisode.tsx +++ b/app/screens/podcast/episode/PodcastEpisode.tsx @@ -1,12 +1,12 @@ import {PropsWithChildren, useCallback} from 'react'; import {ArticleContentMedia} from '../../../api/Types'; import {StyleSheet, View} from 'react-native'; -import {Text, TouchableDebounce} from '../../../components'; -import {IconPlay} from '../../../components/svg'; +import {Text} from '../../../components'; import {useMediaPlayer} from '../../../components/videoComponent/context/useMediaPlayer'; import {buildArticleImageUri, IMG_SIZE_M} from '../../../util/ImageUtil'; import {MediaType} from '../../../components/videoComponent/context/PlayerContext'; import {useTheme} from '../../../Theme'; +import PlayButton from '../../main/tabScreen/radioteka/components/play_button/play_button'; interface Props { article: ArticleContentMedia; @@ -29,9 +29,7 @@ const PodcastEpisode: React.FC> = ({article}) => { return ( - - - + {article.category_title} @@ -56,16 +54,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', overflow: 'hidden', }, - playButton: { - backgroundColor: '#FFD600', - flexDirection: 'row', - width: 40, - gap: 12, - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - aspectRatio: 1, - }, title: { fontSize: 15, }, diff --git a/app/screens/podcast/episodeSelection/PodcastEpisodeSelection.tsx b/app/screens/podcast/episodeSelection/PodcastEpisodeSelection.tsx new file mode 100644 index 0000000..8fa27f3 --- /dev/null +++ b/app/screens/podcast/episodeSelection/PodcastEpisodeSelection.tsx @@ -0,0 +1,64 @@ +import {PropsWithChildren, useState} from 'react'; +import useEpisodes from './useEpisodes'; +import {StyleSheet, View} from 'react-native'; +import {IconCarretDown} from '../../../components/svg'; +import {useTheme} from '../../../Theme'; +import {Text, TouchableDebounce} from '../../../components'; +import PodcastEpisodesModal from './PodcastEpisodesModal'; + +interface Props { + category_id?: number; +} + +const PodcastEpisodeSelection: React.FC> = ({category_id}) => { + const [modalVisible, setModalVisible] = useState(false); + + const {items} = useEpisodes(category_id); + + const {colors} = useTheme(); + + if (!category_id) { + return null; + } + + return ( + { + setModalVisible(!modalVisible); + }}> + + + Epizodas + + Pasirinkite + + {modalVisible ? ( + + + + ) : ( + + )} + setModalVisible(false)} /> + + ); +}; + +export default PodcastEpisodeSelection; + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + marginHorizontal: 12, + marginTop: 12, + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 6, + justifyContent: 'space-between', + alignItems: 'center', + }, + caption: { + fontSize: 13, + }, +}); diff --git a/app/screens/podcast/episodeSelection/PodcastEpisodesModal.tsx b/app/screens/podcast/episodeSelection/PodcastEpisodesModal.tsx new file mode 100644 index 0000000..4fa9e31 --- /dev/null +++ b/app/screens/podcast/episodeSelection/PodcastEpisodesModal.tsx @@ -0,0 +1,116 @@ +import {PropsWithChildren, useCallback} from 'react'; +import {StyleSheet, View} from 'react-native'; +import PlayButton from '../../main/tabScreen/radioteka/components/play_button/play_button'; +import {useArticlePlayer} from '../../main/tabScreen/radioteka/hooks/useArticlePlayer'; +import {MoreArticlesButton, Text, TouchableDebounce} from '../../../components'; +import Modal from 'react-native-modal'; +import {useTheme} from '../../../Theme'; +import {useMediaPlayer} from '../../../components/videoComponent/context/useMediaPlayer'; +import {Article} from '../../../../Types'; +import {FlashList, ListRenderItemInfo} from '@shopify/flash-list'; +import {useNavigation} from '@react-navigation/native'; +import {StackNavigationProp} from '@react-navigation/stack'; +import {MainStackParamList} from '../../../navigation/MainStack'; + +interface Props { + episodes: Article[]; + visible: boolean; + onClose: () => void; +} + +const PodcastEpisodesModal: React.FC> = ({episodes, visible, onClose}) => { + const {mediaData} = useMediaPlayer(); + const {playArticle} = useArticlePlayer(); + + const navigation = useNavigation>(); + + const {colors, strings} = useTheme(); + + const renderItem = useCallback( + ({item}: ListRenderItemInfo
) => { + return ( + + playArticle(item.id)} + /> + { + onClose(); + setTimeout(() => { + navigation.setParams({articleId: item.id}); + }, 200); + }}> + + + {item.title} + + {item.category_title} + {item.item_date && ( + + {item.item_date} + + )} + {item.media_duration} + + + + ); + }, + [mediaData], + ); + + return ( + + + item.id.toString()} + estimatedItemSize={200} + /> + + + + + + ); +}; + +export default PodcastEpisodesModal; + +const styles = StyleSheet.create({ + root: { + flex: 1, + borderRadius: 12, + overflow: 'hidden', + }, + item_root: { + flexDirection: 'row', + padding: 12, + gap: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: 'rgba(0, 0, 0, 0.1)', + }, + item_text_container: { + flex: 1, + gap: 4, + }, + item_title: { + fontSize: 17, + }, + item_caption: { + fontSize: 13, + }, +}); diff --git a/app/screens/podcast/episodeSelection/useEpisodes.ts b/app/screens/podcast/episodeSelection/useEpisodes.ts new file mode 100644 index 0000000..1619e89 --- /dev/null +++ b/app/screens/podcast/episodeSelection/useEpisodes.ts @@ -0,0 +1,25 @@ +import {useEffect, useState} from 'react'; +import {fetchCategoryPlaylist} from '../../../api'; +import {Article} from '../../../../Types'; + +const useEpisodes = (categoryId?: number) => { + const [items, setItems] = useState([]); + + useEffect(() => { + if (items.length > 0 || !categoryId) { + return; + } + + fetchCategoryPlaylist(categoryId).then((response) => { + if (response.articles) { + setItems(response.articles); + } + }); + }, [categoryId]); + + return { + items, + }; +}; + +export default useEpisodes; diff --git a/app/screens/podcast/recommendations/useRecommendations.ts b/app/screens/podcast/recommendations/useRecommendations.ts index 92bcdb9..73670fb 100644 --- a/app/screens/podcast/recommendations/useRecommendations.ts +++ b/app/screens/podcast/recommendations/useRecommendations.ts @@ -17,7 +17,7 @@ const useRecomendations = (articleId: number) => { }); } }); - }, []); + }, [articleId]); return { items, diff --git a/app/screens/search/SearchScreen.tsx b/app/screens/search/SearchScreen.tsx index b80a097..c92b365 100644 --- a/app/screens/search/SearchScreen.tsx +++ b/app/screens/search/SearchScreen.tsx @@ -79,7 +79,11 @@ const SearchScreen: React.FC> = ({navigation, rou const articlePressHandler = useCallback( (article: Article) => { - navigation.navigate('Article', {articleId: article.id}); + if (article.is_audio) { + navigation.navigate('Podcast', {articleId: article.id}); + } else { + navigation.navigate('Article', {articleId: article.id}); + } }, [navigation], ); @@ -188,7 +192,7 @@ const SearchScreen: React.FC> = ({navigation, rou }} translateY={translateY} actions={ - navigation.toggleDrawer()}> + navigation.toggleDrawer()} accessibilityLabel={'šoninis meniu'}> } diff --git a/app/screens/slug/SlugScreen.tsx b/app/screens/slug/SlugScreen.tsx index 1b47eb2..621f3dc 100644 --- a/app/screens/slug/SlugScreen.tsx +++ b/app/screens/slug/SlugScreen.tsx @@ -129,7 +129,13 @@ const SlugScreen: React.FC> = ({navigation, route return ( navigation.push('Article', {articleId: article.id})} + onArticlePress={(article) => { + if (article.is_audio) { + navigation.push('Podcast', {articleId: article.id}); + } else { + navigation.push('Article', {articleId: article.id}); + } + }} /> ); }} diff --git a/app/state/article_storage_store.ts b/app/state/article_storage_store.ts index d499823..f049401 100644 --- a/app/state/article_storage_store.ts +++ b/app/state/article_storage_store.ts @@ -30,6 +30,7 @@ export type SavedArticle = { url?: string; photo: string; is_video?: 1 | 0; + is_audio?: 1 | 0; }; type ArticleStorageState = { @@ -98,6 +99,7 @@ const mapArticleData = (article: ArticleContent): SavedArticle => { photo: article.main_photo?.path, subtitle: article.subtitle, is_video: article.is_video, + is_audio: article.is_audio, }; } else { return { @@ -109,6 +111,7 @@ const mapArticleData = (article: ArticleContent): SavedArticle => { photo: article.main_photo?.path, subtitle: article.article_subtitle, is_video: article.is_video, + is_audio: 0, }; } }; From bdfbd012eec767946e2c63ab314bb976bfcdf9f0 Mon Sep 17 00:00:00 2001 From: Kestas Venslauskas Date: Sat, 8 Feb 2025 14:27:36 +0200 Subject: [PATCH 6/7] bump version --- android/app/build.gradle | 4 ++-- ios/lrtApp.xcodeproj/project.pbxproj | 8 ++++---- package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 30893cc..35f86b7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -89,8 +89,8 @@ android { applicationId "lt.mediapark.lrt" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 205301 - versionName "2.53.1" + versionCode 205400 + versionName "2.54.0" multiDexEnabled true resValue "string", "build_config_package", "lt.mediapark.lrt" } diff --git a/ios/lrtApp.xcodeproj/project.pbxproj b/ios/lrtApp.xcodeproj/project.pbxproj index 38bc694..0d9b726 100644 --- a/ios/lrtApp.xcodeproj/project.pbxproj +++ b/ios/lrtApp.xcodeproj/project.pbxproj @@ -866,13 +866,13 @@ CODE_SIGN_ENTITLEMENTS = lrtApp/lrtApp.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 205301; + CURRENT_PROJECT_VERSION = 205400; DEVELOPMENT_TEAM = TGMUP98888; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; INFOPLIST_FILE = lrtApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 2.53.1; + MARKETING_VERSION = 2.54.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -900,11 +900,11 @@ CODE_SIGN_ENTITLEMENTS = lrtApp/lrtAppRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 205301; + CURRENT_PROJECT_VERSION = 205400; DEVELOPMENT_TEAM = TGMUP98888; INFOPLIST_FILE = lrtApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 2.53.1; + MARKETING_VERSION = 2.54.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/package.json b/package.json index 158c2be..25d567c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lrt.lt", - "version": "2.53.0", + "version": "2.54.0", "private": true, "license": "MIT", "scripts": { From 43a70afb2f41bee94c47c4d8a6559375415eb6fb Mon Sep 17 00:00:00 2001 From: Kestas Venslauskas Date: Sat, 8 Feb 2025 14:34:26 +0200 Subject: [PATCH 7/7] chore: sonar fixes --- app/api/Types.ts | 2 -- .../videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx | 2 +- app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx | 3 +-- app/screens/podcast/PodcastScreen.tsx | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/api/Types.ts b/app/api/Types.ts index cd1bd94..7c33a61 100644 --- a/app/api/Types.ts +++ b/app/api/Types.ts @@ -172,7 +172,6 @@ export type HomeBlockEpikaBlock = { }; export type HomeBlockTopFeedBlock = { - //TODO: remove this template after audioteka api update template: 'top_feed'; type: 'top_feed'; articles: FeedArticle[]; @@ -435,7 +434,6 @@ export type RadiotekaCategory = { }; lrt_show_id: number; title: string; - //TODO: check if this is correct might be incosisntent type LATEST_ITEM: ArticleContentMedia; }; diff --git a/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx b/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx index 3bd93e6..dbadf07 100644 --- a/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx +++ b/app/components/videoComponent/context/miniPlayerAudio/MiniPlayerAudio.tsx @@ -95,7 +95,7 @@ const MiniPlayerAudio: React.FC> = (props) => { mediaType={mediaData.mediaType} poster={mediaData.poster} title={mediaData.title} - streamUri={mediaData.uri!} + streamUri={mediaData.uri} startTime={mediaData.startTime} tracks={mediaData.tracks} autoStart={true} diff --git a/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx b/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx index 8b57ce6..92562c0 100644 --- a/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx +++ b/app/screens/main/tabScreen/radioteka/RadiotekaScreen.tsx @@ -147,7 +147,6 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) categoryTitle={item.data.description.article_title} items={item.data.category_list.map((c) => ({ title: c.title, - //TODO: need to check if this is correct category: c.branch_info?.branch_level2?.branch_title ?? c.branch_info?.branch_level1?.branch_title, imageUrl: buildImageUri( @@ -212,7 +211,7 @@ const RadiotekaScreen: React.FC> = ({isCurrent}) break; } default: { - // console.warn('Unknown list item: ', JSON.stringify(item, null, 4)); + console.warn('Unknown list item: ', JSON.stringify(item, null, 4)); return ; } } diff --git a/app/screens/podcast/PodcastScreen.tsx b/app/screens/podcast/PodcastScreen.tsx index 593178d..14a778d 100644 --- a/app/screens/podcast/PodcastScreen.tsx +++ b/app/screens/podcast/PodcastScreen.tsx @@ -12,7 +12,7 @@ import PodcastAbout from './about/PodcastAbout'; import {ArticleContentMedia} from '../../api/Types'; import {ScrollView} from 'react-native-gesture-handler'; import FastImage from 'react-native-fast-image'; -import {buildArticleImageUri, IMG_SIZE_XL, IMG_SIZE_XXL} from '../../util/ImageUtil'; +import {buildArticleImageUri, IMG_SIZE_XXL} from '../../util/ImageUtil'; import PodcastEpisode from './episode/PodcastEpisode'; import PodcastRecommendations from './recommendations/PodcastRecommendations'; import PodcastEpisodeSelection from './episodeSelection/PodcastEpisodeSelection';