wip: refactoring file structure

This commit is contained in:
Fredrik Burmester
2024-08-15 21:08:35 +02:00
parent d0baf56fd8
commit 1f0ff1594b
26 changed files with 1660 additions and 41 deletions

View File

@@ -22,12 +22,7 @@
"UIBackgroundModes": ["audio"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true,
"NSExceptionDomains": {
"*": {
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
"NSAllowsArbitraryLoads": true
}
},
"supportsTablet": true,

View File

@@ -90,8 +90,6 @@ export default function index() {
})
).data;
console.log("Collections", JSON.stringify(data.Items));
const order = ["boxsets", "tvshows", "movies"];
const cs = data.Items?.sort((a, b) => {

View File

@@ -109,7 +109,7 @@ export default function search() {
<TouchableOpacity
key={item.Id}
className="flex flex-col w-32"
onPress={() => router.push(`/items/${item.Id}/page`)}
onPress={() => router.push(`/items/${item.Id}`)}
>
<MoviePoster item={item} key={item.Id} />
<Text className="mt-2">{item.Name}</Text>
@@ -130,7 +130,7 @@ export default function search() {
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.Id}/page`)}
onPress={() => router.push(`/series/${item.Id}`)}
className="flex flex-col w-32"
>
<Poster
@@ -156,7 +156,7 @@ export default function search() {
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}/page`)}
onPress={() => router.push(`/items/${item.Id}`)}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />

View File

@@ -0,0 +1,133 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import { SongsList } from "@/components/music/SongsList";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
BaseItemKind,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getArtistsApi,
getItemsApi,
getUserApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId, artistId, albumId } = searchParams as {
collectionId: string;
artistId: string;
albumId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [startIndex, setStartIndex] = useState<number>(0);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const { data: album } = useQuery({
queryKey: ["album", albumId, artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [albumId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!albumId,
staleTime: 0,
});
const {
data: songs,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["songs", artistId, albumId],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: albumId,
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
],
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
if (!album) return null;
return (
<ScrollView>
<View className="px-4 pb-24">
<View className="flex flex-row space-x-4 items-start mb-4">
<View className="w-24">
<ArtistPoster item={album} />
</View>
<View className="flex flex-col shrink">
<Text className="font-bold text-3xl">{album?.Name}</Text>
<Text className="">{album?.ProductionYear}</Text>
</View>
</View>
<SongsList
albumId={albumId}
songs={songs?.Items}
collectionId={collectionId}
artistId={artistId}
/>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,131 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { artistId } = searchParams as {
artistId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [startIndex, setStartIndex] = useState<number>(0);
const { data: artist } = useQuery({
queryKey: ["album", artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [artistId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!artistId,
staleTime: 0,
});
const {
data: albums,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["albums", artistId, startIndex],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: artistId,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["MusicAlbum"],
recursive: true,
fields: [
"ParentId",
"PrimaryImageAspectRatio",
"ParentId",
"PrimaryImageAspectRatio",
],
collapseBoxSetItems: false,
albumArtistIds: [artistId],
startIndex,
limit: 100,
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
useEffect(() => {
navigation.setOptions({
title: albums?.Items?.[0].AlbumArtist,
});
}, [albums]);
if (!artist || !albums) return null;
return (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-2">
<View className="w-32 mb-4">
<ArtistPoster item={artist} />
</View>
<Text className="font-bold text-2xl mb-4">Albums</Text>
</View>
}
nestedScrollEnabled
data={albums.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableOpacity
style={{ width: "30%" }}
key={index}
onPress={() => {
router.push(`/albums/${item.Id}`);
}}
>
<View className="flex flex-col gap-y-2">
<ArtistPoster item={item} />
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableOpacity>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

130
app/(auth)/artists/page.tsx Normal file
View File

@@ -0,0 +1,130 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Text } from "@/components/common/Text";
import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
BaseItemKind,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
FlatList,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
const [startIndex, setStartIndex] = useState<number>(0);
const { data, isLoading, isError } = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["collection-items", collection?.Id, startIndex],
queryFn: async () => {
if (!api || !collectionId)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getArtistsApi(api).getArtists({
sortBy: ["SortName"],
sortOrder: ["Ascending"],
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
parentId: collectionId,
userId: user?.Id,
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!collection?.Id && !!api && !!user?.Id,
});
const totalItems = useMemo(() => {
return data?.TotalRecordCount;
}, [data]);
if (!data) return null;
return (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-4">
<Text className="font-bold text-3xl mb-2">Artists</Text>
</View>
}
nestedScrollEnabled
data={data.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableOpacity
style={{
maxWidth: "30%",
width: "100%",
}}
key={index}
onPress={() => {
router.push(`/artists/${item.Id}/page`);
}}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" && (
<MoviePoster item={item} />
)}
{collection?.CollectionType === "music" && (
<ArtistPoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableOpacity>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

View File

@@ -0,0 +1,229 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Text } from "@/components/common/Text";
import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
BaseItemKind,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
const [startIndex, setStartIndex] = useState<number>(0);
const { data, isLoading, isError } = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["collection-items", collection?.Id, startIndex],
queryFn: async () => {
if (!api || !collectionId)
return {
Items: [],
TotalRecordCount: 0,
};
const sortBy: ItemSortBy[] = [];
const includeItemTypes: BaseItemKind[] = [];
console.log("Collection", { collection });
switch (collection?.CollectionType) {
case "movies":
sortBy.push("SortName", "ProductionYear");
break;
case "boxsets":
sortBy.push("IsFolder", "SortName");
break;
default:
sortBy.push("SortName");
break;
}
switch (collection?.CollectionType) {
case "movies":
includeItemTypes.push("Movie");
break;
case "boxsets":
includeItemTypes.push("BoxSet");
break;
case "tvshows":
includeItemTypes.push("Series");
break;
case "music":
includeItemTypes.push("MusicAlbum");
break;
default:
break;
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: 100,
startIndex,
sortBy,
sortOrder: ["Ascending"],
includeItemTypes,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: true,
imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!collection?.Id && !!api && !!user?.Id,
});
const totalItems = useMemo(() => {
return data?.TotalRecordCount;
}, [data]);
return (
<ScrollView>
<View>
<View className="px-4 mb-4">
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
<View className="flex flex-row items-center justify-between">
<Text>
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
{totalItems}
</Text>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => Math.max(prev - 100, 0));
}}
>
<Ionicons
name="arrow-back-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => prev + 100);
}}
>
<Ionicons
name="arrow-forward-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
</View>
</View>
</View>
{isLoading ? (
<View className="my-12">
<ActivityIndicator color={"white"} />
</View>
) : (
<View className="flex flex-row flex-wrap">
{data?.Items?.map((item: BaseItemDto, index: number) => (
<TouchableOpacity
style={{
maxWidth: "33%",
width: "100%",
padding: 10,
}}
key={index}
onPress={() => {
if (item?.Type === "Series") {
router.push(`/series/${item.Id}`);
} else if (item.IsFolder) {
router.push(`/collections/${item?.Id}`);
} else {
router.push(`/items/${item.Id}`);
}
}}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" && (
<MoviePoster item={item} />
)}
{collection?.CollectionType === "music" && (
<ArtistPoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</View>
</TouchableOpacity>
))}
</View>
)}
</View>
{!isLoading && (
<View className="flex flex-row items-center space-x-2 justify-center mt-4 mb-12">
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => Math.max(prev - 100, 0));
}}
>
<Ionicons
name="arrow-back-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => prev + 100);
}}
>
<Ionicons
name="arrow-forward-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
</View>
)}
</ScrollView>
);
};
export default page;

View File

@@ -75,7 +75,7 @@ const downloads: React.FC = () => {
<View className="flex flex-col space-y-2">
{queue.map((q) => (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/${q.item.Id}/page`)}
onPress={() => router.push(`/(auth)/items/${q.item.Id}`)}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
@@ -102,9 +102,7 @@ const downloads: React.FC = () => {
<Text className="text-2xl font-bold mb-2">Active download</Text>
{process?.item ? (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/${process.item.Id}/page`)
}
onPress={() => router.push(`/(auth)/items/${process.item.Id}`)}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>

304
app/(auth)/items/[id].tsx Normal file
View File

@@ -0,0 +1,304 @@
import { Text } from "@/components/common/Text";
import { DownloadItem } from "@/components/DownloadItem";
import { PlayedStatus } from "@/components/PlayedStatus";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SimilarItems } from "@/components/SimilarItems";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { ParallaxScrollView } from "../../../components/ParallaxPage";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { PlayButton } from "@/components/PlayButton";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios12 from "@/utils/profiles/ios12";
import {
currentlyPlayingItemAtom,
triggerPlayAtom,
} from "@/components/CurrentlyPlayingBar";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
import { Ratings } from "@/components/Ratings";
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { OverviewText } from "@/components/OverviewText";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { id } = local as { id: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", id],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: id,
}),
enabled: !!id && !!api,
staleTime: 60,
});
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item],
);
const logoUrl = useMemo(
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
[item],
);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item?.Id],
queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!item?.Id && !!api && !!user?.Id,
staleTime: 0,
});
const { data: playbackUrl } = useQuery({
queryKey: [
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedAudioStream,
selectedSubtitleStream,
],
queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null;
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
});
console.log("Transcode URL: ", url);
return url;
},
enabled: !!sessionData,
staleTime: 0,
});
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const client = useRemoteMediaClient();
const [, setPlayTrigger] = useAtom(triggerPlayAtom);
const onPressPlay = useCallback(
async (type: "device" | "cast" = "device") => {
if (!playbackUrl || !item) return;
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: playbackUrl,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
} else {
setCp({
item,
playbackUrl,
});
// Use this trigger to initiate playback in another component (CurrentlyPlayingBar)
setPlayTrigger((prev) => prev + 1);
}
},
[playbackUrl, item],
);
if (l1)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 pt-4">
<View className="flex flex-col">
{item.Type === "Episode" ? (
<SeriesTitleHeader item={item} />
) : (
<>
<MoviesTitleHeader item={item} />
</>
)}
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
<Ratings item={item} />
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl ? (
<DownloadItem item={item} playbackUrl={playbackUrl} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
<PlayedStatus item={item} />
</View>
<OverviewText text={item.Overview} />
</View>
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<AudioTrackSelector
item={item}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={chromecastReady}
onPress={onPressPlay}
className="grow"
/>
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">
<View className="flex flex-col">
<Text className="text-sm opacity-70">Video</Text>
<Text className="text-sm opacity-70">Audio</Text>
<Text className="text-sm opacity-70">Subtitles</Text>
</View>
<View className="flex flex-col">
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{
item.MediaStreams?.find((i) => i.Type === "Subtitle")
?.DisplayTitle
}
</Text>
</View>
</View>
</ScrollView>
<CastAndCrew item={item} />
{item.Type === "Episode" && (
<View className="mb-4">
<CurrentSeries item={item} />
</View>
)}
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ParallaxScrollView>
);
};
export default page;

110
app/(auth)/series/[id].tsx Normal file
View File

@@ -0,0 +1,110 @@
import { Text } from "@/components/common/Text";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { View } from "react-native";
const page: React.FC = () => {
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
seasonIndex: string;
};
useEffect(() => {
if (seriesId) {
console.log("seasonIndex", seasonIndex);
}
}, [seriesId]);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: item } = useQuery({
queryKey: ["series", seriesId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: seriesId,
}),
enabled: !!seriesId && !!api,
staleTime: 60,
});
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item],
);
const logoUrl = useMemo(
() =>
getLogoImageUrlById({
api,
item,
}),
[item],
);
if (!item || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col pt-4 pb-24">
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>
</View>
<View className="mb-4">
<NextUp seriesId={seriesId} />
</View>
<SeasonPicker item={item} />
</View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -0,0 +1,281 @@
import { Text } from "@/components/common/Text";
import { DownloadItem } from "@/components/DownloadItem";
import { SimilarItems } from "@/components/SimilarItems";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { PlayButton } from "@/components/PlayButton";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios12 from "@/utils/profiles/ios12";
import {
currentlyPlayingItemAtom,
triggerPlayAtom,
} from "@/components/CurrentlyPlayingBar";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Chromecast } from "@/components/Chromecast";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { songId: id } = local as { songId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", id],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: id,
}),
enabled: !!id && !!api,
staleTime: 60,
});
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item],
);
const logoUrl = useMemo(
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
[item],
);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item?.Id],
queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!item?.Id && !!api && !!user?.Id,
staleTime: 0,
});
const { data: playbackUrl } = useQuery({
queryKey: [
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedAudioStream,
selectedSubtitleStream,
],
queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null;
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
});
console.log("Transcode URL: ", url);
return url;
},
enabled: !!sessionData,
staleTime: 0,
});
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const client = useRemoteMediaClient();
const [, setPlayTrigger] = useAtom(triggerPlayAtom);
const onPressPlay = useCallback(
async (type: "device" | "cast" = "device") => {
if (!playbackUrl || !item) return;
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: playbackUrl,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
} else {
setCp({
item,
playbackUrl,
});
// Use this trigger to initiate playback in another component (CurrentlyPlayingBar)
setPlayTrigger((prev) => prev + 1);
}
},
[playbackUrl, item],
);
if (l1)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 pt-4">
<View className="flex flex-col">
<MoviesTitleHeader item={item} />
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl ? (
<DownloadItem item={item} playbackUrl={playbackUrl} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
</View>
</View>
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<AudioTrackSelector
item={item}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={chromecastReady}
onPress={onPressPlay}
className="grow"
/>
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">
<View className="flex flex-col">
<Text className="text-sm opacity-70">Audio</Text>
</View>
<View className="flex flex-col">
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
</Text>
</View>
</View>
</ScrollView>
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -1,10 +1,17 @@
import { Link, Stack } from "expo-router";
import { Link, Stack, usePathname } from "expo-router";
import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
import { useEffect } from "react";
export default function NotFoundScreen() {
const pathname = usePathname();
useEffect(() => {
console.log(`Navigated to ${pathname}`);
}, [pathname]);
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />

View File

@@ -107,14 +107,14 @@ function Layout() {
}}
/>
<Stack.Screen
name="(auth)/items/[id]/page"
name="(auth)/items/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collection]/page"
name="(auth)/collections/[collectionId]"
options={{
title: "",
headerShown: true,
@@ -123,7 +123,41 @@ function Layout() {
}}
/>
<Stack.Screen
name="(auth)/series/[id]/page"
name="(auth)/artists/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/[artistId]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/albums/[albumId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/songs/[songId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]"
options={{
title: "",
headerShown: false,

View File

@@ -0,0 +1,58 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
type ArtistPosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
const ArtistPoster: React.FC<ArtistPosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item,
}),
[item],
);
if (!url)
return (
<View
className="rounded-md overflow-hidden border border-neutral-900"
style={{
aspectRatio: "1/1",
}}
></View>
);
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default ArtistPoster;

View File

@@ -150,7 +150,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
sessionId: sessionData.PlaySessionId,
});
},
[sessionData?.PlaySessionId, item, api, paused],
[sessionData?.PlaySessionId, item, api, paused]
);
const play = () => {
@@ -187,7 +187,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0,
[item],
[item]
);
const backdropUrl = useMemo(
@@ -198,7 +198,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
quality: 70,
width: 200,
}),
[item],
[item]
);
/**
@@ -234,7 +234,9 @@ export const CurrentlyPlayingBar: React.FC = () => {
<BlurView
intensity={Platform.OS === "android" ? 60 : 100}
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
className={`h-full w-full rounded-xl overflow-hidden ${Platform.OS === "android" && "bg-black"}`}
className={`h-full w-full rounded-xl overflow-hidden ${
Platform.OS === "android" && "bg-black"
}`}
>
<Animated.View
style={[
@@ -306,7 +308,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
console.log(e);
writeToLog(
"ERROR",
"Video playback error: " + JSON.stringify(e),
"Video playback error: " + JSON.stringify(e)
);
}}
renderLoader={
@@ -322,27 +324,41 @@ export const CurrentlyPlayingBar: React.FC = () => {
<View className="shrink text-xs">
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/items/${item?.Id}/page`);
console.log(JSON.stringify(item));
if (item?.Type === "Audio")
router.push(`/albums/${item?.AlbumId}`);
else router.push(`/items/${item?.Id}`);
}}
>
<Text>{item?.Name}</Text>
</TouchableOpacity>
{item?.SeriesName ? (
{item?.Type === "Episode" && (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/series/${item.SeriesId}/page`);
router.push(`/(auth)/series/${item.SeriesId}`);
}}
className="text-xs opacity-50"
>
<Text>{item.SeriesName}</Text>
</TouchableOpacity>
) : (
)}
{item?.Type === "Movie" && (
<View>
<Text className="text-xs opacity-50">
{item?.ProductionYear}
</Text>
</View>
)}
{item?.Type === "Audio" && (
<TouchableOpacity
onPress={() => {
console.log(JSON.stringify(item));
router.push(`/albums/${item?.AlbumId}`);
}}
>
<Text className="text-xs opacity-50">{item?.Album}</Text>
</TouchableOpacity>
)}
</View>
</View>
<View className="flex flex-row items-center space-x-2">

View File

@@ -42,7 +42,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
const movies = useMemo(
() => similarItems?.filter((i) => i.Type === "Movie") || [],
[similarItems]
[similarItems],
);
return (
@@ -58,7 +58,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
{movies.map((item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}/page`)}
onPress={() => router.push(`/items/${item.Id}`)}
className="flex flex-col w-32"
>
<MoviePoster item={item} />

View File

@@ -38,11 +38,12 @@ export const ScrollingCollectionList: React.FC<Props> = ({
<TouchableOpacity
key={index}
onPress={() => {
if (item.Type === "Series")
router.push(`/series/${item.Id}/page`);
if (item.Type === "Series") router.push(`/series/${item.Id}`);
else if (item.CollectionType === "music")
router.push(`/artists/page?collectionId=${item.Id}`);
else if (item.Type === "CollectionFolder")
router.push(`/collections/${item.Id}/page`);
else router.push(`/items/${item.Id}/page`);
router.push(`/collections/${item.Id}`);
else router.push(`/items/${item.Id}`);
}}
className={`flex flex-col
${orientation === "vertical" ? "w-32" : "w-48"}

View File

@@ -0,0 +1,38 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import ArtistPoster from "../ArtistPoster";
import { runtimeTicksToMinutes, runtimeTicksToSeconds } from "@/utils/time";
import { useRouter } from "expo-router";
import { SongsListItem } from "./SongsListItem";
interface Props extends ViewProps {
songs?: BaseItemDto[] | null;
collectionId: string;
artistId: string;
albumId: string;
}
export const SongsList: React.FC<Props> = ({
collectionId,
artistId,
albumId,
songs = [],
...props
}) => {
const router = useRouter();
return (
<View className="flex flex-col space-y-2" {...props}>
{songs?.map((item: BaseItemDto, index: number) => (
<SongsListItem
key={item.Id}
item={item}
index={index}
collectionId={collectionId}
artistId={artistId}
albumId={albumId}
/>
))}
</View>
);
};

View File

@@ -0,0 +1,152 @@
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import index from "@/app/(auth)/(tabs)/home";
import { runtimeTicksToSeconds } from "@/utils/time";
import { router } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import ios12 from "@/utils/profiles/ios12";
import {
currentlyPlayingItemAtom,
triggerPlayAtom,
} from "../CurrentlyPlayingBar";
import { useActionSheet } from "@expo/react-native-action-sheet";
interface Props extends TouchableOpacityProps {
collectionId: string;
artistId: string;
albumId: string;
item: BaseItemDto;
index: number;
}
export const SongsListItem: React.FC<Props> = ({
collectionId,
artistId,
albumId,
item,
index,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const [, setPlayTrigger] = useAtom(triggerPlayAtom);
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
const openSelect = () => {
if (!castDevice?.deviceId) {
play("device");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
(selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
play("cast");
break;
case 1:
play("device");
break;
case cancelButtonIndex:
console.log("calcel");
}
},
);
};
const play = async (type: "device" | "cast") => {
if (!user?.Id || !api || !item.Id) return;
const response = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
const sessionData = response.data;
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
});
if (!url || !item) return;
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: url,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
} else {
setCp({
item,
playbackUrl: url,
});
// Use this trigger to initiate playback in another component (CurrentlyPlayingBar)
setPlayTrigger((prev) => prev + 1);
}
};
return (
<TouchableOpacity
onPress={() => {
openSelect();
}}
{...props}
>
<View className="flex flex-row items-center space-x-4 bg-neutral-900 border-neutral-800 px-4 py-4 rounded-xl">
<Text className="opacity-50">{index + 1}</Text>
<View>
<Text className="mb-0.5 font-semibold">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
</View>
</View>
</TouchableOpacity>
);
};

View File

@@ -20,7 +20,7 @@ export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
renderItem={(item, index) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.SeriesId}/page`)}
onPress={() => router.push(`/series/${item.SeriesId}`)}
className="flex flex-col space-y-2 w-32"
>
<Poster

View File

@@ -92,7 +92,7 @@ export const NextEpisodeButton: React.FC<Props> = ({
return (
<Button
onPress={() => router.replace(`/items/${nextEpisode?.Id}/page`)}
onPress={() => router.replace(`/items/${nextEpisode?.Id}`)}
className={`h-12 aspect-square`}
disabled={disabled}
{...props}

View File

@@ -49,7 +49,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/items/${item.Id}/page`);
router.push(`/(auth)/items/${item.Id}`);
}}
key={item.Id}
className="flex flex-col w-32"

View File

@@ -118,7 +118,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
<TouchableOpacity
key={item.Id}
onPress={() => {
router.push(`/(auth)/items/${item.Id}/page`);
router.push(`/(auth)/items/${item.Id}`);
}}
className="flex flex-col w-48"
>

View File

@@ -12,7 +12,7 @@ export const SeriesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<>
<TouchableOpacity
onPress={() => router.push(`/(auth)/series/${item.SeriesId}/page`)}
onPress={() => router.push(`/(auth)/series/${item.SeriesId}`)}
>
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
</TouchableOpacity>

View File

@@ -31,18 +31,21 @@ export const getPrimaryImageUrl = ({
return `${api?.basePath}/Items/${item?.Id}/Images/Primary`;
}
const backdropTag = item.BackdropImageTags?.[0];
const primaryTag = item.ImageTags?.["Primary"];
const backdropTag = item.BackdropImageTags?.[0];
const parentBackdropTag = item.ParentBackdropImageTags?.[0];
const params = new URLSearchParams({
fillWidth: width ? String(width) : "500",
quality: quality ? String(quality) : "90",
quality: quality ? String(quality) : "80",
});
if (primaryTag) {
params.set("tag", primaryTag);
} else if (backdropTag) {
params.set("tag", backdropTag);
} else if (parentBackdropTag) {
params.set("tag", parentBackdropTag);
}
return `${api?.basePath}/Items/${

View File

@@ -59,6 +59,7 @@ export const getStreamUrl = async ({
if (!mediaSource) {
throw new Error("No media source");
}
if (!sessionData.PlaySessionId) {
throw new Error("no PlaySessionId");
}