diff --git a/src/gql/gql.ts b/src/gql/gql.ts index 9aec6e6..770d829 100644 --- a/src/gql/gql.ts +++ b/src/gql/gql.ts @@ -59,7 +59,7 @@ const documents = { types.TagListMediaFragmentDoc, '\n fragment InfoScreenMedia on Media {\n relations {\n edges {\n ...SpecialRelationListMedia\n ...RelationListMedia\n }\n }\n recommendations(page: 1, perPage: 15) {\n ...RecommendationListMedia\n }\n characters {\n ...CharacterListMedia\n }\n staff {\n ...StaffListMedia\n }\n tags {\n ...TagListMedia\n }\n description\n trailer {\n id\n site\n }\n synonyms\n ...InfoSectionMedia\n }\n': types.InfoScreenMediaFragmentDoc, - '\n query AnimeWatchScreenQuery($mediaId: Int!) {\n Media(id: $mediaId) {\n title {\n userPreferred\n }\n idMal\n ...UseAnimeEpisode\n }\n }\n': + '\n query AnimeWatchScreenQuery($mediaId: Int!) {\n Media(id: $mediaId) {\n title {\n userPreferred\n }\n isAdult\n idMal\n ...UseAnimeEpisode\n }\n }\n': types.AnimeWatchScreenQueryDocument, '\n fragment DetailsCard on Media {\n id\n title {\n userPreferred\n }\n genres\n averageScore\n favourites\n coverImage {\n large\n }\n }\n': types.DetailsCardFragmentDoc, @@ -237,8 +237,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 query AnimeWatchScreenQuery($mediaId: Int!) {\n Media(id: $mediaId) {\n title {\n userPreferred\n }\n idMal\n ...UseAnimeEpisode\n }\n }\n' -): (typeof documents)['\n query AnimeWatchScreenQuery($mediaId: Int!) {\n Media(id: $mediaId) {\n title {\n userPreferred\n }\n idMal\n ...UseAnimeEpisode\n }\n }\n']; + source: '\n query AnimeWatchScreenQuery($mediaId: Int!) {\n Media(id: $mediaId) {\n title {\n userPreferred\n }\n isAdult\n idMal\n ...UseAnimeEpisode\n }\n }\n' +): (typeof documents)['\n query AnimeWatchScreenQuery($mediaId: Int!) {\n Media(id: $mediaId) {\n title {\n userPreferred\n }\n isAdult\n idMal\n ...UseAnimeEpisode\n }\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 5f0a09f..a76fbc9 100644 --- a/src/gql/graphql.ts +++ b/src/gql/graphql.ts @@ -5018,6 +5018,7 @@ export type AnimeWatchScreenQueryQuery = { Media?: | ({ __typename?: 'Media'; + isAdult?: boolean | null; idMal?: number | null; title?: { __typename?: 'MediaTitle'; @@ -9720,6 +9721,7 @@ export const AnimeWatchScreenQueryDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'isAdult' } }, { kind: 'Field', name: { kind: 'Name', value: 'idMal' } }, { kind: 'FragmentSpread', diff --git a/src/screens/anime/watch/components/media-container.tsx b/src/screens/anime/watch/components/media-container.tsx index 365ab8c..b0d6c9c 100644 --- a/src/screens/anime/watch/components/media-container.tsx +++ b/src/screens/anime/watch/components/media-container.tsx @@ -7,7 +7,7 @@ import colors from '@/ui/theme/colors'; import type { useAnimeEpisodeFragment } from '../../details/screens/episode-screen/hooks/use-episodes'; import useEpisodes from '../../details/screens/episode-screen/hooks/use-episodes'; -import { mediaIdAtom } from '../store'; +import { isAdultAtom, mediaIdAtom } from '../store'; import EpisodesContainer from './episodes-container'; import ErrorMessage from './error-message'; @@ -16,6 +16,7 @@ interface MediaContainerProps { currentEpisodeId: string; anilistId: number; malId: number | undefined; + isAdult: boolean; } const MediaContainer: React.FC = ({ @@ -23,8 +24,10 @@ const MediaContainer: React.FC = ({ currentEpisodeId, anilistId, malId, + isAdult, }) => { const setMediaId = useSetAtom(mediaIdAtom); + const setIsAdult = useSetAtom(isAdultAtom); useEffect(() => { setMediaId({ @@ -33,6 +36,10 @@ const MediaContainer: React.FC = ({ }); }, [setMediaId, anilistId, malId]); + useEffect(() => { + setIsAdult(isAdult); + }, [setIsAdult, isAdult]); + const { data, isLoading } = useEpisodes(mediaFragment); if (isLoading) { diff --git a/src/screens/anime/watch/components/media-player.tsx b/src/screens/anime/watch/components/media-player.tsx index 8e28d9e..c9bb9b7 100644 --- a/src/screens/anime/watch/components/media-player.tsx +++ b/src/screens/anime/watch/components/media-player.tsx @@ -18,6 +18,10 @@ import RNVideo from 'react-native-video'; import { VideoFormat } from '@/core/video'; import providers from '@/providers'; +import { + shouldSyncAdultAtom, + syncPercentageAtom, +} from '@/screens/settings/store'; import { getWatchedEpisode, markEpisodeAsWatched } from '@/storage/episode'; import type { ProviderType } from '@/storage/provider'; import { getProviders } from '@/storage/provider'; @@ -29,6 +33,7 @@ import { currentSourceAtom, currentTimeAtom, durationAtom, + isAdultAtom, isBufferingAtom, mediaIdAtom, pausedAtom, @@ -78,6 +83,8 @@ const MediaPlayer: React.FC = ({ const setQualityList = useSetAtom(qualityListAtom); const setVideoSize = useSetAtom(videoSizeAtom); + const shouldSyncAdult = useAtomValue(shouldSyncAdultAtom); + const currentEpisode = useAtomValue(currentEpisodeAtom); const mediaId = useAtomValue(mediaIdAtom); @@ -93,6 +100,8 @@ const MediaPlayer: React.FC = ({ const setIsBuffering = useSetAtom(isBufferingAtom); const playBackRate = useAtomValue(playBackRateAtom); const volume = useAtomValue(volumeAtom); + const isAdult = useAtomValue(isAdultAtom); + const syncPercentage = useAtomValue(syncPercentageAtom); const showBufferingTimeout = useRef(null); const shouldMaintainTime = useRef(false); @@ -116,6 +125,7 @@ const MediaPlayer: React.FC = ({ const handleSync = useCallback( (currentTime: number) => { if (!shouldSync) return; + if (isAdult && !shouldSyncAdult) return; if (hasSyncProviders.current) return; @@ -129,7 +139,7 @@ const MediaPlayer: React.FC = ({ if (duration < 10) return; - if (currentTime >= duration * 0.75) { + if (currentTime >= duration * syncPercentage ?? 0.75) { hasSyncProviders.current = true; const storageProviders = getProviders(); @@ -151,7 +161,15 @@ const MediaPlayer: React.FC = ({ }); } }, - [currentEpisode?.number, duration, mediaId, shouldSync] + [ + currentEpisode?.number, + duration, + isAdult, + mediaId, + shouldSync, + shouldSyncAdult, + syncPercentage, + ] ); const handleProgress = useCallback( diff --git a/src/screens/anime/watch/components/player-container.tsx b/src/screens/anime/watch/components/player-container.tsx index a4e8762..808b2e4 100644 --- a/src/screens/anime/watch/components/player-container.tsx +++ b/src/screens/anime/watch/components/player-container.tsx @@ -2,6 +2,7 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai/react'; import React, { useEffect } from 'react'; import Modal from 'react-native-modal'; +import { shouldAskForSyncingAtom } from '@/screens/settings/store'; import type { VideoServer } from '@/types'; import { ActivityIndicator, Button, colors, Text, View } from '@/ui'; @@ -39,6 +40,8 @@ const PlayerContainer: React.FC = ({ ); }); + const shouldAskForSyncingSetting = useAtomValue(shouldAskForSyncingAtom); + const setTimestamps = useSetAtom(timestampsAtom); useEffect(() => { @@ -59,7 +62,7 @@ const PlayerContainer: React.FC = ({ setShouldAskForSyncing(shouldAsk); }, [mediaId.anilistId, shouldNotSyncList, shouldSyncList]); - if (shouldAskForSyncing) { + if (shouldAskForSyncing && shouldAskForSyncingSetting) { return ( @@ -109,7 +112,11 @@ const PlayerContainer: React.FC = ({ {container?.videos?.length && !isLoading ? ( ) : null} diff --git a/src/screens/anime/watch/screen.tsx b/src/screens/anime/watch/screen.tsx index 91609a4..e47774a 100644 --- a/src/screens/anime/watch/screen.tsx +++ b/src/screens/anime/watch/screen.tsx @@ -20,6 +20,7 @@ const document = graphql(` title { userPreferred } + isAdult idMal ...UseAnimeEpisode } @@ -85,6 +86,7 @@ export const AnimeWatchScreen: React.FC = ({ route }) => { currentEpisodeId={episodeId} mediaFragment={media.Media} anilistId={mediaId} + isAdult={media.Media.isAdult || false} /> ); }; diff --git a/src/screens/anime/watch/store.ts b/src/screens/anime/watch/store.ts index 7c3702c..5680038 100644 --- a/src/screens/anime/watch/store.ts +++ b/src/screens/anime/watch/store.ts @@ -129,6 +129,7 @@ export const mediaIdAtom = atom<{ anilistId: 0, malId: 0, }); +export const isAdultAtom = atom(false); export const timestampsAtom = atom([]); diff --git a/src/screens/settings/components/player-settings.tsx b/src/screens/settings/components/player-settings.tsx new file mode 100644 index 0000000..6e3cc21 --- /dev/null +++ b/src/screens/settings/components/player-settings.tsx @@ -0,0 +1,138 @@ +import { useAtom, useAtomValue } from 'jotai/react'; +import { MinusIcon, PlusIcon } from 'lucide-react-native'; +import React, { memo, useState } from 'react'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated'; + +import { Button, Text, View } from '@/ui'; +import Switch from '@/ui/core/switch'; + +import { + shouldAskForSyncingAtom, + shouldSyncAdultAtom, + syncPercentageAtom, +} from '../store'; + +const PlayerSettings = () => { + const [shouldAskForSyncing, setShouldAskForSyncing] = useAtom( + shouldAskForSyncingAtom + ); + const [shouldSyncAdult, setShouldSyncAdult] = useAtom(shouldSyncAdultAtom); + const syncPercentage = useAtomValue(syncPercentageAtom); + + return ( + + + Player + + + + + Should ask for syncing + + + + + Should sync adult + + + + + + Sync percentage ({Math.floor(syncPercentage * 100)}%) + + + + + + + ); +}; + +const AnimatedView = Animated.createAnimatedComponent(View); + +const clamp = (value: number, lowerBound: number, upperBound: number) => { + 'worklet'; + + return Math.min(Math.max(lowerBound, value), upperBound); +}; + +export const SyncPercentageSlider = memo(() => { + const [syncPercentage, setSyncPercentage] = useAtom(syncPercentageAtom); + const animateValue = useSharedValue(syncPercentage); + const [containerWidth, setContainerWidth] = useState(0); + + const gesture = Gesture.Pan() + .onStart((e) => { + const x = e.x; + + animateValue.value = clamp(x / containerWidth, 0, 1); + }) + .onUpdate((e) => { + const x = e.x; + + animateValue.value = clamp(x / containerWidth, 0, 1); + }) + .onFinalize((e) => { + const x = e.x; + + const value = x / containerWidth; + + runOnJS(setSyncPercentage)(clamp(value, 0, 1)); + }); + + const styles = useAnimatedStyle(() => { + return { + width: `${animateValue.value * 100}%`, + }; + }); + + return ( + + + { + setContainerWidth(event.nativeEvent.layout.width); + }} + className="relative flex h-10 grow flex-row items-center rounded-md border border-neutral-400" + > + + 0% + 100% + + + + + + + + ); +}); + +export default PlayerSettings; diff --git a/src/screens/settings/screen.tsx b/src/screens/settings/screen.tsx index aee645c..0d8c2c0 100644 --- a/src/screens/settings/screen.tsx +++ b/src/screens/settings/screen.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Text, View } from '@/ui'; import AccountSettings from './components/account-settings'; +import PlayerSettings from './components/player-settings'; const SettingsScreen = () => { return ( @@ -11,8 +12,14 @@ const SettingsScreen = () => { Settings - - + + + + + + + + ); diff --git a/src/screens/settings/store.ts b/src/screens/settings/store.ts new file mode 100644 index 0000000..eff843b --- /dev/null +++ b/src/screens/settings/store.ts @@ -0,0 +1,14 @@ +import { atomWithMMKV } from '@/core/storage'; + +export const shouldAskForSyncingAtom = atomWithMMKV( + 'player_settings__should-ask-for-syncing', + true +); +export const shouldSyncAdultAtom = atomWithMMKV( + 'player_settings__should-sync-adult', + false +); +export const syncPercentageAtom = atomWithMMKV( + 'player_settings__sync-percentage', + 0.75 +); diff --git a/src/ui/core/switch.tsx b/src/ui/core/switch.tsx new file mode 100644 index 0000000..556624e --- /dev/null +++ b/src/ui/core/switch.tsx @@ -0,0 +1,21 @@ +import { styled } from 'nativewind'; +import React from 'react'; +import { Switch as RNSwitch } from 'react-native'; + +import colors from '../theme/colors'; + +const SSwitch = styled(RNSwitch); + +type SwitchProps = React.ComponentProps; + +const Switch: React.FC = (props) => { + return ( + + ); +}; + +export default Switch;