morethanadiagnosis-hub/mobile/app/(tabs)/podcasts.tsx
2025-11-19 09:42:02 -08:00

242 lines
7.1 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { Audio } from 'expo-av';
import { Theme } from '../../constants/Theme';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
const API_URL = process.env.EXPO_PUBLIC_API_URL || 'http://216.158.230.94:8001/api/v1';
interface PodcastEpisode {
id: string;
title: string;
description: string;
audio_url: string;
published_at: string;
duration: string;
image_url: string;
}
interface PodcastFeed {
title: string;
description: string;
image_url: string;
episodes: PodcastEpisode[];
}
const fetchPodcasts = async (): Promise<PodcastFeed> => {
const response = await fetch(`${API_URL}/podcast/`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
};
export default function PodcastsScreen() {
const { data, isLoading, error } = useQuery({
queryKey: ['podcasts'],
queryFn: fetchPodcasts,
});
const [sound, setSound] = useState<Audio.Sound | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentEpisodeId, setCurrentEpisodeId] = useState<string | null>(null);
useEffect(() => {
return () => {
if (sound) {
sound.unloadAsync();
}
};
}, [sound]);
const playPodcast = async (audioUrl: string, episodeId: string) => {
try {
if (sound) {
await sound.unloadAsync();
}
const { sound: newSound } = await Audio.Sound.createAsync(
{ uri: audioUrl },
{ shouldPlay: true }
);
setSound(newSound);
setCurrentEpisodeId(episodeId);
setIsPlaying(true);
newSound.setOnPlaybackStatusUpdate((status) => {
if (status.isLoaded) {
setIsPlaying(status.isPlaying);
if (status.didJustFinish) {
setIsPlaying(false);
setCurrentEpisodeId(null);
}
}
});
} catch (error) {
console.error('Error playing sound:', error);
}
};
const pausePodcast = async () => {
if (sound) {
await sound.pauseAsync();
setIsPlaying(false);
}
};
const resumePodcast = async () => {
if (sound) {
await sound.playAsync();
setIsPlaying(true);
}
};
const handlePlayPause = (audioUrl: string, episodeId: string) => {
if (currentEpisodeId === episodeId) {
if (isPlaying) {
pausePodcast();
} else {
resumePodcast();
}
} else {
playPodcast(audioUrl, episodeId);
}
};
const renderItem = ({ item }: { item: PodcastEpisode }) => (
<View style={styles.card}>
<View style={styles.cardContent}>
<Image source={{ uri: item.image_url || data?.image_url }} style={styles.thumbnail} />
<View style={styles.textContainer}>
<Text style={styles.episodeTitle} numberOfLines={2}>{item.title}</Text>
<Text style={styles.episodeDate}>{new Date(item.published_at).toLocaleDateString()}</Text>
<Text style={styles.episodeDuration}>{item.duration}</Text>
</View>
<TouchableOpacity
style={styles.playButton}
onPress={() => handlePlayPause(item.audio_url, item.id)}
>
<Ionicons
name={currentEpisodeId === item.id && isPlaying ? "pause-circle" : "play-circle"}
size={48}
color={Theme.colors.primary}
/>
</TouchableOpacity>
</View>
</View>
);
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={Theme.colors.primary} />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>Failed to load podcasts</Text>
</View>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<FlatList
data={data?.episodes}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.headerTitle}>Podcasts</Text>
<Text style={styles.headerSubtitle}>Listen to the latest episodes</Text>
</View>
}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Theme.colors.background,
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
listContent: {
padding: Theme.spacing.m,
},
header: {
marginBottom: Theme.spacing.l,
},
headerTitle: {
fontSize: Theme.typography.fontSize.xxl,
fontFamily: Theme.typography.fontFamily.heading,
color: Theme.colors.text,
fontWeight: 'bold',
},
headerSubtitle: {
fontSize: Theme.typography.fontSize.m,
fontFamily: Theme.typography.fontFamily.body,
color: Theme.colors.text,
opacity: 0.7,
},
card: {
backgroundColor: Theme.colors.surface,
borderRadius: Theme.borderRadius.m,
padding: Theme.spacing.m,
marginBottom: Theme.spacing.m,
...Theme.shadows.card,
},
cardContent: {
flexDirection: 'row',
alignItems: 'center',
},
thumbnail: {
width: 60,
height: 60,
borderRadius: Theme.borderRadius.s,
marginRight: Theme.spacing.m,
backgroundColor: Theme.colors.secondary,
},
textContainer: {
flex: 1,
marginRight: Theme.spacing.s,
},
episodeTitle: {
fontSize: Theme.typography.fontSize.m,
fontFamily: Theme.typography.fontFamily.heading,
color: Theme.colors.text,
fontWeight: '600',
marginBottom: 4,
},
episodeDate: {
fontSize: Theme.typography.fontSize.xs,
fontFamily: Theme.typography.fontFamily.body,
color: Theme.colors.text,
opacity: 0.6,
},
episodeDuration: {
fontSize: Theme.typography.fontSize.xs,
fontFamily: Theme.typography.fontFamily.body,
color: Theme.colors.text,
opacity: 0.6,
},
playButton: {
padding: 4,
},
errorText: {
color: Theme.colors.error || 'red',
fontSize: Theme.typography.fontSize.m,
},
});