mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-01 11:38:26 +01:00
fix: song pages
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { SongsList } from "@/components/music/SongsList";
|
import { SongsList } from "@/components/music/SongsList";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
@@ -11,6 +13,7 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -88,30 +91,31 @@ export default function page() {
|
|||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (!album) return null;
|
if (!album) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ParallaxScrollView
|
||||||
<View className="px-4 pb-24">
|
headerHeight={400}
|
||||||
<View className="flex flex-row space-x-4 items-start mb-4">
|
headerImage={
|
||||||
<View className="w-24">
|
<ItemImage
|
||||||
<ArtistPoster item={album} />
|
variant={"Primary"}
|
||||||
</View>
|
item={album}
|
||||||
<View className="flex flex-col shrink">
|
style={{
|
||||||
<Text className="font-bold text-3xl">{album?.Name}</Text>
|
width: "100%",
|
||||||
<Text className="">{album?.ProductionYear}</Text>
|
height: "100%",
|
||||||
|
}}
|
||||||
<View className="flex flex-row space-x-2 mt-1">
|
/>
|
||||||
{album.AlbumArtists?.map((a) => (
|
}
|
||||||
<TouchableItemRouter key={a.Id} item={album}>
|
>
|
||||||
<Text className="font-bold text-purple-600">
|
<View className="px-4 mb-8">
|
||||||
{album?.AlbumArtist}
|
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
|
||||||
</Text>
|
<Text className="text-neutral-500">
|
||||||
</TouchableItemRouter>
|
{songs?.TotalRecordCount} songs
|
||||||
))}
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View className="px-4">
|
||||||
</View>
|
|
||||||
<SongsList
|
<SongsList
|
||||||
albumId={albumId}
|
albumId={albumId}
|
||||||
songs={songs?.Items}
|
songs={songs?.Items}
|
||||||
@@ -119,6 +123,6 @@ export default function page() {
|
|||||||
artistId={artistId}
|
artistId={artistId}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ParallaxScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -82,50 +86,45 @@ export default function page() {
|
|||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const insets = useSafeAreaInsets();
|
||||||
navigation.setOptions({
|
|
||||||
title: albums?.Items?.[0]?.AlbumArtist || "",
|
|
||||||
});
|
|
||||||
}, [albums]);
|
|
||||||
|
|
||||||
if (!artist || !albums) return null;
|
if (!artist || !albums) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<ParallaxScrollView
|
||||||
contentContainerStyle={{
|
headerHeight={400}
|
||||||
padding: 16,
|
headerImage={
|
||||||
paddingBottom: 140,
|
<ItemImage
|
||||||
}}
|
variant={"Primary"}
|
||||||
ListHeaderComponent={
|
item={artist}
|
||||||
<View className="mb-2">
|
style={{
|
||||||
<View className="w-32 mb-4">
|
width: "100%",
|
||||||
<ArtistPoster item={artist} />
|
height: "100%",
|
||||||
</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>
|
<View className="px-4 mb-8">
|
||||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
|
||||||
</View>
|
<Text className="text-neutral-500">
|
||||||
</TouchableOpacity>
|
{albums.TotalRecordCount} albums
|
||||||
)}
|
</Text>
|
||||||
keyExtractor={(item) => item.Id || ""}
|
</View>
|
||||||
/>
|
<View className="flex flex-row flex-wrap justify-between px-4">
|
||||||
|
{albums.Items.map((item, idx) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
style={{ width: "30%", marginBottom: 20 }}
|
||||||
|
key={idx}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col gap-y-2">
|
||||||
|
<ArtistPoster item={item} />
|
||||||
|
<Text numberOfLines={2}>{item.Name}</Text>
|
||||||
|
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|
||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
|
||||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
|
||||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
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 { ScrollView, View } from "react-native";
|
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
|
||||||
const local = useLocalSearchParams();
|
|
||||||
const { songId: id } = local as { songId: string };
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { setCurrentlyPlayingState } = usePlayback();
|
|
||||||
|
|
||||||
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 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 : ios,
|
|
||||||
audioStreamIndex: selectedAudioStream,
|
|
||||||
subtitleStreamIndex: selectedSubtitleStream,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Transcode URL: ", url);
|
|
||||||
|
|
||||||
return url;
|
|
||||||
},
|
|
||||||
enabled: !!sessionData,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
|
|
||||||
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 {
|
|
||||||
setCurrentlyPlayingState({
|
|
||||||
item,
|
|
||||||
url: playbackUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[playbackUrl, item]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</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} />
|
|
||||||
) : (
|
|
||||||
<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} 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;
|
|
||||||
@@ -71,7 +71,10 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const play = async (type: "device" | "cast") => {
|
const play = async (type: "device" | "cast") => {
|
||||||
if (!user?.Id || !api || !item.Id) return;
|
if (!user?.Id || !api || !item.Id) {
|
||||||
|
console.warn("No user, api or item", user, api, item.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
itemId: item?.Id,
|
itemId: item?.Id,
|
||||||
@@ -87,9 +90,13 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
||||||
|
mediaSourceId: item.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url || !item) return;
|
if (!url || !item) {
|
||||||
|
console.warn("No url or item", url, item.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
if (type === "cast" && client) {
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
await CastContext.getPlayServicesState().then((state) => {
|
||||||
@@ -111,6 +118,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
console.log("Playing on device", url, item.Id);
|
||||||
setCurrentlyPlayingState({
|
setCurrentlyPlayingState({
|
||||||
item,
|
item,
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const routes = [
|
|||||||
"artists/[artistId]",
|
"artists/[artistId]",
|
||||||
"collections/[collectionId]",
|
"collections/[collectionId]",
|
||||||
"items/page",
|
"items/page",
|
||||||
"songs/[songId]",
|
|
||||||
"series/[id]",
|
"series/[id]",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user