From 1f0ff1594bdd4b7d41b27288ddc54c67cac3310d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 15 Aug 2024 21:08:35 +0200 Subject: [PATCH] wip: refactoring file structure --- app.json | 7 +- app/(auth)/(tabs)/home/index.tsx | 2 - app/(auth)/(tabs)/search/index.tsx | 6 +- app/(auth)/albums/[albumId].tsx | 133 +++++++++ app/(auth)/artists/[artistId]/page.tsx | 131 +++++++++ app/(auth)/artists/page.tsx | 130 +++++++++ app/(auth)/collections/[collectionId].tsx | 229 +++++++++++++++ app/(auth)/downloads.tsx | 6 +- app/(auth)/items/[id].tsx | 304 ++++++++++++++++++++ app/(auth)/series/[id].tsx | 110 +++++++ app/(auth)/songs/[songId].tsx | 281 ++++++++++++++++++ app/+not-found.tsx | 9 +- app/_layout.tsx | 40 ++- components/ArtistPoster.tsx | 58 ++++ components/CurrentlyPlayingBar.tsx | 34 ++- components/SimilarItems.tsx | 4 +- components/home/ScrollingCollectionList.tsx | 9 +- components/music/SongsList.tsx | 38 +++ components/music/SongsListItem.tsx | 152 ++++++++++ components/series/CurrentSeries.tsx | 2 +- components/series/NextEpisodeButton.tsx | 2 +- components/series/NextUp.tsx | 2 +- components/series/SeasonPicker.tsx | 2 +- components/series/SeriesTitleHeader.tsx | 2 +- utils/jellyfin/image/getPrimaryImageUrl.ts | 7 +- utils/jellyfin/media/getStreamUrl.ts | 1 + 26 files changed, 1660 insertions(+), 41 deletions(-) create mode 100644 app/(auth)/albums/[albumId].tsx create mode 100644 app/(auth)/artists/[artistId]/page.tsx create mode 100644 app/(auth)/artists/page.tsx create mode 100644 app/(auth)/collections/[collectionId].tsx create mode 100644 app/(auth)/items/[id].tsx create mode 100644 app/(auth)/series/[id].tsx create mode 100644 app/(auth)/songs/[songId].tsx create mode 100644 components/ArtistPoster.tsx create mode 100644 components/music/SongsList.tsx create mode 100644 components/music/SongsListItem.tsx diff --git a/app.json b/app.json index 78938a9c..59cb5497 100644 --- a/app.json +++ b/app.json @@ -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, diff --git a/app/(auth)/(tabs)/home/index.tsx b/app/(auth)/(tabs)/home/index.tsx index 8e2ded17..e1dc71df 100644 --- a/app/(auth)/(tabs)/home/index.tsx +++ b/app/(auth)/(tabs)/home/index.tsx @@ -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) => { diff --git a/app/(auth)/(tabs)/search/index.tsx b/app/(auth)/(tabs)/search/index.tsx index fc7ee3cf..fd9c4b2c 100644 --- a/app/(auth)/(tabs)/search/index.tsx +++ b/app/(auth)/(tabs)/search/index.tsx @@ -109,7 +109,7 @@ export default function search() { router.push(`/items/${item.Id}/page`)} + onPress={() => router.push(`/items/${item.Id}`)} > {item.Name} @@ -130,7 +130,7 @@ export default function search() { renderItem={(item) => ( router.push(`/series/${item.Id}/page`)} + onPress={() => router.push(`/series/${item.Id}`)} className="flex flex-col w-32" > ( router.push(`/items/${item.Id}/page`)} + onPress={() => router.push(`/items/${item.Id}`)} className="flex flex-col w-48" > diff --git a/app/(auth)/albums/[albumId].tsx b/app/(auth)/albums/[albumId].tsx new file mode 100644 index 00000000..57faba7d --- /dev/null +++ b/app/(auth)/albums/[albumId].tsx @@ -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(0); + + const navigation = useNavigation(); + + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + + + ), + }); + }); + + 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 ( + + + + + + + + {album?.Name} + {album?.ProductionYear} + + + + + + ); +} diff --git a/app/(auth)/artists/[artistId]/page.tsx b/app/(auth)/artists/[artistId]/page.tsx new file mode 100644 index 00000000..80e1e5cd --- /dev/null +++ b/app/(auth)/artists/[artistId]/page.tsx @@ -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(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 ( + + + + + Albums + + } + nestedScrollEnabled + data={albums.Items} + numColumns={3} + columnWrapperStyle={{ + justifyContent: "space-between", + }} + renderItem={({ item, index }) => ( + { + router.push(`/albums/${item.Id}`); + }} + > + + + {item.Name} + {item.ProductionYear} + + + )} + keyExtractor={(item) => item.Id || ""} + /> + ); +} diff --git a/app/(auth)/artists/page.tsx b/app/(auth)/artists/page.tsx new file mode 100644 index 00000000..7a95322b --- /dev/null +++ b/app/(auth)/artists/page.tsx @@ -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(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 ( + + Artists + + } + nestedScrollEnabled + data={data.Items} + numColumns={3} + columnWrapperStyle={{ + justifyContent: "space-between", + }} + renderItem={({ item, index }) => ( + { + router.push(`/artists/${item.Id}/page`); + }} + > + + {collection?.CollectionType === "movies" && ( + + )} + {collection?.CollectionType === "music" && ( + + )} + {item.Name} + {item.ProductionYear} + + + )} + keyExtractor={(item) => item.Id || ""} + /> + ); +} diff --git a/app/(auth)/collections/[collectionId].tsx b/app/(auth)/collections/[collectionId].tsx new file mode 100644 index 00000000..f054af4d --- /dev/null +++ b/app/(auth)/collections/[collectionId].tsx @@ -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(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 ( + + + + {collection?.Name} + + + {startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "} + {totalItems} + + + { + setStartIndex((prev) => Math.max(prev - 100, 0)); + }} + > + + + { + setStartIndex((prev) => prev + 100); + }} + > + + + + + + {isLoading ? ( + + + + ) : ( + + {data?.Items?.map((item: BaseItemDto, index: number) => ( + { + if (item?.Type === "Series") { + router.push(`/series/${item.Id}`); + } else if (item.IsFolder) { + router.push(`/collections/${item?.Id}`); + } else { + router.push(`/items/${item.Id}`); + } + }} + > + + {collection?.CollectionType === "movies" && ( + + )} + {collection?.CollectionType === "music" && ( + + )} + {item.Name} + + {item.ProductionYear} + + + + ))} + + )} + + {!isLoading && ( + + { + setStartIndex((prev) => Math.max(prev - 100, 0)); + }} + > + + + { + setStartIndex((prev) => prev + 100); + }} + > + + + + )} + + ); +}; + +export default page; diff --git a/app/(auth)/downloads.tsx b/app/(auth)/downloads.tsx index cfab6c42..2aff6483 100644 --- a/app/(auth)/downloads.tsx +++ b/app/(auth)/downloads.tsx @@ -75,7 +75,7 @@ const downloads: React.FC = () => { {queue.map((q) => ( 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" > @@ -102,9 +102,7 @@ const downloads: React.FC = () => { Active download {process?.item ? ( - 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" > diff --git a/app/(auth)/items/[id].tsx b/app/(auth)/items/[id].tsx new file mode 100644 index 00000000..7110659a --- /dev/null +++ b/app/(auth)/items/[id].tsx @@ -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(-1); + const [selectedSubtitleStream, setSelectedSubtitleStream] = + useState(0); + const [maxBitrate, setMaxBitrate] = useState({ + 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 ( + + + + ); + + if (!item?.Id || !backdropUrl) return null; + + return ( + + } + logo={ + <> + {logoUrl ? ( + + ) : null} + + } + > + + + {item.Type === "Episode" ? ( + + ) : ( + <> + + + )} + {item?.ProductionYear} + + + + + {playbackUrl ? ( + + ) : ( + + )} + + + + + + + + setMaxBitrate(val)} + selected={maxBitrate} + /> + + + + + + + + + + + + + Video + Audio + Subtitles + + + + {item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle} + + + {item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle} + + + { + item.MediaStreams?.find((i) => i.Type === "Subtitle") + ?.DisplayTitle + } + + + + + + + + {item.Type === "Episode" && ( + + + + )} + + + + + + ); +}; + +export default page; diff --git a/app/(auth)/series/[id].tsx b/app/(auth)/series/[id].tsx new file mode 100644 index 00000000..fcdfcb26 --- /dev/null +++ b/app/(auth)/series/[id].tsx @@ -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 ( + + } + logo={ + <> + {logoUrl ? ( + + ) : null} + + } + > + + + {item?.Name} + {item?.Overview} + + + + + + + + ); +}; + +export default page; diff --git a/app/(auth)/songs/[songId].tsx b/app/(auth)/songs/[songId].tsx new file mode 100644 index 00000000..110ee877 --- /dev/null +++ b/app/(auth)/songs/[songId].tsx @@ -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: () => ( + + + + ), + }); + }); + + const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]); + const [selectedAudioStream, setSelectedAudioStream] = useState(-1); + const [selectedSubtitleStream, setSelectedSubtitleStream] = + useState(0); + const [maxBitrate, setMaxBitrate] = useState({ + 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 ( + + + + ); + + if (!item?.Id || !backdropUrl) return null; + + return ( + + } + logo={ + <> + {logoUrl ? ( + + ) : null} + + } + > + + + + {item?.ProductionYear} + + + + {playbackUrl ? ( + + ) : ( + + )} + + + + + setMaxBitrate(val)} + selected={maxBitrate} + /> + + + + + + + + + + + + + Audio + + + + {item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle} + + + + + + + + + + ); +}; + +export default page; diff --git a/app/+not-found.tsx b/app/+not-found.tsx index 5a8c1964..7d6ea6be 100644 --- a/app/+not-found.tsx +++ b/app/+not-found.tsx @@ -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 ( <> diff --git a/app/_layout.tsx b/app/_layout.tsx index 54340338..7fc5020f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -107,14 +107,14 @@ function Layout() { }} /> + + + + = ({ + item, + showProgress = false, +}) => { + const [api] = useAtom(apiAtom); + + const url = useMemo( + () => + getPrimaryImageUrl({ + api, + item, + }), + [item], + ); + + if (!url) + return ( + + ); + + return ( + + + + ); +}; + +export default ArtistPoster; diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index e2ba26df..56a3a431 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -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 = () => { { 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 = () => { { - 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}`); }} > {item?.Name} - {item?.SeriesName ? ( + {item?.Type === "Episode" && ( { - router.push(`/(auth)/series/${item.SeriesId}/page`); + router.push(`/(auth)/series/${item.SeriesId}`); }} className="text-xs opacity-50" > {item.SeriesName} - ) : ( + )} + {item?.Type === "Movie" && ( {item?.ProductionYear} )} + {item?.Type === "Audio" && ( + { + console.log(JSON.stringify(item)); + router.push(`/albums/${item?.AlbumId}`); + }} + > + {item?.Album} + + )} diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx index 102a082f..5da622b3 100644 --- a/components/SimilarItems.tsx +++ b/components/SimilarItems.tsx @@ -42,7 +42,7 @@ export const SimilarItems: React.FC = ({ itemId }) => { const movies = useMemo( () => similarItems?.filter((i) => i.Type === "Movie") || [], - [similarItems] + [similarItems], ); return ( @@ -58,7 +58,7 @@ export const SimilarItems: React.FC = ({ itemId }) => { {movies.map((item) => ( router.push(`/items/${item.Id}/page`)} + onPress={() => router.push(`/items/${item.Id}`)} className="flex flex-col w-32" > diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 99a154c0..9ee4be30 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -38,11 +38,12 @@ export const ScrollingCollectionList: React.FC = ({ { - 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"} diff --git a/components/music/SongsList.tsx b/components/music/SongsList.tsx new file mode 100644 index 00000000..8b5d2ca7 --- /dev/null +++ b/components/music/SongsList.tsx @@ -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 = ({ + collectionId, + artistId, + albumId, + songs = [], + ...props +}) => { + const router = useRouter(); + return ( + + {songs?.map((item: BaseItemDto, index: number) => ( + + ))} + + ); +}; diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx new file mode 100644 index 00000000..fc150fc4 --- /dev/null +++ b/components/music/SongsListItem.tsx @@ -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 = ({ + 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 ( + { + openSelect(); + }} + {...props} + > + + {index + 1} + + {item.Name} + + {runtimeTicksToSeconds(item.RunTimeTicks)} + + + + + ); +}; diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index 3255ddb5..5fc2c670 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -20,7 +20,7 @@ export const CurrentSeries = ({ item }: { item: BaseItemDto }) => { renderItem={(item, index) => ( router.push(`/series/${item.SeriesId}/page`)} + onPress={() => router.push(`/series/${item.SeriesId}`)} className="flex flex-col space-y-2 w-32" > = ({ return (