Merge branch '7-implement-search-functionality' of git@github.com:anultravioletaurora/Jellify.git

This commit is contained in:
Violet Caulfield
2025-01-25 08:21:59 -06:00
69 changed files with 1023 additions and 446 deletions

View File

@@ -1,6 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../enums/query-keys";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { ItemSortBy } from "@jellyfin/sdk/lib/generated-client/models";

View File

@@ -0,0 +1,11 @@
import Client from "../../../api/client";
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
export async function downloadTrack(itemId: string) : Promise<void> {
getLibraryApi(Client.api!)
.getDownload({
itemId
}, {
'responseType': 'blob'
})
}

View File

@@ -1,55 +1,22 @@
import { Api } from "@jellyfin/sdk/lib/api"
import { ImageFormat, ImageType } from "@jellyfin/sdk/lib/generated-client/models"
import { getImageApi } from "@jellyfin/sdk/lib/utils/api"
import _ from "lodash"
import { queryConfig } from "../query.config"
import Client from "@/api/client"
import Client from "../../../api/client"
import { QueryConfig } from "../query.config";
export function fetchImage(api: Api, itemId: string, imageType?: ImageType) : Promise<string> {
return new Promise(async (resolve) => {
let imageResponse = await api.axiosInstance
.get(getImageApi(api).getItemImageUrlById(
itemId,
imageType,
{
format: queryConfig.images.format,
fillHeight: queryConfig.images.fillHeight,
fillWidth: queryConfig.images.fillWidth
}
))
console.debug(convertFileToBase64(imageResponse.data));
console.debug(typeof imageResponse.data)
resolve(convertFileToBase64(imageResponse.data));
});
}
export function fetchItemImage(itemId: string, imageType?: ImageType, width?: number) {
export function fetchItemImage(itemId: string, imageType?: ImageType, size?: number) {
return getImageApi(Client.api!)
.getItemImage({
itemId,
imageType: imageType ? imageType : ImageType.Primary,
format: ImageFormat.Jpg
width: size ?? QueryConfig.playerArtwork.width,
height: size ?? QueryConfig.playerArtwork.height
}, {
responseType: 'blob'
})
.then((response) => {
console.log(convertFileToBase64(response.data))
return convertFileToBase64(response.data);
console.log(response)
return URL.createObjectURL(response.data)
});
}
function base64toJpeg(encode: string) : string {
return `data:image/jpeg;base64,${encode}`;
}
function convertFileToBase64(file: any): string {
console.debug("Converting file to base64", file)
let encode = base64toJpeg(Buffer.from(file, 'binary').toString('base64'));
console.debug(encode);
return encode;
}

View File

@@ -0,0 +1,20 @@
import Client from "../../../api/client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
export async function fetchItem(itemId: string) : Promise<BaseItemDto> {
return new Promise((resolve, reject) => {
getItemsApi(Client.api!)
.getItems({
ids: [
itemId
]
})
.then((response) => {
if (response.data.Items && response.data.TotalRecordCount == 1)
resolve(response.data.Items[0])
else
reject(`${response.data.TotalRecordCount} items returned for ID`);
})
});
}

View File

@@ -1,6 +1,6 @@
import { BaseItemDto, BaseItemKind, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { queryConfig } from "../query.config";
import { QueryConfig } from "../query.config";
import Client from "../../client";
export function fetchRecentlyPlayed(): Promise<BaseItemDto[]> {
@@ -13,7 +13,7 @@ export function fetchRecentlyPlayed(): Promise<BaseItemDto[]> {
includeItemTypes: [
BaseItemKind.Audio
],
limit: queryConfig.limits.recents,
limit: QueryConfig.limits.recents,
parentId: Client.library!.musicLibraryId,
recursive: true,
sortBy: [

View File

@@ -0,0 +1,43 @@
import Client from "../../../api/client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { isEmpty, trim } from "lodash";
import { QueryConfig } from "../query.config";
/**
* Performs a search for items against the Jellyfin server, trimming whitespace
* around the search term for the best possible results.
* @param searchString The search term to look up against
* @returns A promise of a BaseItemDto array, be it empty or not
*/
export async function fetchSearchResults(searchString: string | undefined) : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
console.debug("Searching Jellyfin for items")
if (isEmpty(searchString))
resolve([]);
getItemsApi(Client.api!)
.getItems({
searchTerm: trim(searchString),
recursive: true,
includeItemTypes: [
'Audio',
'MusicAlbum',
'MusicArtist',
'Playlist'
],
limit: QueryConfig.limits.search
})
.then((response) => {
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
})
.catch((error) => {
reject(error)
});
})
}

View File

@@ -0,0 +1,90 @@
import Client from "../../../api/client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSuggestionsApi } from "@jellyfin/sdk/lib/utils/api";
export async function fetchSearchSuggestions() : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getSuggestionsApi(Client.api!)
.getSuggestions({
userId: Client.user!.id,
type: [
'MusicArtist',
'MusicAlbum',
'Audio',
'Playlist'
]
})
.then((response) => {
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
})
.catch((error) => {
reject(error);
})
})
}
export async function fetchSuggestedArtists() : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getSuggestionsApi(Client.api!)
.getSuggestions({
userId: Client.user!.id,
type: [
'MusicArtist'
]
})
.then((response) => {
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
})
.catch((error) => {
reject(error);
})
})
}
export async function fetchSuggestedAlbums() : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getSuggestionsApi(Client.api!)
.getSuggestions({
userId: Client.user!.id,
type: [
'MusicAlbum'
]
})
.then((response) => {
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
})
.catch((error) => {
reject(error);
})
})
}
export async function fetchSuggestedTracks() : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getSuggestionsApi(Client.api!)
.getSuggestions({
userId: Client.user!.id,
type: [
'Audio'
]
})
.then((response) => {
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
})
.catch((error) => {
reject(error);
})
})
}

View File

@@ -3,7 +3,7 @@ import { QueryKeys } from "../../enums/query-keys";
import { fetchItemImage } from "./functions/images";
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
export const useItemImage = (itemId: string, imageType?: ImageType, width?: number) => useQuery({
queryKey: [QueryKeys.ItemImage, itemId, imageType, width],
queryFn: () => fetchItemImage(itemId, imageType, width)
export const useItemImage = (itemId: string, imageType?: ImageType, size?: number) => useQuery({
queryKey: [QueryKeys.ItemImage, itemId, imageType, size],
queryFn: () => fetchItemImage(itemId, imageType, size)
});

8
api/queries/item.ts Normal file
View File

@@ -0,0 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../enums/query-keys";
import { fetchItem } from "./functions/item";
export const useItem = (itemId: string) => useQuery({
queryKey: [QueryKeys.Item, itemId],
queryFn: () => fetchItem(itemId)
});

View File

@@ -1,12 +1,13 @@
import { ImageFormat } from "@jellyfin/sdk/lib/generated-client/models";
export const queryConfig = {
export const QueryConfig = {
limits: {
recents: 50 // TODO: Adjust this when we add a list navigator to the end of the recents
recents: 50, // TODO: Adjust this when we add a list navigator to the end of the recents
search: 25,
},
images: {
fillHeight: 300,
fillWidth: 300,
height: 300,
width: 300,
format: ImageFormat.Jpg
},
banners: {
@@ -20,9 +21,13 @@ export const queryConfig = {
format: ImageFormat.Png
},
playerArtwork: {
fillHeight: 1000,
fillWidth: 1000,
height: 1000,
width: 1000,
format: ImageFormat.Jpg
},
staleTime: 1000 * 60
staleTime: {
oneDay: 1000 * 60 * 60 * 24, // 1 Day
oneWeek: 1000 * 60 * 60 * 24 * 7, // 7 Days
oneFortnight: 1000 * 60 * 60 * 24 * 7 * 14 // 14 Days
}
}

View File

@@ -0,0 +1,8 @@
import { QueryKeys } from "../../enums/query-keys";
import { useQuery } from "@tanstack/react-query";
import { fetchSearchSuggestions } from "./functions/suggestions";
export const useSearchSuggestions = () => useQuery({
queryKey: [QueryKeys.SearchSuggestions],
queryFn: () => fetchSearchSuggestions()
})

View File

@@ -2,7 +2,7 @@ import { QueryKeys } from "../../enums/query-keys";
import { ItemSortBy } from "@jellyfin/sdk/lib/generated-client/models/item-sort-by";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { useQuery } from "@tanstack/react-query";
import { queryConfig } from "./query.config";
import { QueryConfig } from "./query.config";
import Client from "../client";
export const useItemTracks = (itemId: string, sort: boolean = false) => useQuery({
@@ -29,5 +29,5 @@ export const useItemTracks = (itemId: string, sort: boolean = false) => useQuery
return response.data.Items ? response.data.Items! : [];
})
},
staleTime: queryConfig.staleTime
staleTime: QueryConfig.staleTime.oneDay
})

View File

@@ -4,7 +4,7 @@ import { ScrollView, YStack, XStack } from "tamagui";
import { CachedImage } from "@georstat/react-native-image-cache";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { queryConfig } from "../../api/queries/query.config";
import { QueryConfig } from "../../api/queries/query.config";
import { H4, H5, Text } from "../Global/helpers/text";
import { FlatList } from "react-native";
import { usePlayerContext } from "../../player/provider";
@@ -12,7 +12,7 @@ import { RunTimeTicks } from "../Global/helpers/time-codes";
import Track from "../Global/components/track";
import { useItemTracks } from "../../api/queries/tracks";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import FavoriteHeaderButton from "../Global/components/favorite-button";
import FavoriteButton from "../Global/components/favorite-button";
import { useEffect } from "react";
import Client from "../../api/client";
@@ -26,7 +26,7 @@ export default function Album(props: AlbumProps): React.JSX.Element {
props.navigation.setOptions({
headerRight: () => {
return (
<FavoriteHeaderButton item={props.album} />
<FavoriteButton item={props.album} />
)
}
})
@@ -46,13 +46,17 @@ export default function Album(props: AlbumProps): React.JSX.Element {
return (
<SafeAreaView edges={["right", "left"]}>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<YStack alignItems="center" minHeight={width / 1.1}>
<YStack
alignItems="center"
alignContent="center"
minHeight={width / 1.1}
>
<CachedImage
source={getImageApi(Client.api!)
.getItemImageUrlById(
props.album.Id!,
ImageType.Primary,
{ ...queryConfig.playerArtwork})}
{ ...QueryConfig.playerArtwork})}
imageStyle={{
position: "relative",
width: width / 1.1,
@@ -81,10 +85,11 @@ export default function Album(props: AlbumProps): React.JSX.Element {
}}/>
<XStack justifyContent="flex-end">
<XStack marginTop={"$3"} justifyContent="flex-end">
<Text
color={"$gray10"}
color={"$purpleGray"}
style={{ display: "block"}}
marginRight={"$1"}
>
Total Runtime:
</Text>

View File

@@ -29,7 +29,7 @@ export default function Albums({ navigation }: AlbumsProps) : React.JSX.Element
subCaption={album.ProductionYear?.toString() ?? ""}
cornered
onPress={() => {
navigation.navigate("Album", { album })
navigation.push("Album", { album })
}}
width={width / 2.1}
/>

View File

@@ -8,10 +8,10 @@ import { H2 } from "../Global/helpers/text";
import { useState } from "react";
import { CachedImage } from "@georstat/react-native-image-cache";
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { queryConfig } from "../../api/queries/query.config";
import { QueryConfig } from "../../api/queries/query.config";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import FavoriteHeaderButton from "../Global/components/favorite-button";
import FavoriteButton from "../Global/components/favorite-button";
import Client from "../../api/client";
interface ArtistProps {
@@ -24,7 +24,7 @@ export default function Artist(props: ArtistProps): React.JSX.Element {
props.navigation.setOptions({
headerRight: () => {
return (
<FavoriteHeaderButton item={props.artist} />
<FavoriteButton item={props.artist} />
)
}
});
@@ -48,7 +48,7 @@ export default function Artist(props: ArtistProps): React.JSX.Element {
.getItemImageUrlById(
props.artist.Id!,
ImageType.Primary,
{ ...queryConfig.banners})
{ ...QueryConfig.banners})
}
imageStyle={{
width: width,
@@ -77,7 +77,7 @@ export default function Artist(props: ArtistProps): React.JSX.Element {
cornered
itemId={album.Id!}
onPress={() => {
props.navigation.navigate('Album', {
props.navigation.push('Album', {
album
})
}}

View File

@@ -30,7 +30,7 @@ export default function Artists({ navigation }: ArtistsProps): React.JSX.Element
itemId={artist.Id!}
caption={artist.Name ?? "Unknown Artist"}
onPress={() => {
navigation.navigate("Artist", { artist })
navigation.push("Artist", { artist })
}}
width={width / 2.1}
/>

View File

@@ -0,0 +1,10 @@
import { View } from "tamagui";
import { Text } from "../Global/helpers/text";
export default function CarPlayHome() : React.JSX.Element {
return (
<View>
<Text>Yeet</Text>
</View>
)
}

View File

@@ -1,34 +0,0 @@
import React, {useEffect} from 'react';
import {Text, View} from 'react-native';
import {CarPlay, NowPlayingTemplate} from 'react-native-carplay';
export function NowPlaying() {
useEffect(() => {
const template = new NowPlayingTemplate({
albumArtistButtonEnabled: true,
upNextButtonEnabled: false,
onUpNextButtonPressed() {
console.log('up next was pressed');
},
onButtonPressed(e) {
console.log(e);
},
});
CarPlay.enableNowPlaying(true);
CarPlay.pushTemplate(template);
return () => {};
}, []);
return (
<View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
<Text>Now Playing</Text>
</View>
);
}
NowPlaying.navigationOptions = {
headerTitle: 'Now Playing Template',
};

View File

@@ -29,7 +29,7 @@ export default function FavoritesScreen({
caption={item.name}
width={width / 2.1}
onPress={() => {
navigation.navigate(item.name)
navigation.push(item.name)
}}
/>
)

View File

@@ -14,7 +14,7 @@ interface SetFavoriteMutation {
item: BaseItemDto,
}
export default function FavoriteHeaderButton({
export default function FavoriteButton({
item,
onToggle
}: {
@@ -37,8 +37,7 @@ export default function FavoriteHeaderButton({
},
onSuccess: () => {
setIsFavorite(true);
if (onToggle)
onToggle();
onToggle ? onToggle() : {};
}
})
@@ -51,6 +50,7 @@ export default function FavoriteHeaderButton({
},
onSuccess: () => {
setIsFavorite(false);
onToggle ? onToggle(): {};
}
})
@@ -62,7 +62,10 @@ export default function FavoriteHeaderButton({
}
useEffect(() => {
if (isFetched && data && data.IsFavorite)
if (isFetched
&& !isUndefined(data)
&& !isUndefined(data.IsFavorite)
)
setIsFavorite(data.IsFavorite)
}, [
isFetched,
@@ -71,22 +74,10 @@ export default function FavoriteHeaderButton({
useEffect(() => {
refetch();
setIsFavorite(
isUndefined(item.UserData) ? false
: item.UserData.IsFavorite ?? false
);
}, [
item
]);
useEffect(() => {
if (nowPlayingIsFavorite !== isFavorite && nowPlaying?.item.Id === item.Id) {
setIsFavorite(nowPlayingIsFavorite);
}
}, [
nowPlayingIsFavorite
])
return (
isFetching && isUndefined(item.UserData) ? (
<Spinner />

View File

@@ -0,0 +1,112 @@
import { usePlayerContext } from "../../../player/provider";
import { StackParamList } from "../../../components/types";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Separator, Spacer, View, XStack, YStack } from "tamagui";
import { Text } from "../helpers/text";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import BlurhashedImage from "../helpers/blurhashed-image";
import Icon from "../helpers/icon";
import { Colors } from "../../../enums/colors";
import { QueuingType } from "../../../enums/queuing-type";
export default function Item({
item,
queueName,
navigation,
} : {
item: BaseItemDto,
queueName: string,
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
const { usePlayNewQueue } = usePlayerContext();
const { width } = useSafeAreaFrame();
return (
<View flex={1}>
<Separator />
<XStack
alignContent="center"
flex={1}
minHeight={width / 9}
onPress={() => {
switch (item.Type) {
case ("MusicArtist") : {
navigation.push("Artist", {
artist: item
})
break;
}
case ("MusicAlbum") : {
navigation.push("Album", {
album: item
})
break;
}
case ("Audio") : {
usePlayNewQueue.mutate({
track: item,
tracklist: [item],
queueName,
queuingType: QueuingType.FromSelection
})
break;
}
}
}}
onLongPress={() => {
navigation.push("Details", {
item,
isNested: false
})
}}
paddingVertical={"$2"}
marginHorizontal={"$1"}
>
<BlurhashedImage item={item} size={width / 9} />
<YStack
marginLeft={"$1"}
justifyContent="flex-start"
flex={5}
>
<Text bold>{ item.Name ?? ""}</Text>
{ item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
<Text>{ item.AlbumArtist ?? "Untitled Artist" }</Text>
) : (
<Spacer />
)}
</YStack>
<XStack alignContent="flex-end" flex={2}>
{ item.UserData?.IsFavorite ? (
<Icon
small
color={Colors.Primary}
name="heart"
/>
) : (
<Spacer />
)}
<Icon
small
name="dots-vertical"
onPress={() => {
navigation.push("Details", {
item,
isNested: false
})
}}
/>
</XStack>
</XStack>
</View>
)
}

View File

@@ -1,18 +1,18 @@
import { usePlayerContext } from "../../../player/provider";
import React from "react";
import { Separator, Spacer, View, XStack, YStack } from "tamagui";
import { Separator, Spacer, useTheme, View, XStack, YStack } from "tamagui";
import { Text } from "../helpers/text";
import { RunTimeTicks } from "../helpers/time-codes";
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { Colors } from "../../../enums/colors";
import { CachedImage } from "@georstat/react-native-image-cache";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api/image-api";
import { queryConfig } from "../../../api/queries/query.config";
import { QueryConfig } from "../../../api/queries/query.config";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import Icon from "../helpers/icon";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../../../components/types";
import Client from "../../../api/client";
import { QueuingType } from "../../../enums/queuing-type";
interface TrackProps {
track: BaseItemDto;
@@ -21,6 +21,7 @@ interface TrackProps {
index: number | undefined;
showArtwork?: boolean | undefined;
onPress?: () => void | undefined;
isNested?: boolean | undefined
}
export default function Track({
@@ -30,7 +31,8 @@ export default function Track({
index,
queueName,
showArtwork,
onPress
onPress,
isNested
} : {
track: BaseItemDto,
tracklist: BaseItemDto[],
@@ -38,7 +40,8 @@ export default function Track({
index?: number | undefined,
queueName?: string | undefined,
showArtwork?: boolean | undefined,
onPress?: () => void | undefined
onPress?: () => void | undefined,
isNested?: boolean | undefined
}) : React.JSX.Element {
const { width } = useSafeAreaFrame();
@@ -46,6 +49,8 @@ export default function Track({
const isPlaying = nowPlaying?.item.Id === track.Id;
const theme = useTheme();
return (
<View>
<Separator />
@@ -60,10 +65,17 @@ export default function Track({
track,
index,
tracklist,
queueName: queueName ? queueName : track.Album ? track.Album! : "Queue"
queueName: queueName ? queueName : track.Album ? track.Album! : "Queue",
queuingType: QueuingType.FromSelection
});
}
}}
onLongPress={() => {
navigation.push("Details", {
item: track,
isNested: isNested
})
}}
paddingVertical={"$2"}
marginHorizontal={"$1"}
>
@@ -79,7 +91,7 @@ export default function Track({
.getItemImageUrlById(
track.AlbumId ?? "",
ImageType.Primary,
{ ...queryConfig.images }
{ ...QueryConfig.images }
)
}
imageStyle={{
@@ -91,7 +103,7 @@ export default function Track({
/>
) : (
<Text color={isPlaying ? Colors.Primary : Colors.White}>
<Text color={isPlaying ? theme.telemagenta : theme.color}>
{ track.IndexNumber?.toString() ?? "" }
</Text>
)}
@@ -100,7 +112,7 @@ export default function Track({
<YStack alignContent="center" justifyContent="flex-start" flex={5}>
<Text
bold
color={isPlaying ? Colors.Primary : Colors.White}
color={isPlaying ? theme.telemagenta : theme.color}
lineBreakStrategyIOS="standard"
numberOfLines={1}
>
@@ -124,7 +136,7 @@ export default function Track({
minWidth={24}
>
{ track.UserData?.IsFavorite ? (
<Icon small name="heart" color={Colors.Primary} />
<Icon small name="heart" color={theme.telemagenta.val} />
) : (
<Spacer />
)}
@@ -143,8 +155,9 @@ export default function Track({
>
<Icon small name="dots-vertical" onPress={() => {
navigation.push("Details", {
item: track
})
item: track,
isNested: isNested
});
}} />
</YStack>

View File

@@ -11,7 +11,7 @@ interface BlurhashLoadingProps {
export default function BlurhashedImage({ item, size, type }: { item: BaseItemDto, size: number, type?: ImageType }) : React.JSX.Element {
const { data: image, isSuccess } = useItemImage(item.Id!, type);
const { data: image, isSuccess } = useItemImage(item.Id!, type, size);
const blurhash = !isEmpty(item.ImageBlurHashes)
&& !isEmpty(item.ImageBlurHashes.Primary)
@@ -19,11 +19,13 @@ export default function BlurhashedImage({ item, size, type }: { item: BaseItemDt
: undefined;
return (
<View minHeight={size}>
<View minHeight={size} minWidth={size}>
{ isSuccess ? (
<Image
src={image}
source={{
uri: image
}}
style={{
height: size,
width: size,

View File

@@ -1,7 +1,7 @@
import React from "react"
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"
import { Colors } from "../../../enums/colors"
import { useColorScheme } from "react-native";
import { ColorTokens, getTokens } from "tamagui";
const smallSize = 24;
@@ -9,14 +9,28 @@ const regularSize = 36;
const largeSize = 48
export default function Icon({ name, onPress, small, large, color }: { name: string, onPress?: () => void, small?: boolean, large?: boolean, color?: Colors }) : React.JSX.Element {
export default function Icon({
name, onPress,
small,
large,
color
}: {
name: string,
onPress?: () => void,
small?: boolean,
large?: boolean,
color?: ColorTokens
}) : React.JSX.Element {
const isDarkMode = useColorScheme() === "dark"
let size = large ? largeSize : small ? smallSize : regularSize
return (
<MaterialCommunityIcons
color={color ? color : isDarkMode ? Colors.White : Colors.Background}
color={color ? color
: isDarkMode ? getTokens().color.$purpleGray.val
: getTokens().color.$purpleDark.val
}
name={name}
onPress={onPress}
size={size}

View File

@@ -1,9 +1,9 @@
import { Colors } from '../../../enums/colors';
import React, { SetStateAction } from 'react';
import React from 'react';
import { Input as TamaguiInput} from 'tamagui';
interface InputProps {
onChangeText: React.Dispatch<SetStateAction<string | undefined>>,
onChangeText: (value: string | undefined) => void,
placeholder: string
value: string | undefined;
secureTextEntry?: boolean | undefined;
@@ -21,6 +21,7 @@ export default function Input(props: InputProps): React.JSX.Element {
value={props.value}
flexGrow={props.flexGrow ? 1 : "unset"}
secureTextEntry={props.secureTextEntry}
clearButtonMode="always"
/>
)
}

View File

@@ -6,7 +6,7 @@ import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { CachedImage } from "@georstat/react-native-image-cache";
import invert from "invert-color"
import { Blurhash } from "react-native-blurhash"
import { queryConfig } from "../../../api/queries/query.config";
import { QueryConfig } from "../../../api/queries/query.config";
import { Text } from "./text";
import Client from "../../../api/client";
@@ -52,7 +52,7 @@ export function ItemCard(props: CardProps) {
.getItemImageUrlById(
props.itemId,
ImageType.Logo,
{ ...queryConfig.logos})
{ ...QueryConfig.logos})
}
imageStyle={{
...logoDimensions,
@@ -72,7 +72,7 @@ export function ItemCard(props: CardProps) {
.getItemImageUrlById(
props.itemId,
ImageType.Primary,
{ ...queryConfig.images})
{ ...QueryConfig.images})
}
imageStyle={{
...dimensions,

View File

@@ -1,6 +1,6 @@
import { Colors } from "../../../enums/colors";
import React from "react";
import { SliderProps as TamaguiSliderProps, SliderVerticalProps, Slider as TamaguiSlider, styled, Slider } from "tamagui";
import { SliderProps as TamaguiSliderProps, SliderVerticalProps, Slider as TamaguiSlider, styled, Slider, getTokens } from "tamagui";
interface SliderProps {
value?: number | undefined;
@@ -10,16 +10,16 @@ interface SliderProps {
}
const JellifySliderThumb = styled(Slider.Thumb, {
backgroundColor: Colors.Primary,
borderColor: Colors.Background,
backgroundColor: getTokens().color.$telemagenta,
borderColor: getTokens().color.$purpleGray,
})
const JellifySliderTrack = styled(Slider.Track, {
backgroundColor: Colors.Borders
backgroundColor: getTokens().color.$purpleGray
});
const JellifyActiveSliderTrack = styled(Slider.TrackActive, {
backgroundColor: Colors.Primary
backgroundColor: getTokens().color.$telemagenta
})
export function HorizontalSlider({

View File

@@ -12,7 +12,7 @@ export function RunTimeTicks({ children } : { children?: number | null | undefin
let time = calculateRunTimeFromTicks(children);
return <Text color="$gray10">{ time }</Text>
return <Text color="$purpleGray">{ time }</Text>
}
function calculateRunTimeFromSeconds(seconds: number) : string {

View File

@@ -72,7 +72,7 @@ export default function Home(): React.JSX.Element {
headerShown: false,
presentation: "modal"
}}
/>
/>
</HomeStack.Group>
</HomeStack.Navigator>
</HomeProvider>

View File

@@ -1,12 +1,13 @@
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useUserPlaylists } from "../../../api/queries/playlist";
import { ItemCard } from "../../../components/Global/helpers/item-card";
import { H2 } from "../../../components/Global/helpers/text";
import { ProvidedHomeProps } from "../../../components/types";
import { StackParamList } from "../../../components/types";
import React from "react";
import { FlatList } from "react-native";
import { View } from "tamagui";
export default function Playlists({ navigation }: ProvidedHomeProps) : React.JSX.Element {
export default function Playlists({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}) : React.JSX.Element {
const { data: playlists } = useUserPlaylists();
@@ -21,7 +22,7 @@ export default function Playlists({ navigation }: ProvidedHomeProps) : React.JSX
itemId={playlist.Id!}
caption={playlist.Name ?? "Untitled Playlist"}
onPress={() => {
navigation.navigate('Playlist', {
navigation.push('Playlist', {
playlist
})
}} />

View File

@@ -1,13 +1,14 @@
import React, { useEffect } from "react";
import React from "react";
import { View } from "tamagui";
import { useHomeContext } from "../provider";
import { H2 } from "../../Global/helpers/text";
import { ProvidedHomeProps } from "../../types";
import { StackParamList } from "../../types";
import { FlatList } from "react-native";
import { ItemCard } from "../../Global/helpers/item-card";
import { getPrimaryBlurhashFromDto } from "../../../helpers/blurhash";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
export default function RecentArtists({ navigation }: ProvidedHomeProps): React.JSX.Element {
export default function RecentArtists({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
const { recentArtists } = useHomeContext();
@@ -24,7 +25,7 @@ export default function RecentArtists({ navigation }: ProvidedHomeProps): React.
itemId={recentArtist.Id!}
caption={recentArtist.Name ?? "Unknown Artist"}
onPress={() => {
navigation.navigate('Artist',
navigation.push('Artist',
{
artist: recentArtist,
}

View File

@@ -4,8 +4,16 @@ import { useHomeContext } from "../provider";
import { H2 } from "../../Global/helpers/text";
import { ItemCard } from "../../Global/helpers/item-card";
import { usePlayerContext } from "../../../player/provider";
import { StackParamList } from "../../../components/types";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { trigger } from "react-native-haptic-feedback";
import { QueuingType } from "../../../enums/queuing-type";
export default function RecentlyPlayed(): React.JSX.Element {
export default function RecentlyPlayed({
navigation
} : {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { usePlayNewQueue } = usePlayerContext();
const { recentTracks } = useHomeContext();
@@ -27,9 +35,17 @@ export default function RecentlyPlayed(): React.JSX.Element {
track: recentlyPlayedTrack,
index: index,
tracklist: recentTracks,
queueName: "Recently Played"
queueName: "Recently Played",
queuingType: QueuingType.FromSelection
});
}}
onLongPress={() => {
trigger("impactMedium");
navigation.push("Details", {
item: recentlyPlayedTrack,
isNested: false
})
}}
/>
)
})}

View File

@@ -1,4 +1,4 @@
import { ProvidedHomeProps } from "../../../components/types";
import { StackParamList } from "../../../components/types";
import { ScrollView, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { YStack, XStack, Separator } from "tamagui";
@@ -9,19 +9,34 @@ import { useHomeContext } from "../provider";
import { H3 } from "../../../components/Global/helpers/text";
import Avatar from "../../../components/Global/helpers/avatar";
import Client from "../../../api/client";
import { usePlayerContext } from "../../../player/provider";
import { useEffect } from "react";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
export function ProvidedHome({ route, navigation }: ProvidedHomeProps): React.JSX.Element {
export function ProvidedHome({
navigation
} : {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { refreshing: refetching, onRefresh: onRefetch } = useHomeContext()
const { nowPlayingIsFavorite } = usePlayerContext();
useEffect(() => {
onRefetch()
}, [
nowPlayingIsFavorite
])
return (
<SafeAreaView edges={["top", "right", "left"]}>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl
refreshing={refetching}
onRefresh={onRefetch}
refreshing={refetching}
onRefresh={onRefetch}
/>
}>
<YStack alignContent='flex-start'>
@@ -33,15 +48,15 @@ export function ProvidedHome({ route, navigation }: ProvidedHomeProps): React.JS
<Separator marginVertical={"$2"} />
<RecentArtists route={route} navigation={navigation} />
<RecentArtists navigation={navigation} />
<Separator marginVertical={"$3"} />
<RecentlyPlayed />
<RecentlyPlayed navigation={navigation} />
<Separator marginVertical={"$3"} />
<Playlists route={route} navigation={navigation}/>
<Playlists navigation={navigation}/>
</YStack>
</ScrollView>
</SafeAreaView>

View File

@@ -1,28 +1,39 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import { StackParamList } from "../types";
import TrackOptions from "./helpers/TrackOptions";
import { View, XStack, YStack } from "tamagui";
import { ScrollView, Spacer, View, XStack, YStack } from "tamagui";
import BlurhashedImage from "../Global/helpers/blurhashed-image";
import { Text } from "../Global/helpers/text";
import { Colors } from "@/enums/colors";
import { Colors } from "../../enums/colors";
import FavoriteButton from "../Global/components/favorite-button";
import { useEffect } from "react";
import { trigger } from "react-native-haptic-feedback";
export default function ItemDetail({
item,
navigation
navigation,
isNested
} : {
item: BaseItemDto,
navigation: NativeStackNavigationProp<StackParamList>
navigation: NativeStackNavigationProp<StackParamList>,
isNested?: boolean | undefined
}) : React.JSX.Element {
let options: React.JSX.Element | undefined = undefined;
useEffect(() => {
trigger("impactMedium");
}, [
item
]);
const { width } = useSafeAreaFrame();
switch (item.Type) {
case "Audio": {
options = TrackOptions({ item, navigation });
options = TrackOptions({ item, navigation, isNested });
break;
}
@@ -47,24 +58,44 @@ export default function ItemDetail({
}
return (
<SafeAreaView edges={["right", "left"]}>
<XStack>
<BlurhashedImage
item={item}
size={width / 3}
/>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<YStack alignItems="center" flex={1}>
<YStack justifyContent="flex-start">
<Text bold fontSize={"$6"}>
<XStack
justifyContent="center"
alignItems="center"
maxHeight={width / 1.5}
maxWidth={width / 1.5}
>
<BlurhashedImage
item={item}
size={width / 1.5}
/>
</XStack>
<YStack
marginLeft={"$0.5"}
alignContent="center"
justifyContent="center"
flex={2}
>
<Text textAlign="center" bold fontSize={"$6"}>
{ item.Name ?? "Untitled Track" }
</Text>
<Text
textAlign="center"
fontSize={"$6"}
color={Colors.Primary}
onPress={() => {
if (item.ArtistItems) {
navigation.navigate("Artist", {
if (isNested)
navigation.getParent()!.goBack();
navigation.goBack();
navigation.push("Artist", {
artist: item.ArtistItems[0]
});
}
@@ -73,15 +104,23 @@ export default function ItemDetail({
</Text>
<Text
textAlign="center"
fontSize={"$6"}
color={"$gray10"}
color={"$purpleGray"}
>
{ item.Album ?? "" }
</Text>
<Spacer />
<FavoriteButton item={item} />
<Spacer />
{ options ?? <View /> }
</YStack>
</XStack>
{ options ?? <View /> }
</SafeAreaView>
</YStack>
</ScrollView>
)
}

View File

@@ -1,19 +1,62 @@
import { usePlayerContext } from "../../../player/provider";
import { useItem } from "../../../api/queries/item";
import Icon from "../../../components/Global/helpers/icon";
import { StackParamList } from "../../../components/types";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { View } from "tamagui";
import { XStack } from "tamagui";
import { QueuingType } from "../../../enums/queuing-type";
export default function TrackOptions({
item,
navigation
navigation,
isNested
} : {
item: BaseItemDto,
navigation: NativeStackNavigationProp<StackParamList>
navigation: NativeStackNavigationProp<StackParamList>,
isNested: boolean | undefined// Whether this is nested in the player modal
}) : React.JSX.Element {
const { data: album, isSuccess } = useItem(item.AlbumId ?? "");
const { useAddToQueue } = usePlayerContext();
return (
<View>
</View>
<XStack alignContent="flex-end" justifyContent="space-between">
{ isSuccess && (
<Icon
name="music-box"
onPress={() => {
if (isNested)
navigation.getParent()!.goBack();
navigation.goBack();
navigation.push("Album", {
album
});
}}
/>
)}
<Icon
name="table-column-plus-before"
onPress={() => {
useAddToQueue.mutate({
track: item,
queuingType: QueuingType.PlayingNext
})
}}
/>
<Icon
name="table-column-plus-after"
onPress={() => {
useAddToQueue.mutate({
track: item
})
}}
/>
</XStack>
)
}

View File

@@ -6,7 +6,7 @@ import React from "react";
export default function DetailsScreen({
route,
navigation
navigation,
} : {
route: RouteProp<StackParamList, "Details">,
navigation: NativeStackNavigationProp<StackParamList>
@@ -15,6 +15,7 @@ export default function DetailsScreen({
<ItemDetail
item={route.params.item}
navigation={navigation}
isNested={route.params.isNested}
/>
)
}

View File

@@ -0,0 +1,8 @@
import { TextTickerProps } from "react-native-text-ticker";
export const TextTickerConfig : TextTickerProps = {
duration: 5000,
loop: true,
repeatSpacer: 20,
marqueeDelay: 1000
}

View File

@@ -1,29 +1,34 @@
import React, { } from "react";
import { XStack, YStack } from "tamagui";
import { useTheme, View, XStack, YStack } from "tamagui";
import { usePlayerContext } from "../../player/provider";
import { BottomTabNavigationEventMap } from "@react-navigation/bottom-tabs";
import { NavigationHelpers, ParamListBase } from "@react-navigation/native";
import { BlurView } from "@react-native-community/blur";
import Icon from "../Global/helpers/icon";
import { Text } from "../Global/helpers/text";
import { Colors } from "../../enums/colors";
import { CachedImage } from "@georstat/react-native-image-cache";
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import { queryConfig } from "../../api/queries/query.config";
import { QueryConfig } from "../../api/queries/query.config";
import TextTicker from 'react-native-text-ticker';
import PlayPauseButton from "./helpers/buttons";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import Client from "../../api/client";
import { TextTickerConfig } from "./component.config";
export function Miniplayer({ navigation }: { navigation : NavigationHelpers<ParamListBase, BottomTabNavigationEventMap> }) : React.JSX.Element {
const theme = useTheme();
const { nowPlaying, useSkip } = usePlayerContext();
const { width } = useSafeAreaFrame();
return (
<BlurView overlayColor={Colors.Background}>
<View style={{
backgroundColor: theme.background.val,
borderColor: theme.borderColor.val
}}>
{ nowPlaying && (
<XStack
@@ -41,7 +46,7 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
.getItemImageUrlById(
nowPlaying!.item.AlbumId ?? "",
ImageType.Primary,
{ ...queryConfig.images }
{ ...QueryConfig.images }
)
}
imageStyle={{
@@ -55,22 +60,17 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
</YStack>
<YStack alignContent="flex-start" flex={4} maxWidth={"$20"}>
<TextTicker
duration={5000}
loop
repeatSpacer={20}
marqueeDelay={1000}
>
<YStack
alignContent="flex-start"
marginLeft={"$0.5"}
flex={4}
maxWidth={"$20"}
>
<TextTicker {...TextTickerConfig}>
<Text bold>{nowPlaying?.title ?? "Nothing Playing"}</Text>
</TextTicker>
<TextTicker
duration={5000}
loop
repeatSpacer={20}
marqueeDelay={1000}
>
<TextTicker {...TextTickerConfig}>
<Text color={Colors.Primary}>{nowPlaying?.artist ?? ""}</Text>
</TextTicker>
</YStack>
@@ -89,6 +89,6 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
</XStack>
</XStack>
)}
</BlurView>
</View>
)
}

View File

@@ -1,11 +1,7 @@
import { queryConfig } from "../../../api/queries/query.config";
import { HorizontalSlider } from "../../../components/Global/helpers/slider";
import { RunTimeSeconds } from "../../../components/Global/helpers/time-codes";
import { StackParamList } from "../../../components/types";
import { usePlayerContext } from "../../../player/provider";
import { CachedImage } from "@georstat/react-native-image-cache";
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import React, { useState, useEffect } from "react";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
@@ -14,9 +10,10 @@ import PlayPauseButton from "../helpers/buttons";
import { H5, Text } from "../../../components/Global/helpers/text";
import Icon from "../../../components/Global/helpers/icon";
import { Colors } from "../../../enums/colors";
import { State } from "react-native-track-player";
import FavoriteHeaderButton from "../../Global/components/favorite-button";
import Client from "../../../api/client";
import FavoriteButton from "../../Global/components/favorite-button";
import BlurhashedImage from "../../../components/Global/helpers/blurhashed-image";
import TextTicker from "react-native-text-ticker";
import { TextTickerConfig } from "../component.config";
export default function PlayerScreen({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
@@ -59,13 +56,17 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
<>
<YStack>
<YStack alignItems="center">
<YStack
alignItems="center"
alignContent="center"
>
<Text>Playing from</Text>
<H5>{ queueName ?? "Queue"}</H5>
<TextTicker {...TextTickerConfig}>
<H5>{ queueName ?? "Queue"}</H5>
</TextTicker>
</YStack>
<XStack
animation={"bouncy"}
justifyContent="center"
alignContent="center"
minHeight={width / 1.1}
@@ -73,60 +74,53 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
useTogglePlayback.mutate(undefined)
}}
>
<CachedImage
source={getImageApi(Client.api!)
.getItemImageUrlById(
nowPlaying!.item.AlbumId ?? "",
ImageType.Primary,
{ ...queryConfig.playerArtwork }
)
}
imageStyle={{
position: "relative",
alignSelf: "center",
width: playbackState === State.Playing ? width / 1.1 : width / 1.4,
height: playbackState === State.Playing ? width / 1.1 : width / 1.4,
borderRadius: 2
}}
<BlurhashedImage
item={nowPlaying!.item}
size={width / 1.1}
/>
</XStack>
<XStack marginHorizontal={20} paddingVertical={5}>
<YStack justifyContent="flex-start" flex={4}>
<Text
bold
fontSize={"$6"}
>
{nowPlaying!.title ?? "Untitled Track"}
</Text>
<TextTicker {...TextTickerConfig}>
<Text
bold
fontSize={"$6"}
>
{nowPlaying!.title ?? "Untitled Track"}
</Text>
</TextTicker>
<Text
fontSize={"$6"}
color={Colors.Primary}
onPress={() => {
if (nowPlaying!.item.ArtistItems) {
navigation.goBack(); // Dismiss player modal
navigation.push("Artist", {
artist: nowPlaying!.item.ArtistItems![0],
});
}
}}
>
{nowPlaying.artist ?? "Unknown Artist"}
</Text>
<TextTicker {...TextTickerConfig}>
<Text
fontSize={"$6"}
color={Colors.Primary}
onPress={() => {
if (nowPlaying!.item.ArtistItems) {
navigation.navigate("Artist", {
artist: nowPlaying!.item.ArtistItems![0],
});
}
}}
>
{nowPlaying.artist ?? "Unknown Artist"}
</Text>
</TextTicker>
<Text
fontSize={"$6"}
color={"$gray10"}
>
{ nowPlaying!.album ?? "" }
</Text>
<TextTicker {...TextTickerConfig}>
<Text
fontSize={"$6"}
color={"$purpleGray"}
>
{ nowPlaying!.album ?? "" }
</Text>
</TextTicker>
</YStack>
<XStack
justifyContent="flex-end"
alignItems="center"
flex={1}
flex={2}
>
{/* Buttons for favorites, song menu go here */}
@@ -134,14 +128,15 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
name="dots-horizontal-circle-outline"
onPress={() => {
navigation.navigate("Details", {
item: nowPlaying!.item
item: nowPlaying!.item,
isNested: true
});
}}
/>
<Spacer />
<FavoriteHeaderButton
<FavoriteButton
item={nowPlaying!.item}
onToggle={() => setNowPlayingIsFavorite(!nowPlayingIsFavorite)}
/>

View File

@@ -34,6 +34,7 @@ export default function Queue({ navigation }: { navigation: NativeStackNavigatio
console.debug(`Skipping to index ${index}`)
useSkip.mutate(index);
}}
isNested
/>
)
}}

View File

@@ -35,7 +35,7 @@ export default function Player({ navigation }: { navigation: NativeStackNavigati
name="Details"
component={DetailsScreen}
options={{
headerShown: false
headerTitle: ""
}}
/>

View File

@@ -8,7 +8,7 @@ import { RunTimeTicks } from "../Global/helpers/time-codes";
import { H4, H5, Text } from "../Global/helpers/text";
import Track from "../Global/components/track";
import { FlatList } from "react-native";
import { queryConfig } from "../../api/queries/query.config";
import { QueryConfig } from "../../api/queries/query.config";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api/image-api";
import { CachedImage } from "@georstat/react-native-image-cache";
import { SafeAreaView } from "react-native-safe-area-context";
@@ -41,7 +41,7 @@ export default function Playlist(props: PlaylistProps): React.JSX.Element {
.getItemImageUrlById(
props.playlist.Id!,
ImageType.Primary,
{ ...queryConfig.images})}
{ ...QueryConfig.images})}
imageStyle={{
position: "relative",
width: 300,
@@ -74,7 +74,7 @@ export default function Playlist(props: PlaylistProps): React.JSX.Element {
<XStack justifyContent="flex-end">
<Text
color={"$gray10"}
color={"$purpleGray"}
style={{ display: "block"}}
>
Total Runtime:

View File

@@ -1,10 +1,57 @@
import React from "react";
import { View } from "tamagui";
import React, { useCallback, useState } from "react";
import Input from "../Global/helpers/input";
import { debounce } from "lodash";
import Item from "../Global/components/item";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import { QueryKeys } from "../../enums/query-keys";
import { fetchSearchResults } from "../../api/queries/functions/search";
import { useQuery } from "@tanstack/react-query";
import { FlatList } from "react-native";
export default function Search({
navigation
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const [searchString, setSearchString] = useState<string | undefined>(undefined);
const { data: items, refetch, isFetched, isFetching } = useQuery({
queryKey: [QueryKeys.Search, searchString],
queryFn: () => fetchSearchResults(searchString)
})
const search = useCallback(
debounce(() => {
refetch();
}, 750),
[]
);
const handleSearchStringUpdate = (value: string | undefined) => {
setSearchString(value)
search();
}
export default function Search(): React.JSX.Element {
return (
<View>
</View>
<FlatList
contentInsetAdjustmentBehavior="automatic"
progressViewOffset={10}
ListHeaderComponent={(
<Input
placeholder="The Seeker"
onChangeText={(value) => handleSearchStringUpdate(value)}
value={searchString}
/>
)}
data={items}
refreshing={isFetching}
renderItem={({ index, item }) => {
return (
<Item item={item} queueName={searchString ?? "Search"} navigation={navigation} />
)
}}
/>
)
}

View File

@@ -0,0 +1,17 @@
import { RouteProp } from "@react-navigation/native";
import { StackParamList } from "../types";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import React from "react";
import Search from "./component";
export default function SearchScreen({
route,
navigation
} : {
route: RouteProp<StackParamList>,
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
return (
<Search navigation={navigation} />
)
}

View File

@@ -0,0 +1,67 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack"
import SearchScreen from "./screen";
import { StackParamList } from "../types";
import { ArtistScreen } from "../Artist/screens";
import { AlbumScreen } from "../Album/screens";
import { PlaylistScreen } from "../Playlist/screens";
import DetailsScreen from "../ItemDetail/screen";
const Stack = createNativeStackNavigator<StackParamList>();
export default function SearchStack() : React.JSX.Element {
return (
<Stack.Navigator
id="Search"
>
<Stack.Screen
name="Search"
component={SearchScreen}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
}}
/>
<Stack.Screen
name="Artist"
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? "Unknown Artist",
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
})}
/>
<Stack.Screen
name="Album"
component={AlbumScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<Stack.Screen
name="Playlist"
component={PlaylistScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<Stack.Screen
name="Details"
component={DetailsScreen}
options={{
headerShown: false,
presentation: "modal"
}}
/>
</Stack.Navigator>
)
}

View File

@@ -1,13 +1,14 @@
import { StackParamList } from "@/components/types";
import { StackParamList } from "../../../components/types";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import AccountDetails from "../helpers/account-details";
export default function AccountDetails({
export default function AccountDetailsScreen({
navigation
} : {
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
return (
<AccountDetails navigation={navigation} />
<AccountDetails />
)
}

View File

@@ -1,12 +1,12 @@
import React from "react";
import { SafeAreaView } from "react-native";
import { ListItem, ScrollView, Separator, YGroup } from "tamagui";
import AccountDetails from "../helpers/account-details";
import SignOut from "../helpers/sign-out";
import ServerDetails from "../helpers/server-details";
import LibraryDetails from "../helpers/library-details";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "@/components/types";
import { StackParamList } from "../../../components/types";
import { useSafeAreaFrame } from "react-native-safe-area-context";
export default function Root({
navigation
@@ -14,33 +14,33 @@ export default function Root({
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
return (
<SafeAreaView>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<YGroup
alignSelf="center"
bordered
width={240}
size="$5"
>
<YGroup.Item>
<ListItem
hoverTheme
pressTheme
title="Account Details"
subTitle="Everything is about you, man"
onPress={() => {
navigation.push("AccountDetails")
}}
/>
</YGroup.Item>
</YGroup>
const { width } = useSafeAreaFrame();
<ServerDetails />
<Separator marginVertical={15} />
<LibraryDetails />
<SignOut />
</ScrollView>
</SafeAreaView>
return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
<YGroup
alignSelf="center"
bordered
width={width / 1.5}
size="$5"
>
<YGroup.Item>
<ListItem
hoverTheme
pressTheme
title="Account Details"
subTitle="Everything is about you, man"
onPress={() => {
navigation.push("AccountDetails")
}}
/>
</YGroup.Item>
</YGroup>
<ServerDetails />
<Separator marginVertical={15} />
<LibraryDetails />
<SignOut />
</ScrollView>
)
}

View File

@@ -21,9 +21,10 @@ export default function Settings(): React.JSX.Element {
/>
<SettingsStack.Screen
name="Account"
name="AccountDetails"
component={AccountDetails}
options={{
title: "Account",
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'

View File

@@ -1,13 +0,0 @@
import { createStackNavigator } from "@react-navigation/stack"
import { NowPlaying } from "./CarPlay/NowPlaying";
const Stack = createStackNavigator();
export default function JellifyCarplay(): React.JSX.Element {
return (
<Stack.Navigator>
<Stack.Screen name="NowPlaying" component={NowPlaying} />
</Stack.Navigator>
)
}

View File

@@ -12,7 +12,8 @@ import { PortalProvider } from "tamagui";
import Client from "../api/client";
import { JellifyProvider, useJellifyContext } from "./provider";
import { CarPlay } from "react-native-carplay"
import JellifyCarplay from "./carplay";
import { createStackNavigator } from "@react-navigation/stack";
import CarPlayHome from "./CarPlay/Home";
export default function Jellify(): React.JSX.Element {
@@ -31,43 +32,7 @@ function App(): React.JSX.Element {
const { loggedIn } = useJellifyContext();
const [carPlayConnected, setCarPlayConnected] = useState(CarPlay.connected);
useEffect(() => {
console.debug("Client instance changed")
}, [
Client.instance
])
useEffect(() => {
function onConnect() {
setCarPlayConnected(true)
}
function onDisconnect() {
setCarPlayConnected(false)
}
CarPlay.registerOnConnect(onConnect);
CarPlay.registerOnDisconnect(onDisconnect);
return () => {
CarPlay.unregisterOnConnect(onConnect)
CarPlay.unregisterOnDisconnect(onDisconnect)
};
});
return carPlayConnected ? (
<NavigationContainer>
{ loggedIn ? (
<JellifyCarplay />
) : (
<View>
<Text>Please login in the app before using CarPlay</Text>
</View>
)}
</NavigationContainer>
) : (
return (
<NavigationContainer theme={isDarkMode ? JellifyDarkTheme : JellifyLightTheme}>
<SafeAreaProvider>
{ loggedIn ? (

View File

@@ -1,10 +1,11 @@
import Client from "../api/client";
import { isUndefined } from "lodash";
import { createContext, ReactNode, useContext, useState } from "react";
import { createContext, ReactNode, useContext, useEffect, useState } from "react";
import { CarPlay } from "react-native-carplay";
interface JellifyContext {
loggedIn: boolean;
setLoggedIn: React.Dispatch<React.SetStateAction<boolean>>;
carPlayConnected: boolean;
}
const JellifyContextInitializer = () => {
@@ -15,15 +16,35 @@ const JellifyContextInitializer = () => {
!isUndefined(Client.server)
);
const [carPlayConnected, setCarPlayConnected] = useState(CarPlay.connected);
useEffect(() => {
function onConnect() {
setCarPlayConnected(true)
}
function onDisconnect() {
setCarPlayConnected(false)
}
CarPlay.registerOnConnect(onConnect);
CarPlay.registerOnDisconnect(onDisconnect);
return () => {
CarPlay.unregisterOnConnect(onConnect)
CarPlay.unregisterOnDisconnect(onDisconnect)
};
});
return {
loggedIn,
setLoggedIn,
carPlayConnected
}
}
const JellifyContext = createContext<JellifyContext>({
loggedIn: false,
setLoggedIn: () => {}
carPlayConnected: false
});
export const JellifyProvider: ({ children }: {
@@ -31,14 +52,14 @@ export const JellifyProvider: ({ children }: {
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const {
loggedIn,
setLoggedIn
carPlayConnected
} = JellifyContextInitializer();
return (
<JellifyContext.Provider
value={{
loggedIn,
setLoggedIn
carPlayConnected
}}
>
{children}

View File

@@ -4,13 +4,13 @@ import Home from "./Home/component";
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons";
import { useColorScheme } from "react-native";
import { Colors } from "../enums/colors";
import Search from "./Search/component";
import Favorites from "./Favorites/component";
import Settings from "./Settings/stack";
import { Discover } from "./Discover/component";
import { Miniplayer } from "./Player/mini-player";
import { Separator } from "tamagui";
import { usePlayerContext } from "../player/provider";
import SearchStack from "./Search/stack";
const Tab = createBottomTabNavigator();
@@ -63,8 +63,9 @@ export function Tabs() : React.JSX.Element {
<Tab.Screen
name="Search"
component={Search}
component={SearchStack}
options={{
headerShown: false,
tabBarIcon: ({color, size }) => (
<MaterialCommunityIcons name="magnify" color={color} size={size} />
)

View File

@@ -34,7 +34,8 @@ export type StackParamList = {
playlist: BaseItemDto
};
Details: {
item: BaseItemDto
item: BaseItemDto,
isNested: boolean | undefined
}
}

View File

@@ -1,6 +1,6 @@
export enum Colors {
Primary = "#cc2f71", // Telemagenta
Secondary = "#514C63", // English Violet
Secondary = "#66617B", // Wisteria
Borders = "#100538", // Russian Violet
Background = "#070217", // Rich Black

View File

@@ -35,4 +35,7 @@ export enum QueryKeys {
FavoriteTracks = "FavoriteTracks",
UserData = "UserData",
UpdatePlayerOptions = "UpdatePlayerOptions",
Item = "Item",
Search = "Search",
SearchSuggestions = "SearchSuggestions",
}

View File

@@ -1,22 +1,19 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { JellifyTrack } from "../types/JellifyTrack";
import { RatingType, TrackType } from "react-native-track-player";
import { Api } from "@jellyfin/sdk";
import { QueuingType } from "../enums/queuing-type";
import querystring from "querystring"
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../api/client";
import { isUndefined } from "lodash";
const container = "opus,mp3,aac,m4a,flac,webma,webm,wav,ogg,mpa,wma";
// TODO: Make this configurable
const transcodingContainer = "m4a";
export function mapDtoToTrack(item: BaseItemDto, queuingType?: QueuingType) : JellifyTrack {
const urlParams = {
"Container": container,
"Container": item.Container,
"TranscodingContainer": transcodingContainer,
"TranscodingProtocol": "hls",
"EnableRemoteMedia": true,

View File

@@ -6,6 +6,7 @@ import { PlaybackService } from './player/service'
import TrackPlayer from 'react-native-track-player';
import Client from './api/client';
Client.instance;
AppRegistry.registerComponent(appName, () => App);
TrackPlayer.registerPlaybackService(() => PlaybackService);
Client.instance;
AppRegistry.registerComponent('RNCarPlayScene', () => App)
TrackPlayer.registerPlaybackService(() => PlaybackService);

View File

@@ -1,4 +1,5 @@
#import "RNCarPlay.h"
#import <RCTAppDelegate.h>
#ifdef DEBUG
#ifdef FB_SONARKIT_ENABLED

View File

@@ -47,6 +47,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, RCTBridgeDelegate {
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
}
override func bundleURL() -> URL? {
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index");
}
private func initializeFlipper(with application: UIApplication) {
#if DEBUG
#if FB_SONARKIT_ENABLED

23
package-lock.json generated
View File

@@ -29,6 +29,7 @@
"nativewind": "4.0.36",
"react": "18.3.1",
"react-native": "0.75.2",
"react-native-background-actions": "^4.0.1",
"react-native-blurhash": "^2.1.0",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-device-info": "^11.1.0",
@@ -9796,6 +9797,12 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -15417,6 +15424,22 @@
}
}
},
"node_modules/react-native-background-actions": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/react-native-background-actions/-/react-native-background-actions-4.0.1.tgz",
"integrity": "sha512-LADhnb4ag1oH5Lotq0j8K9e2cFmrafFyg2PCME88VkTjqDUgNcJonkNdMCTHN0N3fh+hwAA7nDR4Cxkj9Q8eCw==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.7"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/Rapsssito"
},
"peerDependencies": {
"react-native": ">=0.47.0"
}
},
"node_modules/react-native-blurhash": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/react-native-blurhash/-/react-native-blurhash-2.1.0.tgz",

View File

@@ -31,6 +31,7 @@
"nativewind": "4.0.36",
"react": "18.3.1",
"react-native": "0.75.2",
"react-native-background-actions": "^4.0.1",
"react-native-blurhash": "^2.1.0",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-device-info": "^11.1.0",

View File

@@ -1,7 +0,0 @@
<!DOCTYPE html>
<head>
<title>Jellify: A music app for Jellyfin</title>
</head>
<body>
<p>Hello world!</p>
</body>

View File

@@ -1 +1 @@
export const UPDATE_INTERVAL: number = 1000
export const UPDATE_INTERVAL: number = 250 // We need to do better rounding in the player scrubber before lowering this value for silky smoothness

View File

@@ -1,17 +1,18 @@
import { isEmpty } from "lodash";
import { QueuingType } from "../../enums/queuing-type";
import { JellifyTrack } from "../../types/JellifyTrack";
import { getActiveTrackIndex } from "react-native-track-player/lib/src/trackPlayer";
/**
* Finds and returns the index of the player queue to insert additional tracks into
* @param playQueue The current player queue
* @returns The index to insert songs to play next at
*/
export const findPlayNextIndexStart = (playQueue: JellifyTrack[]) => {
if (playQueue.length > 0)
return 1
export const findPlayNextIndexStart = async (playQueue: JellifyTrack[]) => {
if (isEmpty(playQueue))
return 0;
return 0;
return (await getActiveTrackIndex())! + 1;
}
/**
@@ -19,10 +20,18 @@ export const findPlayNextIndexStart = (playQueue: JellifyTrack[]) => {
* @param playQueue The current player queue
* @returns The index to insert songs to add to the user queue
*/
export const findPlayQueueIndexStart = (playQueue: JellifyTrack[]) => {
export const findPlayQueueIndexStart = async (playQueue: JellifyTrack[]) => {
if (isEmpty(playQueue))
return 0;
return playQueue.findIndex(queuedTrack => queuedTrack.QueuingType === QueuingType.FromSelection);
const activeIndex = await getActiveTrackIndex();
if (playQueue.findIndex(track => track.QueuingType === QueuingType.FromSelection) === -1)
return activeIndex! + 1
return playQueue.findIndex((queuedTrack, index) =>
queuedTrack.QueuingType === QueuingType.FromSelection &&
index > activeIndex!
);
}

View File

@@ -11,8 +11,8 @@ const CAPABILITIES: Capability[] = [
// Capability.JumpBackward,
Capability.SkipToNext,
Capability.SkipToPrevious,
Capability.Like,
Capability.Dislike
// Capability.Like,
// Capability.Dislike
]
export const useSetupPlayer = () => useQuery({
@@ -31,15 +31,15 @@ export const useSetupPlayer = () => useQuery({
capabilities: CAPABILITIES,
notificationCapabilities: CAPABILITIES,
compactCapabilities: CAPABILITIES,
ratingType: RatingType.Heart,
likeOptions: {
isActive: false,
title: "Favorite"
},
dislikeOptions: {
isActive: true,
title: "Unfavorite"
}
// ratingType: RatingType.Heart,
// likeOptions: {
// isActive: false,
// title: "Favorite"
// },
// dislikeOptions: {
// isActive: true,
// title: "Unfavorite"
// }
});
});
}

View File

@@ -1,3 +1,4 @@
import { QueuingType } from "../enums/queuing-type";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
export interface QueueMutation {
@@ -5,4 +6,10 @@ export interface QueueMutation {
index?: number | undefined;
tracklist: BaseItemDto[];
queueName: string;
queuingType?: QueuingType | undefined;
}
export interface AddToQueueMutation {
track: BaseItemDto,
queuingType?: QueuingType | undefined;
}

View File

@@ -2,21 +2,21 @@ import { createContext, ReactNode, SetStateAction, useContext, useEffect, useSta
import { JellifyTrack } from "../types/JellifyTrack";
import { storage } from "../constants/storage";
import { MMKVStorageKeys } from "../enums/mmkv-storage-keys";
import { findPlayQueueIndexStart } from "./helpers/index";
import TrackPlayer, { Event, Progress, State, usePlaybackState, useProgress, useTrackPlayerEvents } from "react-native-track-player";
import { findPlayNextIndexStart, findPlayQueueIndexStart } from "./helpers/index";
import TrackPlayer, { Event, Progress, State, Track, usePlaybackState, useProgress, useTrackPlayerEvents } from "react-native-track-player";
import _, { isEqual, isUndefined } from "lodash";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { handlePlaybackProgressUpdated, handlePlaybackState } from "./handlers";
import { useSetupPlayer, useUpdateOptions } from "../player/hooks";
import { UPDATE_INTERVAL } from "./config";
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { QueueMutation } from "./interfaces";
import { mapDtoToTrack } from "../helpers/mappings";
import { QueuingType } from "../enums/queuing-type";
import { trigger } from "react-native-haptic-feedback";
import { getQueue, pause, seekTo, skip, skipToNext, skipToPrevious } from "react-native-track-player/lib/src/trackPlayer";
import { convertRunTimeTicksToSeconds } from "..//helpers/runtimeticks";
import { convertRunTimeTicksToSeconds } from "../helpers/runtimeticks";
import Client from "../api/client";
import { AddToQueueMutation, QueueMutation } from "./interfaces";
interface PlayerContext {
showPlayer: boolean;
@@ -28,6 +28,7 @@ interface PlayerContext {
nowPlaying: JellifyTrack | undefined;
queue: JellifyTrack[];
queueName: string | undefined;
useAddToQueue: UseMutationResult<void, Error, AddToQueueMutation, unknown>;
useTogglePlayback: UseMutationResult<void, Error, number | undefined, unknown>;
useSeekTo: UseMutationResult<void, Error, number, unknown>;
useSkip: UseMutationResult<void, Error, number | undefined, unknown>;
@@ -74,7 +75,7 @@ const PlayerContextInitializer = () => {
}
const addToQueue = async (tracks: JellifyTrack[]) => {
let insertIndex = findPlayQueueIndexStart(queue);
const insertIndex = await findPlayQueueIndexStart(queue);
console.debug(`Adding ${tracks.length} to queue at index ${insertIndex}`)
await TrackPlayer.add(tracks, insertIndex);
@@ -83,12 +84,36 @@ const PlayerContextInitializer = () => {
setShowMiniplayer(true);
}
const addToNext = async (tracks: JellifyTrack[]) => {
const insertIndex = await findPlayNextIndexStart(queue);
console.debug(`Adding ${tracks.length} to queue at index ${insertIndex}`);
await TrackPlayer.add(tracks, insertIndex);
setQueue(await getQueue() as JellifyTrack[]);
setShowMiniplayer(true);
}
//#endregion Functions
//#region Hooks
const useAddToQueue = useMutation({
mutationFn: async (mutation: AddToQueueMutation) => {
trigger("impactMedium");
if (mutation.queuingType === QueuingType.PlayingNext)
return addToNext([mapDtoToTrack(mutation.track, mutation.queuingType)]);
else
return addToQueue([mapDtoToTrack(mutation.track, mutation.queuingType)])
}
})
const useTogglePlayback = useMutation({
mutationFn: async (index?: number | undefined) => {
trigger("impactLight");
trigger("impactMedium");
if (playbackState === State.Playing)
await pause();
else
@@ -98,7 +123,7 @@ const PlayerContextInitializer = () => {
const useSeekTo = useMutation({
mutationFn: async (position: number) => {
trigger('impactLight');
trigger('impactMedium');
await seekTo(position);
handlePlaybackProgressUpdated(Client.sessionId, playStateApi, nowPlaying!, {
@@ -111,7 +136,7 @@ const PlayerContextInitializer = () => {
const useSkip = useMutation({
mutationFn: async (index?: number | undefined) => {
trigger("impactLight")
trigger("impactMedium")
if (!isUndefined(index)) {
setIsSkipping(true);
setNowPlaying(queue[index]);
@@ -128,7 +153,7 @@ const PlayerContextInitializer = () => {
const usePrevious = useMutation({
mutationFn: async () => {
trigger("impactLight");
trigger("impactMedium");
const nowPlayingIndex = queue.findIndex((track) => track.item.Id === nowPlaying!.item.Id);
@@ -141,7 +166,7 @@ const PlayerContextInitializer = () => {
const usePlayNewQueue = useMutation({
mutationFn: async (mutation: QueueMutation) => {
trigger("impactLight");
trigger("impactMedium");
setIsSkipping(true);
@@ -261,6 +286,7 @@ const PlayerContextInitializer = () => {
nowPlaying,
queue,
queueName,
useAddToQueue,
useTogglePlayback,
useSeekTo,
useSkip,
@@ -283,6 +309,24 @@ export const PlayerContext = createContext<PlayerContext>({
nowPlaying: undefined,
queue: [],
queueName: undefined,
useAddToQueue: {
mutate: () => {},
mutateAsync: async () => {},
data: undefined,
error: null,
variables: undefined,
isError: false,
isIdle: true,
isPaused: false,
isPending: false,
isSuccess: false,
status: "idle",
reset: () => {},
context: {},
failureCount: 0,
failureReason: null,
submittedAt: 0
},
useTogglePlayback: {
mutate: () => {},
mutateAsync: async () => {},
@@ -389,6 +433,7 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
nowPlaying,
queue,
queueName,
useAddToQueue,
useTogglePlayback,
useSeekTo,
useSkip,
@@ -408,6 +453,7 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
nowPlaying,
queue,
queueName,
useAddToQueue,
useTogglePlayback,
useSeekTo,
useSkip,

View File

@@ -2,7 +2,7 @@ import Client from "../api/client";
import { JellifyTrack } from "../types/JellifyTrack";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import TrackPlayer, { Event, RatingType } from "react-native-track-player";
import { getActiveTrack } from "react-native-track-player/lib/src/trackPlayer";
import { getActiveTrack, getActiveTrackIndex } from "react-native-track-player/lib/src/trackPlayer";
/**
* Jellify Playback Service.
@@ -41,50 +41,31 @@ export async function PlaybackService() {
TrackPlayer.addEventListener(Event.RemoteLike, async () => {
const progress = await TrackPlayer.getProgress();
const nowPlaying = await getActiveTrack() as JellifyTrack;
const nowPlayingIndex = await getActiveTrackIndex();
await getUserLibraryApi(Client.api!)
.markFavoriteItem({
itemId: nowPlaying.item.Id!
});
await TrackPlayer.updateOptions({
likeOptions: {
isActive: false,
title: "Favorite"
},
dislikeOptions: {
isActive: true,
title: "Unfavorite"
}
});
await TrackPlayer.updateMetadataForTrack(nowPlayingIndex!, {
rating: RatingType.Heart
})
});
TrackPlayer.addEventListener(Event.RemoteDislike, async () => {
const progress = await TrackPlayer.getProgress();
const nowPlaying = await getActiveTrack() as JellifyTrack;
const nowPlayingIndex = await getActiveTrackIndex();
await getUserLibraryApi(Client.api!)
.markFavoriteItem({
itemId: nowPlaying.item.Id!
});
await TrackPlayer.updateNowPlayingMetadata({
elapsedTime: progress.position,
await TrackPlayer.updateMetadataForTrack(nowPlayingIndex!, {
rating: undefined
});
await TrackPlayer.updateOptions({
likeOptions: {
isActive: true,
title: "Favorite"
},
dislikeOptions: {
isActive: false,
title: "Unfavorite"
}
});
});
}

View File

@@ -1,7 +1,19 @@
import { animations, tokens, themes, media, shorthands } from '@tamagui/config/v3'
import { createTamagui } from 'tamagui' // or '@tamagui/core'
import { animations, tokens as TamaguiTokens, media, shorthands } from '@tamagui/config/v3'
import { createTamagui, createTokens } from 'tamagui' // or '@tamagui/core'
import { headingFont, bodyFont } from './fonts.config'
const tokens = createTokens({
...TamaguiTokens,
color: {
purpleDark: "#070217",
purple: "#100538",
purpleGray: "#66617B",
telemagenta: "#cc2f71",
white: "#ffffff",
black: "#000000"
},
})
const jellifyConfig = createTamagui({
animations,
fonts:{
@@ -11,7 +23,18 @@ const jellifyConfig = createTamagui({
media,
shorthands,
tokens,
themes,
themes: {
dark: {
background: tokens.color.purpleDark,
borderColor: tokens.color.purpleGray,
color: tokens.color.white
},
light: {
background: tokens.color.white,
borderColor: tokens.color.purpleGray,
color: tokens.color.purpleDark
}
}
});
export type JellifyConfig = typeof jellifyConfig