reactjsreact-nativeexporeact-navigationreact-native-track-player

Save state on react navigation modal close with react native track player


I have a react-navigation screen set as a modal with a react-native-track player in it. Initially, it loads with no issue, but when I close the modal and open it back - the audio plays but the artwork and details are not visible.

Looks like resources are freed up every time I close the modal(detach). I'm trying to persist the state(AsyncStorage) but I totally confused myself.

import { useEffect, useState } from "react";
import { SafeAreaView } from 'react-native-safe-area-context';
import { Text, View, Image, StyleSheet, ScrollView, TouchableOpacity, Share, ActivityIndicator } from "react-native";
import { Colors, MarqueeDirections, Marquee, Button, Carousel } from "react-native-ui-lib";
import FontAwesome5 from '@expo/vector-icons/FontAwesome5';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Dialog } from "react-native-ui-lib/src/incubator";
import Slider from '@react-native-community/slider';
import { useBook } from "../context/useBook";
import TrackPlayer, { Capability, State, Event, usePlaybackState, useProgress, useTrackPlayerEvents } from 'react-native-track-player';
import AsyncStorage from '@react-native-async-storage/async-storage';

const podcasts = [
    {
        title: 'Lettera a un nemico',
        url: 'https://api.spreaker.com/download/episode/43447400/letteraoreste.mp3',
    },
    {
        title: '143 - Intelligenza Artificiale Generativa con Jacopo Perfetti',
        url: 'https://chtbl.com/track/9E947E/api.spreaker.com/download/episode/52096290/def_hc_perfetti_v2_loud.mp3',
    },
    {
        title: 'IA : Microsoft investit 10 milliards dans Open AI pour tout dominer ?',
        url: 'https://traffic.megaphone.fm/FODL8281657475.mp3?updated=1673441802',
    }
];

const BG_COLOR = '#3d405b';

const PlayerModal = ({ navigation, route }) => {
    const { stateParam, currentBookParam } = route.params;

    const book = useBook();

    const tracksCount = podcasts.length;
    const [trackIndex, setTrackIndex] = useState(0);
    const [trackTitle, setTrackTitle] = useState();
    const [trackArtist, setTrackArtist] = useState();
    const [trackArtwork, setTrackArtwork] = useState();

    const [currentBook, setCurrentBook] = useState(0);

    const playBackState = usePlaybackState();
    const progress = useProgress();

    const [isTextDialogVisible, setIsTextDialogVisible] = useState(false);
    const [isReadingMode, setIsReadingMode] = useState(false);
    const [fontSize, setFontSize] = useState(14);
    const [fontTheme, setFontTheme] = useState({ modalBackgroundColor: Colors.white, backgroundColor: Colors.white, color: Colors.grey10 });

    const storeData = async (value) => {
        try {
            const jsonValue = JSON.stringify(value);

            await AsyncStorage.setItem('@test', jsonValue);
        } catch (e) {
            // saving error
        }
    }

    const getData = async () => {
        try {
            const jsonValue = await AsyncStorage.getItem('@test');
            return jsonValue != null ? JSON.parse(jsonValue) : null;
        } catch (e) {
            // error reading value
        }
    }

    const setupPlayer = async () => {
        try {
            await TrackPlayer.setupPlayer();
            await TrackPlayer.updateOptions({
                capabilities: [
                    Capability.Play,
                    Capability.Pause,
                    Capability.SkipToNext,
                    Capability.SkipToPrevious
                ],
            });

            //TODO: get all tracks from DB
            await TrackPlayer.add(
                podcasts
                //currentBook?.keyIdeas[0]?.audioTrack
            );
            await gettrackdata();
            await TrackPlayer.play();
        } catch (error) { console.log(error); }
    };

    useTrackPlayerEvents([Event.PlaybackTrackChanged], async event => {
        if (event.type === Event.PlaybackTrackChanged && event.nextTrack !== null) {
            const track = await TrackPlayer.getTrack(event.nextTrack);
            const { title } = track;

            setTrackIndex(event.nextTrack);
            setTrackTitle(title);
        }
    });

    const gettrackdata = async () => {
        let trackIndex = await TrackPlayer.getCurrentTrack();
        let trackObject = await TrackPlayer.getTrack(trackIndex);

        setTrackIndex(trackIndex);
        setTrackTitle(trackObject.title);
        setTrackArtist(currentBook?.author);
        setTrackArtwork(currentBook?.artwork);
    };

    const togglePlayBack = async playBackState => {
        const currentTrack = await TrackPlayer.getCurrentTrack();
        if (currentTrack != null) {
            if ((playBackState == State.Paused) | (playBackState == State.Ready)) {
                await TrackPlayer.play();
            } else {
                await TrackPlayer.pause();
            }
        }
    };

    const nexttrack = async () => {
        if (trackIndex < tracksCount - 1) {
            await TrackPlayer.skipToNext();
            gettrackdata();
        };
    };

    const previoustrack = async () => {
        if (trackIndex > 0) {
            await TrackPlayer.skipToPrevious();
            gettrackdata();
        };
    };

    async function carouselPageChanged(pageIndex, oldPageIndex) {
        if (pageIndex > oldPageIndex) {
            await nexttrack();
        }
        else {
            await previoustrack();
        }
    }

    useEffect(() => {
        setupPlayer();

        // if asyncStorage has data - it means player has been setup already and we have a book
        getData().then(async (res) => {
            if (res === null) {
                console.log(currentBookParam?.title);
                // save mode that we get as a param to identify what view to display: Reading or Listening
                // it can be later accessed by StickyComponent above Tabs
                currentBookParam.mode = stateParam; //read || listen
                setCurrentBook(currentBookParam);
                storeData(currentBookParam);
            }
            else {
                console.log('res: ' + res?.title);

                setCurrentBook(res);
            }
        });

        //book?.addToHistory(currentBook);

        //return () => TrackPlayer.destroy();
    }, []);

    useEffect(() => {
        // set header buttons and colors
        navigation.setOptions({
            title: currentBook?.title,
            headerTitleStyle: { color: isReadingMode ? fontTheme.modalBackgroundColor : BG_COLOR },
            headerStyle: {
                backgroundColor: isReadingMode ? fontTheme.modalBackgroundColor : BG_COLOR,
                borderBottom: 0
            },
            headerLeft: () => (
                <View style={{ flexDirection: 'row', alignItems: 'center', alignContent: 'center', justifyContent: 'flex-start' }}>
                    <TouchableOpacity onPress={() => { navigation.goBack() }}><FontAwesome5 name="chevron-down" size={25} color={isReadingMode ? fontTheme.color : Colors.white} /></TouchableOpacity>
                </View>
            ),
            headerRight: () => (
                <View style={{ width: 250, flexDirection: 'row', alignItems: 'center', alignContent: 'center', justifyContent: 'flex-end' }}>
                    {playBackState === State.Buffering || playBackState === State.Connecting || playBackState === State.None ?
                        <ActivityIndicator size="small" color={fontTheme.color} />
                        :
                        <>
                            {isReadingMode &&
                                <TouchableOpacity onPress={() => setIsTextDialogVisible((current) => !current)} style={{ marginRight: 15 }}><MaterialIcons name="format-size" size={25} color={fontTheme.color} /></TouchableOpacity>
                            }
                            {isReadingMode &&
                                <TouchableOpacity onPress={() => togglePlayBack(playBackState)} style={{ marginRight: 15 }}>
                                    {playBackState === State.Playing ?
                                        <MaterialIcons name="pause-circle-filled" size={25} color={fontTheme.color} />
                                        :
                                        <MaterialIcons name="play-circle-filled" size={25} color={fontTheme.color} />
                                    }
                                </TouchableOpacity>
                            }
                            {isReadingMode ?
                                <TouchableOpacity onPress={() => setIsReadingMode((current) => !current)} style={{ marginRight: 15 }}><FontAwesome5 name="headphones-alt" size={25} color={fontTheme.color} /></TouchableOpacity>
                                :
                                <TouchableOpacity onPress={() => setIsReadingMode((current) => !current)} style={{ marginRight: 15 }}><FontAwesome5 name="readme" size={25} color={Colors.white} /></TouchableOpacity>
                            }
                            <TouchableOpacity><MaterialIcons name="more-horiz" size={30} color={isReadingMode ? fontTheme.color : Colors.white} /></TouchableOpacity>
                        </>
                    }
                </View>
            )
        });
    }, [currentBook, playBackState, isReadingMode, fontTheme, State]);

    const onShare = async () => {
        try {
            const result = await Share.share({
                message: 'Syat | Читай и слушай самое важное из книг за мгновение',
            });
            if (result.action === Share.sharedAction) {
                if (result.activityType) {
                    // shared with activity type of result.activityType
                } else {
                    // shared
                }
            } else if (result.action === Share.dismissedAction) {
                // dismissed
            }
        } catch (error) {
            alert(error.message);
        }
    };

    return (
        <SafeAreaView edges={['bottom']} style={{ flex: 1, backgroundColor: isReadingMode ? fontTheme.modalBackgroundColor : BG_COLOR }}>
            <Dialog
                useSafeArea
                visible={isTextDialogVisible}
                top={true}
                containerStyle={{ width: '95%', backgroundColor: Colors.white, paddingVertical: 15, paddingHorizontal: 5, marginTop: '20%', borderRadius: 12 }}
                onDismiss={() => setIsTextDialogVisible(false)}
                direction={'up'}
            >
                <View style={{ paddingHorizontal: 15, flexDirection: 'row', alignItems: 'center', alignContent: 'center', justifyContent: 'space-around' }}>
                    <TouchableOpacity><MaterialIcons name="format-size" size={20} color={BG_COLOR} /></TouchableOpacity>
                    <Slider
                        style={{ flex: 1 }}
                        thumbTintColor='#00b4d8'
                        minimumTrackTintColor='#00b4d8'
                        maximumTrackTintColor={Colors.grey50}
                        step={1}
                        value={fontSize}
                        minimumValue={12}
                        maximumValue={24}
                        onValueChange={(val) => setFontSize(val)}
                    />
                    <TouchableOpacity><MaterialIcons name="format-size" size={30} color={BG_COLOR} /></TouchableOpacity>
                </View>
                <View style={{ marginTop: 15, flexDirection: 'row', alignItems: 'center', alignContent: 'center', justifyContent: 'space-around' }}>
                    <Button onPress={() => setFontTheme({ modalBackgroundColor: Colors.white, backgroundColor: Colors.white, color: Colors.grey1 })} label={'Aa'} labelStyle={{ color: Colors.grey1 }} size={Button.sizes.large} outline={true} outlineColor={Colors.grey50} outlineWidth={1} backgroundColor={Colors.white} borderRadius={5} />
                    <Button onPress={() => setFontTheme({ modalBackgroundColor: Colors.yellow70, backgroundColor: Colors.yellow70, color: Colors.grey1 })} label={'Aa'} labelStyle={{ color: Colors.grey1 }} size={Button.sizes.large} backgroundColor={Colors.yellow70} borderRadius={5} />
                    <Button onPress={() => setFontTheme({ modalBackgroundColor: BG_COLOR, backgroundColor: BG_COLOR, color: Colors.white })} label={'Aa'} size={Button.sizes.large} backgroundColor={BG_COLOR} borderRadius={5} />
                </View>
            </Dialog>
            <ScrollView bounces={false} showsVerticalScrollIndicator style={styles.container}>
                {isReadingMode ?
                    <Carousel
                        initialPage={trackIndex}
                        bounces={false}
                        onChangePage={carouselPageChanged}>
                        {currentBook?.keyIdeas?.map((keyIdea, y) =>
                            <View style={{ flexGrow: 1, backgroundColor: fontTheme.backgroundColor, padding: 14 }}>
                                <Text selectable style={[styles.textHeading, { color: fontTheme.color }]}>{currentBook?.keyIdeas[y]?.title}</Text>

                                <Text selectable style={[styles.textParagraph, { fontSize, color: fontTheme.color }]}>{currentBook?.keyIdeas[y]?.content}</Text>
                            </View>
                        )}
                    </Carousel>
                    :
                    <View style={styles.bookItem}>
                        <View style={styles.bookImageBg}>
                            <Image style={styles.bookImage} source={{ uri: trackArtwork }} />

                            {/* <Image style={styles.bookImage} source={{ uri: currentBook?.image }} /> */}
                            <Marquee
                                key={1}
                                label={trackTitle}
                                duration={8000}
                                direction={MarqueeDirections.LEFT}
                                containerStyle={{ height: 'auto', marginTop: 15, marginBottom: 5 }}
                                labelStyle={styles.bookTitle}
                                numberOfReps={-1}
                            />
                            <Text style={styles.bookAuthor}>{trackArtist}</Text>
                            {/* <Text style={styles.bookAuthor}>{currentBook?.author}</Text> */}
                        </View>

                        <View style={styles.innerContainer}>
                            {/* Track progress bar */}
                            <Slider
                                containerStyle={{ marginTop: 60 }}
                                thumbTintColor='#00b4d8'
                                minimumTrackTintColor='#00b4d8'
                                maximumTrackTintColor={Colors.grey20}
                                value={progress.position}
                                minimumValue={0}
                                maximumValue={progress.duration}
                                onSlidingComplete={async value => await TrackPlayer.seekTo(value)}
                            />
                            <View style={{ flexDirection: 'row', alignItems: 'center', alignContent: 'center', justifyContent: 'space-between' }}>
                                <Text style={{ color: Colors.white }}>{
                                    new Date(progress.position * 1000)
                                        .toLocaleTimeString()
                                        .substring(3)}
                                </Text>
                                <Text style={{ color: Colors.white }}>
                                    {new Date((progress.duration - progress.position) * 1000)
                                        .toLocaleTimeString()
                                        .substring(3)}
                                </Text>
                            </View>

                            {/* Player buttons */}
                            <View style={styles.actionsContainer}>
                                {!State.Ready ?
                                    <ActivityIndicator size="small" color={Colors.white} />
                                    :
                                    <>
                                        <TouchableOpacity onPress={previoustrack} style={{ alignItems: 'center' }}><MaterialIcons name="skip-previous" size={40} color={Colors.grey60} /></TouchableOpacity>
                                        {/* <TouchableOpacity disabled={!audioTrack || !isAudioTrackLoaded} onPress={backwards} style={{ alignItems: 'center' }}><MaterialIcons name="replay-10" size={40} color={Colors.grey60} /></TouchableOpacity> */}
                                        <TouchableOpacity onPress={() => togglePlayBack(playBackState)} style={{ alignItems: 'center' }}>
                                            {playBackState === State.Playing ?
                                                <MaterialIcons name="pause-circle-filled" size={90} color={Colors.grey60} />
                                                :
                                                <MaterialIcons name="play-circle-filled" size={90} color={Colors.grey60} />
                                            }
                                        </TouchableOpacity>
                                        {/* <TouchableOpacity disabled={!audioTrack || !isAudioTrackLoaded} onPress={forward} style={{ alignItems: 'center' }}><MaterialIcons name="forward-10" size={40} color={Colors.grey60} /></TouchableOpacity> */}
                                        <TouchableOpacity onPress={nexttrack} style={{ alignItems: 'center' }}><MaterialIcons name="skip-next" size={40} color={Colors.grey60} /></TouchableOpacity>
                                    </>
                                }
                            </View>
                            {/* END Player buttons */}
                        </View>
                    </View>
                }
            </ScrollView>
        </SafeAreaView>
    )
}

export default PlayerModal;

const styles = StyleSheet.create({
    container: {
        flex: 1,
        backgroundColor: BG_COLOR
    },
    innerContainer: {
        backgroundColor: BG_COLOR,
        paddingHorizontal: 24
    },
    actionsContainer: {
        backgroundColor: '#3d405b',
        flexDirection: 'row',
        alignContent: 'center',
        alignItems: 'center',
        justifyContent: 'space-evenly',
        marginTop: 30
    },
    sectionTitle: {
        fontFamily: 'Inter_600SemiBold',
        color: Colors.black,
        fontSize: 16,
        marginVertical: 10
    },
    textHeading: {
        fontFamily: 'RobotoSerif_600SemiBold',
        color: Colors.grey1,
        fontSize: 22,
        marginBottom: 15
    },
    textParagraph: {
        fontFamily: 'RobotoSerif_400Regular',
        color: Colors.grey1,
        fontSize: 14,
        marginBottom: 15
    },
    bookTitle: {
        fontFamily: 'Inter_800ExtraBold',
        color: Colors.grey70,
        fontSize: 24
    },
    bookAuthor: {
        fontFamily: 'Inter_600SemiBold',
        color: Colors.grey40,
        fontSize: 16,
        marginBottom: 5
    },
    bookDesc: {
        fontFamily: 'Inter_400Regular',
        color: Colors.black,
        fontSize: 14,
    },
    bookItem: {},
    bookImageBg: {
        backgroundColor: BG_COLOR,
        marginBottom: 12,
        padding: 10,
        alignContent: 'center',
        justifyContent: 'center',
        alignItems: 'center'
    },
    bookImage: {
        width: '100%',
        height: 300,
        resizeMode: 'contain'
    },
    keyIdea: {
        flexDirection: 'row',
        width: '100%',
        alignItems: 'center',
        borderBottomColor: Colors.grey10,
        borderBottomWidth: StyleSheet.hairlineWidth,
        paddingVertical: 10
    },
    keyIdeaOrder: {
        fontFamily: 'Inter_600SemiBold',
        color: Colors.black,
        fontSize: 16,
    },
    keyIdeaTitle: {
        fontFamily: 'Inter_400Regular',
        color: Colors.black,
        fontSize: 14,
        marginBottom: 5,
        marginHorizontal: 25
    }
});


Solution

  • I moved the player setup to App.js, leaving the catch section empty.

    useEffect(() => {
      isPlayerInitialized();
    }, []);
    
    async function isPlayerInitialized() {
      let isPlayerInitialized = false;
    
      try {
        await TrackPlayer.setupPlayer();
        await TrackPlayer.updateOptions({
          capabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext, Capability.SkipToPrevious]
        });
    
        isPlayerInitialized = true;
      } catch (e) {
        // intentionally leaved as blank
      }
    }

    Passing item object via react-navigation params from modal's parent Component and handling it in my Modal, where I add tracks to already initialized TrackPlayer.

    const {
      state,
      currentBook
    } = route.params;
    
    useEffect(() => {
      setupPlayer();
    }, []);
    
    const setupPlayer = async() => {
      try {
        setTrackArtist(currentBook ? .author);
        setTrackArtwork(currentBook ? .artwork);
    
        await TrackPlayer.add(podcasts);
    
        await gettrackdata();
        await TrackPlayer.play();
      } catch (error) {
        console.log(error);
      }
    };