From 54665becf3a3785a5c7353f92720a0a04698537a Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Sat, 3 Feb 2024 17:41:55 +0700 Subject: [PATCH 1/3] feat: adding loading status (#47) --- .../components/episode-container.tsx | 27 +++++++++--- .../episode-screen/hooks/use-episodes.tsx | 43 +++++++++++++------ .../anime/watch/components/error-message.tsx | 20 +++++++-- .../watch/components/media-container.tsx | 27 ++++++++++-- .../watch/components/player-container.tsx | 4 ++ .../watch/components/servers-container.tsx | 12 ++++-- 6 files changed, 102 insertions(+), 31 deletions(-) diff --git a/src/screens/anime/details/screens/episode-screen/components/episode-container.tsx b/src/screens/anime/details/screens/episode-screen/components/episode-container.tsx index a1b8ef2..ca71c98 100644 --- a/src/screens/anime/details/screens/episode-screen/components/episode-container.tsx +++ b/src/screens/anime/details/screens/episode-screen/components/episode-container.tsx @@ -1,6 +1,6 @@ import { useAtomValue, useSetAtom } from 'jotai/react'; import { RotateCwIcon } from 'lucide-react-native'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Else, If, Then } from 'react-if'; import { type FragmentType, graphql, useFragment } from '@/gql'; @@ -38,8 +38,15 @@ const EpisodeContainer: React.FC = ({ }) => { const media = useFragment(EpisodeContainerFragment, mediaFragment); const currentModuleId = useAtomValue(currentModuleIdAtom); - - const { data, isLoading, isRefetching, refetch } = useEpisodes(media); + const [episodeFetchingStatus, setEpisodeFetchingStatus] = useState<{ + isError: boolean; + status: string; + }>({ isError: false, status: '' }); + + const { data, isLoading, isRefetching, refetch } = useEpisodes( + media, + setEpisodeFetchingStatus + ); const setSectionEpisodes = useSetAtom(sectionEpisodesAtom); const setEpisodeChunk = useSetAtom(episodeChunkAtom); @@ -106,15 +113,23 @@ const EpisodeContainer: React.FC = ({ + + {!episodeFetchingStatus.isError && ( + + {episodeFetchingStatus.status} + + )} - - There are no episodes for this anime - + {episodeFetchingStatus.isError && ( + + {episodeFetchingStatus.status} + + )} diff --git a/src/screens/anime/details/screens/episode-screen/hooks/use-episodes.tsx b/src/screens/anime/details/screens/episode-screen/hooks/use-episodes.tsx index 17157bc..4b7db3a 100644 --- a/src/screens/anime/details/screens/episode-screen/hooks/use-episodes.tsx +++ b/src/screens/anime/details/screens/episode-screen/hooks/use-episodes.tsx @@ -34,33 +34,47 @@ export const useAnimeEpisodeFragment = graphql(` `); const useEpisodes = ( - mediaFragment: FragmentType + mediaFragment: FragmentType, + onStatusChange?: (change: { isError: boolean; status: string }) => void ) => { const media = useFragment(useAnimeEpisodeFragment, mediaFragment); - const { data, isLoading: isAnimeIdLoading } = useAnimeId(media); + const { data: animeId, isLoading: isAnimeIdLoading } = useAnimeId(media); const queryKey = ['episodes', media.id]; - if (data?.data) { - queryKey.push(data.data); + if (animeId?.data) { + queryKey.push(animeId.data); } const episodes = useWebViewData( queryKey, async (webview) => { - if (!data?.data) { + onStatusChange?.({ isError: false, status: 'Loading anime id' }); + + if (!animeId?.data) { + if (isAnimeIdLoading) { + onStatusChange?.({ isError: false, status: 'Loading anime id' }); + } else { + onStatusChange?.({ isError: true, status: 'Cannot find anime id' }); + } + return []; } const nonValidatedEpisodesPromise = webview.sendMessage( 'anime.getEpisodes', { - animeId: data?.data, - extraData: data?.extraData, + animeId: animeId?.data, + extraData: animeId?.extraData, } ); + onStatusChange?.({ + isError: false, + status: 'Loading episodes', + }); + const metadataEpisodesPromise = getEpisodeInfo(media); const [nonValidatedEpisodesResult, metadataEpisodesResult] = @@ -70,6 +84,11 @@ const useEpisodes = ( ]); if (nonValidatedEpisodesResult.status === 'rejected') { + onStatusChange?.({ + isError: true, + status: 'Cannot find episodes', + }); + return []; } @@ -78,10 +97,9 @@ const useEpisodes = ( .safeParse(nonValidatedEpisodesResult.value); if (!validation.success) { - Toast.show({ - type: 'error', - text1: 'Cannot find episodes', - text2: validation.error.message, + onStatusChange?.({ + isError: true, + status: 'Cannot find episodes', }); return []; @@ -117,8 +135,6 @@ const useEpisodes = ( }; }); - console.log('episodes', episodes); - return episodes; }, @@ -130,7 +146,6 @@ const useEpisodes = ( text2: err, }); }, - enabled: !isAnimeIdLoading, retry: 0, // For some reason, when calling useEpisodes in watch screen, it will return empty episodes diff --git a/src/screens/anime/watch/components/error-message.tsx b/src/screens/anime/watch/components/error-message.tsx index 6d8cec0..e087b7c 100644 --- a/src/screens/anime/watch/components/error-message.tsx +++ b/src/screens/anime/watch/components/error-message.tsx @@ -1,4 +1,5 @@ import { useNavigation } from '@react-navigation/native'; +import { ArrowLeft, RefreshCcw } from 'lucide-react-native'; import React from 'react'; import { Button, Text, View } from '@/ui'; @@ -6,9 +7,10 @@ import Sticker from '@/ui/sticker'; interface ErrorMessageProps { message: string; + onRetry?: () => void; } -const ErrorMessage: React.FC = ({ message }) => { +const ErrorMessage: React.FC = ({ message, onRetry }) => { const navigation = useNavigation(); const handleGoBack = () => { @@ -25,9 +27,19 @@ const ErrorMessage: React.FC = ({ message }) => { {message} - + + + + + ); }; diff --git a/src/screens/anime/watch/components/media-container.tsx b/src/screens/anime/watch/components/media-container.tsx index b0d6c9c..ccc6fbf 100644 --- a/src/screens/anime/watch/components/media-container.tsx +++ b/src/screens/anime/watch/components/media-container.tsx @@ -1,8 +1,8 @@ import { useSetAtom } from 'jotai/react'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import type { FragmentType } from '@/gql'; -import { ActivityIndicator, View } from '@/ui'; +import { ActivityIndicator, Text, View } from '@/ui'; import colors from '@/ui/theme/colors'; import type { useAnimeEpisodeFragment } from '../../details/screens/episode-screen/hooks/use-episodes'; @@ -29,6 +29,11 @@ const MediaContainer: React.FC = ({ const setMediaId = useSetAtom(mediaIdAtom); const setIsAdult = useSetAtom(isAdultAtom); + const [episodeFetchingStatus, setEpisodeFetchingStatus] = useState<{ + isError: boolean; + status: string; + }>({ isError: false, status: '' }); + useEffect(() => { setMediaId({ anilistId, @@ -40,18 +45,32 @@ const MediaContainer: React.FC = ({ setIsAdult(isAdult); }, [setIsAdult, isAdult]); - const { data, isLoading } = useEpisodes(mediaFragment); + const { data, isLoading, refetch } = useEpisodes( + mediaFragment, + setEpisodeFetchingStatus + ); if (isLoading) { return ( + + {!episodeFetchingStatus.isError && ( + + {episodeFetchingStatus.status} + + )} ); } if (!data?.length) { - return ; + return ( + + ); } return ( diff --git a/src/screens/anime/watch/components/player-container.tsx b/src/screens/anime/watch/components/player-container.tsx index 808b2e4..ad7a3a6 100644 --- a/src/screens/anime/watch/components/player-container.tsx +++ b/src/screens/anime/watch/components/player-container.tsx @@ -105,6 +105,10 @@ const PlayerContainer: React.FC = ({ {isLoading || isServerLoading ? ( + + + {isLoading ? 'Loading video...' : 'Loading server...'} + ) : null} diff --git a/src/screens/anime/watch/components/servers-container.tsx b/src/screens/anime/watch/components/servers-container.tsx index 3a675fa..0bb6504 100644 --- a/src/screens/anime/watch/components/servers-container.tsx +++ b/src/screens/anime/watch/components/servers-container.tsx @@ -18,8 +18,11 @@ const ServersContainer: React.FC = ({ const setServers = useSetAtom(serversAtom); const [currentServer, setCurrentServer] = useAtom(currentServerAtom); - const { data: videoServers, isLoading: videoServersLoading } = - useLoadVideoServers(currentEpisode.id, currentEpisode.extra || undefined); + const { + data: videoServers, + isLoading: videoServersLoading, + refetch, + } = useLoadVideoServers(currentEpisode.id, currentEpisode.extra || undefined); useEffect(() => { if (!videoServers?.length) { @@ -35,7 +38,10 @@ const ServersContainer: React.FC = ({ if (!videoServers?.length && !videoServersLoading) return ( - + ); return ( From dd6b258a5f7ad45431342d42d8727fcb9663cee4 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Sat, 3 Feb 2024 17:42:18 +0700 Subject: [PATCH 2/3] fix(media-unit-stats): handle not yet released media (#48) --- src/gql/gql.ts | 6 +++--- src/gql/graphql.ts | 17 +++++++++++++++++ src/ui/media-unit-stats.tsx | 10 ++++++++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/gql/gql.ts b/src/gql/gql.ts index 800e026..547423d 100644 --- a/src/gql/gql.ts +++ b/src/gql/gql.ts @@ -87,7 +87,7 @@ const documents = { types.CardMediaFragmentDoc, '\n fragment CharacterCard on CharacterEdge {\n node {\n id\n name {\n userPreferred\n }\n image {\n large\n }\n }\n role\n }\n': types.CharacterCardFragmentDoc, - '\n fragment MediaUnitStatsMedia on Media {\n type\n episodes\n chapters\n mediaListEntry {\n progress\n }\n nextAiringEpisode {\n episode\n }\n }\n': + '\n fragment MediaUnitStatsMedia on Media {\n type\n episodes\n chapters\n mediaListEntry {\n progress\n }\n nextAiringEpisode {\n episode\n }\n status\n }\n': types.MediaUnitStatsMediaFragmentDoc, '\n fragment StaffCard on StaffEdge {\n node {\n id\n name {\n userPreferred\n }\n image {\n large\n }\n }\n role\n }\n': types.StaffCardFragmentDoc, @@ -333,8 +333,8 @@ export function graphql( * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql( - source: '\n fragment MediaUnitStatsMedia on Media {\n type\n episodes\n chapters\n mediaListEntry {\n progress\n }\n nextAiringEpisode {\n episode\n }\n }\n' -): (typeof documents)['\n fragment MediaUnitStatsMedia on Media {\n type\n episodes\n chapters\n mediaListEntry {\n progress\n }\n nextAiringEpisode {\n episode\n }\n }\n']; + source: '\n fragment MediaUnitStatsMedia on Media {\n type\n episodes\n chapters\n mediaListEntry {\n progress\n }\n nextAiringEpisode {\n episode\n }\n status\n }\n' +): (typeof documents)['\n fragment MediaUnitStatsMedia on Media {\n type\n episodes\n chapters\n mediaListEntry {\n progress\n }\n nextAiringEpisode {\n episode\n }\n status\n }\n']; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/gql/graphql.ts b/src/gql/graphql.ts index c035325..9910548 100644 --- a/src/gql/graphql.ts +++ b/src/gql/graphql.ts @@ -5402,6 +5402,7 @@ export type MediaUnitStatsMediaFragment = { type?: MediaType | null; episodes?: number | null; chapters?: number | null; + status?: MediaStatus | null; mediaListEntry?: { __typename?: 'MediaList'; progress?: number | null; @@ -5456,6 +5457,7 @@ export const MediaUnitStatsMediaFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -5539,6 +5541,7 @@ export const WatchCardFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -6027,6 +6030,7 @@ export const CardMediaFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -6095,6 +6099,7 @@ export const RelationListMediaFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -6213,6 +6218,7 @@ export const RecommendationListMediaFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -6801,6 +6807,7 @@ export const InfoScreenMediaFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -7615,6 +7622,7 @@ export const SearchLayoutContainerFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -7771,6 +7779,7 @@ export const BannerCardMediaFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -7931,6 +7940,7 @@ export const WatchedListDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -8261,6 +8271,7 @@ export const AuthWatchedListDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -9050,6 +9061,7 @@ export const AiringScheduleDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -9241,6 +9253,7 @@ export const PopularThisSeasonDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -9435,6 +9448,7 @@ export const UpcomingNextSeasonDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -9742,6 +9756,7 @@ export const InfoDetailsScreenDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -12299,6 +12314,7 @@ export const MediaDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, @@ -12505,6 +12521,7 @@ export const BannerCardDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, ], }, }, diff --git a/src/ui/media-unit-stats.tsx b/src/ui/media-unit-stats.tsx index d11cc12..a4f5e59 100644 --- a/src/ui/media-unit-stats.tsx +++ b/src/ui/media-unit-stats.tsx @@ -3,7 +3,7 @@ import { twMerge } from 'tailwind-merge'; import type { FragmentType } from '@/gql'; import { graphql, useFragment } from '@/gql'; -import { MediaType } from '@/gql/graphql'; +import { MediaStatus, MediaType } from '@/gql/graphql'; import type { TextProps } from './core'; import { Text } from './core'; @@ -19,6 +19,7 @@ export const MediaUnitStatsFragment = graphql(` nextAiringEpisode { episode } + status } `); @@ -61,7 +62,12 @@ const MediaUnitStats: React.FC< {media.mediaListEntry?.progress && ' | '} - {releasedEpisode ?? totalMediaUnit} {'| '} + + {media.status === MediaStatus.Finished + ? totalMediaUnit + : releasedEpisode ?? '??'} + + {' | '} {totalMediaUnit ? ( From 2c26dbcd9421f008105d98f386772dce51206ee5 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Sat, 3 Feb 2024 17:42:36 +0700 Subject: [PATCH 3/3] feat(media-player): automatically next episode when video ended (#49) --- .../anime/watch/components/media-player.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/screens/anime/watch/components/media-player.tsx b/src/screens/anime/watch/components/media-player.tsx index 1669d1c..59c1876 100644 --- a/src/screens/anime/watch/components/media-player.tsx +++ b/src/screens/anime/watch/components/media-player.tsx @@ -1,3 +1,4 @@ +import { useNavigation } from '@react-navigation/native'; import { useAtom, useAtomValue, useSetAtom } from 'jotai/react'; import { styled } from 'nativewind'; import React, { @@ -42,6 +43,7 @@ import { playerAtom, playerResizeMode, qualityListAtom, + sectionEpisodesAtom, sourceListAtom, videoSizeAtom, volumeAtom, @@ -75,6 +77,9 @@ const MediaPlayer: React.FC = ({ ...props }) => { const playerRef = useRef(null); + + const navigation = useNavigation(); + const setPlayer = useSetAtom(playerAtom); const resizeMode = useAtomValue(playerResizeMode); @@ -88,6 +93,7 @@ const MediaPlayer: React.FC = ({ const shouldSyncAdult = useAtomValue(shouldSyncAdultAtom); const currentEpisode = useAtomValue(currentEpisodeAtom); + const sectionEpisodes = useAtomValue(sectionEpisodesAtom); const mediaId = useAtomValue(mediaIdAtom); const [currentSource, setCurrentSource] = useAtom(currentSourceAtom); @@ -244,6 +250,20 @@ const MediaPlayer: React.FC = ({ [setIsBuffering] ); + const handleVideoEnded = useCallback(() => { + if (!currentEpisode) return; + + const currentEpisodeIndex = sectionEpisodes.findIndex( + (episode) => episode.id === currentEpisode.id + ); + + const nextEpisode = sectionEpisodes[currentEpisodeIndex + 1]; + + if (!nextEpisode) return; + + navigation.setParams({ episodeId: nextEpisode.id }); + }, [currentEpisode, navigation, sectionEpisodes]); + useEffect(() => { if (!videos?.length) return; @@ -424,6 +444,7 @@ const MediaPlayer: React.FC = ({ return (