mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-22 00:34:43 +01:00
fix
This commit is contained in:
@@ -23,7 +23,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
queryKey: ["backdrop", item.Id],
|
||||
queryFn: async () => getBackdrop(api, item),
|
||||
enabled: !!api && !!item.Id,
|
||||
staleTime: Infinity,
|
||||
staleTime: 60 * 60 * 24 * 7,
|
||||
});
|
||||
|
||||
const [progress, setProgress] = useState(
|
||||
|
||||
@@ -1,182 +1,50 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { getPlaybackInfo, useDownloadMedia } from "@/utils/jellyfin";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { router } from "expo-router";
|
||||
import { getPlaybackInfo, useDownloadMedia } from "@/utils/jellyfin";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { ProcessItem, runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { Text } from "./common/Text";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
type DownloadProps = {
|
||||
item: BaseItemDto;
|
||||
};
|
||||
|
||||
// const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => {
|
||||
// if (!item.Id || !item.Name) {
|
||||
// writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments", {
|
||||
// item,
|
||||
// inputUrl,
|
||||
// });
|
||||
// throw new Error("Item must have an Id and Name");
|
||||
// }
|
||||
|
||||
// const [session, setSession] = useAtom<ProcessItem | null>(runningProcesses);
|
||||
|
||||
// const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||
|
||||
// const command = `-y -fflags +genpts -i ${inputUrl} -c copy -max_muxing_queue_size 9999 ${output}`;
|
||||
|
||||
// const startRemuxing = useCallback(async () => {
|
||||
// if (!item.Id || !item.Name) {
|
||||
// writeToLog(
|
||||
// "ERROR",
|
||||
// "useRemuxHlsToMp4 ~ startRemuxing ~ missing arguments",
|
||||
// {
|
||||
// item,
|
||||
// inputUrl,
|
||||
// }
|
||||
// );
|
||||
// throw new Error("Item must have an Id and Name");
|
||||
// }
|
||||
|
||||
// writeToLog(
|
||||
// "INFO",
|
||||
// `useRemuxHlsToMp4 ~ startRemuxing for item ${item.Id} with url ${inputUrl}`,
|
||||
// {
|
||||
// item,
|
||||
// inputUrl,
|
||||
// }
|
||||
// );
|
||||
|
||||
// try {
|
||||
// setSession({
|
||||
// item,
|
||||
// progress: 0,
|
||||
// });
|
||||
|
||||
// FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
// let percentage = 0;
|
||||
|
||||
// const videoLength =
|
||||
// (item.MediaSources?.[0].RunTimeTicks || 0) / 10000000; // In seconds
|
||||
// const fps = item.MediaStreams?.[0].RealFrameRate || 25;
|
||||
// const totalFrames = videoLength * fps;
|
||||
|
||||
// const processedFrames = statistics.getVideoFrameNumber();
|
||||
|
||||
// if (totalFrames > 0) {
|
||||
// percentage = Math.floor((processedFrames / totalFrames) * 100);
|
||||
// }
|
||||
|
||||
// setSession((prev) => {
|
||||
// return prev?.item.Id === item.Id!
|
||||
// ? { ...prev, progress: percentage }
|
||||
// : prev;
|
||||
// });
|
||||
// });
|
||||
|
||||
// await FFmpegKit.executeAsync(command, async (session) => {
|
||||
// const returnCode = await session.getReturnCode();
|
||||
// if (returnCode.isValueSuccess()) {
|
||||
// const currentFiles: BaseItemDto[] = JSON.parse(
|
||||
// (await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
// );
|
||||
|
||||
// const otherItems = currentFiles.filter((i) => i.Id !== item.Id);
|
||||
|
||||
// await AsyncStorage.setItem(
|
||||
// "downloaded_files",
|
||||
// JSON.stringify([...otherItems, item])
|
||||
// );
|
||||
|
||||
// writeToLog(
|
||||
// "INFO",
|
||||
// `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
|
||||
// {
|
||||
// item,
|
||||
// inputUrl,
|
||||
// }
|
||||
// );
|
||||
// setSession(null);
|
||||
// } else if (returnCode.isValueError()) {
|
||||
// console.error("Failed to remux:");
|
||||
// writeToLog(
|
||||
// "ERROR",
|
||||
// `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||
// {
|
||||
// item,
|
||||
// inputUrl,
|
||||
// }
|
||||
// );
|
||||
// setSession(null);
|
||||
// } else if (returnCode.isValueCancel()) {
|
||||
// console.log("Remuxing was cancelled");
|
||||
// writeToLog(
|
||||
// "INFO",
|
||||
// `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
|
||||
// {
|
||||
// item,
|
||||
// inputUrl,
|
||||
// }
|
||||
// );
|
||||
// setSession(null);
|
||||
// }
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Failed to remux:", error);
|
||||
// writeToLog(
|
||||
// "ERROR",
|
||||
// `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||
// {
|
||||
// item,
|
||||
// inputUrl,
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
// }, [inputUrl, output, item, command]);
|
||||
|
||||
// const cancelRemuxing = useCallback(async () => {
|
||||
// FFmpegKit.cancel();
|
||||
// setSession(null);
|
||||
// console.log("Remuxing cancelled");
|
||||
// }, []);
|
||||
|
||||
// return { session, startRemuxing, cancelRemuxing };
|
||||
// };
|
||||
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
||||
// const { session, startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(
|
||||
// url,
|
||||
// item
|
||||
// );
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [process] = useAtom(runningProcesses);
|
||||
|
||||
const { downloadMedia, isDownloading, error } = useDownloadMedia(api);
|
||||
const { downloadMedia, isDownloading, error, cancelDownload } =
|
||||
useDownloadMedia(api, user?.Id);
|
||||
|
||||
const { data: playbackInfo, isLoading } = useQuery({
|
||||
queryKey: ["playbackInfo", item.Id],
|
||||
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
||||
});
|
||||
|
||||
const downloadFile = useCallback(async () => {
|
||||
const playbackInfo = await getPlaybackInfo(api, item.Id, user?.Id);
|
||||
if (!playbackInfo) return;
|
||||
|
||||
const source = playbackInfo?.MediaSources?.[0];
|
||||
const source = playbackInfo.MediaSources?.[0];
|
||||
|
||||
if (source?.SupportsDirectPlay && item.CanDownload) {
|
||||
downloadMedia(item);
|
||||
} else {
|
||||
console.log("file not supported");
|
||||
throw new Error(
|
||||
"Direct play not supported thus the file cannot be downloaded"
|
||||
);
|
||||
}
|
||||
}, [item, user]);
|
||||
}, [item, user, playbackInfo]);
|
||||
|
||||
const [downloaded, setDownloaded] = useState<boolean>(false);
|
||||
const [key, setKey] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -186,7 +54,19 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
||||
|
||||
if (data.find((d) => d.Id === item.Id)) setDownloaded(true);
|
||||
})();
|
||||
}, [key]);
|
||||
}, [process]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ActivityIndicator size={"small"} color={"white"} />;
|
||||
}
|
||||
|
||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
||||
return (
|
||||
<View style={{ opacity: 0.5 }}>
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (process && process.item.Id !== item.Id!) {
|
||||
return (
|
||||
@@ -201,17 +81,22 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
||||
{process ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// cancelRemuxing();
|
||||
cancelDownload();
|
||||
}}
|
||||
className="-rotate-45"
|
||||
className="relative"
|
||||
>
|
||||
<ProgressCircle
|
||||
size={22}
|
||||
fill={process.progress}
|
||||
width={3}
|
||||
tintColor="#3498db"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={26}
|
||||
fill={process.progress}
|
||||
width={3}
|
||||
tintColor="#3498db"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
</View>
|
||||
<View className="absolute top-0 left-0 font-bold w-full h-full flex flex-col items-center justify-center">
|
||||
<Text className="text-[6px]">{process.progress.toFixed(0)}%</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : downloaded ? (
|
||||
<TouchableOpacity
|
||||
@@ -221,7 +106,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download" size={28} color="#16a34a" />
|
||||
<Ionicons name="cloud-download" size={26} color="#16a34a" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
@@ -229,7 +114,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
||||
downloadFile();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download-outline" size={28} color="white" />
|
||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient, InvalidateQueryFilters } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
@@ -14,6 +14,29 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateQueries = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
refetchType: "all",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["resumeItems", user?.Id],
|
||||
refetchType: "all",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp", item.SeriesId],
|
||||
refetchType: "all",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["episodes"],
|
||||
refetchType: "all",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["seasons"],
|
||||
refetchType: "all",
|
||||
});
|
||||
}, [api, item.Id, queryClient, user?.Id]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{item.UserData?.Played ? (
|
||||
@@ -24,11 +47,8 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
refetchType: "all",
|
||||
});
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark-circle" size={30} color="white" />
|
||||
@@ -41,11 +61,8 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
refetchType: "all",
|
||||
});
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark-circle-outline" size={30} color="white" />
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Switch,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
getStreamUrl,
|
||||
getUserItemData,
|
||||
reportPlaybackProgress,
|
||||
reportPlaybackStopped,
|
||||
} from "@/utils/jellyfin";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import Video, {
|
||||
OnBufferData,
|
||||
OnPlaybackStateChangedData,
|
||||
@@ -16,17 +19,8 @@ import Video, {
|
||||
OnVideoErrorData,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
getBackdrop,
|
||||
getStreamUrl,
|
||||
getUserItemData,
|
||||
reportPlaybackProgress,
|
||||
reportPlaybackStopped,
|
||||
} from "@/utils/jellyfin";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Button } from "./Button";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
@@ -36,11 +30,7 @@ type VideoPlayerProps = {
|
||||
const BITRATES = [
|
||||
{
|
||||
key: "Max",
|
||||
value: 140000000,
|
||||
},
|
||||
{
|
||||
key: "10 Mb/s",
|
||||
value: 10000000,
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
@@ -50,10 +40,6 @@ const BITRATES = [
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "1 Mb/s",
|
||||
value: 1000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
@@ -62,12 +48,8 @@ const BITRATES = [
|
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const [showPoster, setShowPoster] = useState(true);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [buffering, setBuffering] = useState(false);
|
||||
const [maxBitrate, setMaxbitrate] = useState(140000000);
|
||||
const [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const [forceTranscoding, setForceTranscoding] = useState<boolean>(false);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -81,7 +63,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
itemId,
|
||||
}),
|
||||
enabled: !!itemId && !!api,
|
||||
staleTime: 0,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
@@ -182,7 +164,9 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
|
||||
return (
|
||||
<View>
|
||||
{playbackURL && (
|
||||
{enableVideo === true &&
|
||||
playbackURL !== null &&
|
||||
playbackURL !== undefined ? (
|
||||
<Video
|
||||
style={{ width: 0, height: 0 }}
|
||||
source={{
|
||||
@@ -190,6 +174,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
}}
|
||||
debug={{
|
||||
enable: true,
|
||||
thread: true,
|
||||
}}
|
||||
ref={videoRef}
|
||||
onBuffer={onBuffer}
|
||||
onSeek={(t) => onSeek(t)}
|
||||
@@ -216,7 +204,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
backBufferDurationMs: 30 * 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
@@ -241,9 +229,9 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{BITRATES?.map((b: any) => (
|
||||
{BITRATES?.map((b: any, index: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={b.value}
|
||||
key={index.toString()}
|
||||
onSelect={() => {
|
||||
setMaxbitrate(b.value);
|
||||
}}
|
||||
|
||||
@@ -8,7 +8,7 @@ import Poster from "../Poster";
|
||||
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Cast & Crew</Text>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||
<HorizontalScroll<NonNullable<BaseItemDto["People"]>[number]>
|
||||
data={item.People}
|
||||
renderItem={(item, index) => (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Text } from "../common/Text";
|
||||
export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Series</Text>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={[item]}
|
||||
renderItem={(item, index) => (
|
||||
|
||||
@@ -7,8 +7,27 @@ import Poster from "../Poster";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { router } from "expo-router";
|
||||
import { nextUp } from "@/utils/jellyfin";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const { data: items } = useQuery({
|
||||
queryKey: ["nextUp", seriesId],
|
||||
queryFn: async () =>
|
||||
await nextUp({
|
||||
userId: user?.Id,
|
||||
api,
|
||||
itemId: seriesId,
|
||||
}),
|
||||
enabled: !!api && !!seriesId && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
export const NextUp = ({ items }: { items?: BaseItemDto[] | null }) => {
|
||||
if (!items?.length)
|
||||
return (
|
||||
<View>
|
||||
@@ -19,7 +38,7 @@ export const NextUp = ({ items }: { items?: BaseItemDto[] | null }) => {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Next up</Text>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={items}
|
||||
renderItem={(item, index) => (
|
||||
|
||||
@@ -84,7 +84,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
<View className="mb-2">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-row">
|
||||
<View className="flex flex-row px-4">
|
||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>Season {selectedSeason}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
Reference in New Issue
Block a user