Merge pull request #51 from anultravioletaurora/9-implement-playlist-crud
9 implement playlist crud
@@ -14,7 +14,7 @@ jobs:
|
||||
node-version: 20
|
||||
|
||||
- name: Echo package.json version to Github ENV
|
||||
run: echo VERISON_NUMBER=$(node -p -e "require('./package.json').version") >> $GITHUB_ENV
|
||||
run: echo VERSION_NUMBER=$(node -p -e "require('./package.json').version") >> $GITHUB_ENV
|
||||
|
||||
- run: npm run init
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
node-version: 20
|
||||
|
||||
- name: Echo package.json version to Github ENV
|
||||
run: echo VERISON_NUMBER=$(npm version minor --tag-version-prefix="" --no-commit-hooks) >> $GITHUB_ENV
|
||||
run: echo VERSION_NUMBER=$(npm version minor --tag-version-prefix="" --no-commit-hooks) >> $GITHUB_ENV
|
||||
|
||||
- run: npm run init
|
||||
|
||||
@@ -37,4 +37,12 @@ jobs:
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "[skip actions]"
|
||||
file_pattern: "ios/Jellify.xcodeproj/project.pbxproj"
|
||||
file_pattern: "ios/Jellify.xcodeproj/project.pbxproj"
|
||||
|
||||
- name: Create Github Release
|
||||
uses: elgohr/Github-Release-Action@v5
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
title: "Jellify - ${{ env.VERSION_NUMBER }}"
|
||||
tag: ${{ env.VERSION_NUMBER }}
|
||||
@@ -1,29 +1,34 @@
|
||||
# Jellify (verb) - to make gelatinous
|
||||
# Jellify
|
||||

|
||||
|
||||
A music player for [Jellyfin](https://jellyfin.org/) built with [React Native](https://reactnative.dev/).
|
||||
jellify (verb) - to make gelatinous
|
||||
|
||||
*Jellify* is a music player for [Jellyfin](https://jellyfin.org/) built with [React Native](https://reactnative.dev/). It has a UX meant to feel familiar if you’ve used other music streaming apps.
|
||||
|
||||
### Background
|
||||
I wanted to create a music app that could handle extremely large music libraries (i.e., 100K+ songs) and not get bogged down. I wanted to avoid syncing the database to the device when the library changes, and instead opt for a heavily cached persistence layer instead. I discovered Tanstack Query and combined it with React Native MMKV and the rest was history!
|
||||
I was after a music app for Jellyfin that showcased my music with artwork and had the ability to algorithmically curate music (not that you have to use *Jellify* that way). I also wanted to create a music app that could handle my extremely large music libraries (i.e., 100K+ songs) and not get bogged down. The end goal was to build a music streaming app that worked like the big guys, all while being FOSS and powered by self hosting.
|
||||
|
||||
This app was designed with me and my dad in mind, since I wanted to give him a sleek, one stop shop for live recordings of bands he likes (read: the Grateful Dead), with a UI that he'd find instantly familiar and useful. CarPlay / Android Auto support is also a must for him.
|
||||
This app was designed with me and my dad in mind, since I wanted to give him a sleek, one stop shop for live recordings of bands he likes (read: the Grateful Dead). The UI was designed so that he'd find it instantly familiar and useful. CarPlay / Android Auto support was also a must for us, as we both use CarPlay religiously.
|
||||
|
||||
Designed to be lightweight and scalable, *Jellify* caters to those who want a music player experience similar to what's provided by music streaming services.
|
||||
**TL;DR** Designed to be lightweight and scalable, *Jellify* caters to those who want a mobile Jellyfin music experience similar to what's provided by the big music streaming services.
|
||||
|
||||
## Features
|
||||
### Current
|
||||
- Available via Private Testflight
|
||||
- iOS and Android support
|
||||
- Carefully crafted Light and Dark modes
|
||||
- Home screen access to previously played tracks, artists, and your playlists
|
||||
- Full Last.FM Plugin support
|
||||
- Library of Favorited Music, not too dissimilar to how streaming services handle your 'library'
|
||||
- Full playlist support, including creating, updating, and reordering
|
||||
|
||||
### Roadmap
|
||||
- Full playlist support, including creating, updating, and reordering
|
||||
- Quick access to similar artists and items for discovering music in your library
|
||||
- Support for Jellyfin mixes
|
||||
- CarPlay / Android Auto Support
|
||||
- Public Testflight
|
||||
- Offline Playback
|
||||
- Web / Desktop support
|
||||
|
||||
## Lemme see!
|
||||
### Home
|
||||
@@ -37,7 +42,7 @@ Designed to be lightweight and scalable, *Jellify* caters to those who want a mu
|
||||

|
||||
|
||||
### Player
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
@@ -62,7 +67,5 @@ This is undoubtedly a passion project of [mine](https://github.com/anultraviolet
|
||||
|
||||
## Special Thanks To
|
||||
- The [Jellyfin Team](https://jellyfin.org/) for their amazing server software
|
||||
- Tony, Alyssa for their contributions
|
||||
|
||||
## Running Locally
|
||||
Clone the repository and run ```npm i``` to install the dependencies
|
||||
- Tony and Jordan for their testing and feedback from the early stages of development
|
||||
- Alyssa, for your artistic abilities and giving Jellify the flair it needed
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import Client from "../../../api/client";
|
||||
import { getPlaylistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
|
||||
export async function addToPlaylist(track: BaseItemDto, playlist: BaseItemDto) {
|
||||
return getPlaylistsApi(Client.api!)
|
||||
.addItemToPlaylist({
|
||||
ids: [
|
||||
track.Id!
|
||||
],
|
||||
playlistId: playlist.Id!
|
||||
})
|
||||
}
|
||||
|
||||
export async function reorderPlaylist(playlistId: string, itemId: string, to: number) {
|
||||
return getPlaylistsApi(Client.api!)
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchFavoriteAlbums, fetchFavoriteArtists, fetchFavoriteTracks, fetchUserData } from "./functions/favorites";
|
||||
import { fetchFavoriteAlbums, fetchFavoriteArtists, fetchFavoritePlaylists, fetchFavoriteTracks, fetchUserData } from "./functions/favorites";
|
||||
|
||||
export const useFavoriteArtists = () => useQuery({
|
||||
queryKey: [QueryKeys.FavoriteArtists],
|
||||
queryFn: () => {
|
||||
|
||||
return fetchFavoriteArtists()
|
||||
}
|
||||
queryFn: () => fetchFavoriteArtists()
|
||||
});
|
||||
|
||||
export const useFavoriteAlbums = () => useQuery({
|
||||
queryKey: [QueryKeys.FavoriteAlbums],
|
||||
queryFn: () => {
|
||||
queryFn: () => fetchFavoriteAlbums()
|
||||
});
|
||||
|
||||
return fetchFavoriteAlbums()
|
||||
}
|
||||
export const useFavoritePlaylists = () => useQuery({
|
||||
queryKey: [QueryKeys.FavoritePlaylists],
|
||||
queryFn: () => fetchFavoritePlaylists()
|
||||
});
|
||||
|
||||
export const useFavoriteTracks = () => useQuery({
|
||||
|
||||
@@ -70,6 +70,40 @@ export function fetchFavoriteAlbums(): Promise<BaseItemDto[]> {
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchFavoritePlaylists(): Promise<BaseItemDto[]> {
|
||||
console.debug(`Fetching user's favorite playlists`);
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
getItemsApi(Client.api!)
|
||||
.getItems({
|
||||
userId: Client.user!.id,
|
||||
parentId: Client.library!.playlistLibraryId,
|
||||
fields: [
|
||||
"Path"
|
||||
],
|
||||
sortBy: [
|
||||
ItemSortBy.SortName
|
||||
],
|
||||
sortOrder: [
|
||||
SortOrder.Ascending
|
||||
]
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.Items)
|
||||
resolve(response.data.Items.filter(item =>
|
||||
item.UserData?.IsFavorite ||
|
||||
item.Path?.includes("/config/data/playlists")
|
||||
))
|
||||
else
|
||||
resolve([])
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchFavoriteTracks(): Promise<BaseItemDto[]> {
|
||||
console.debug(`Fetching user's favorite tracks`);
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ export function fetchUserPlaylists(): Promise<BaseItemDto[]> {
|
||||
]
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.Items) {
|
||||
console.log(response.data.Items);
|
||||
resolve(response.data.Items.filter(playlist => playlist.Path?.includes("/config/data/playlists")))
|
||||
}
|
||||
if (response.data.Items)
|
||||
resolve(response.data.Items.filter(playlist =>
|
||||
playlist.Path?.includes("/config/data/playlists")
|
||||
))
|
||||
else
|
||||
resolve([]);
|
||||
})
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchUserPlaylists } from "./functions/playlists";
|
||||
import { fetchFavoritePlaylists } from "./functions/favorites";
|
||||
|
||||
export const useFavoritePlaylists = () => useQuery({
|
||||
queryKey: [QueryKeys.FavoritePlaylists],
|
||||
queryFn: () => fetchFavoritePlaylists()
|
||||
});
|
||||
|
||||
export const useUserPlaylists = () => useQuery({
|
||||
queryKey: [QueryKeys.UserPlaylists],
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import React from "react"
|
||||
import { StackParamList } from "../types"
|
||||
import { RouteProp } from "@react-navigation/native"
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack"
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack"
|
||||
import Albums from "./component"
|
||||
|
||||
export default function AlbumsScreen({
|
||||
route,
|
||||
navigation
|
||||
} : {
|
||||
route: RouteProp<StackParamList, "Albums">,
|
||||
navigation: NativeStackNavigationProp<StackParamList, "Albums", undefined>
|
||||
}) : React.JSX.Element {
|
||||
export default function AlbumsScreen(
|
||||
props: NativeStackScreenProps<StackParamList, 'Albums'>
|
||||
) : React.JSX.Element {
|
||||
return (
|
||||
<Albums route={route} navigation={navigation}/>
|
||||
<Albums {...props} />
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import ArtistsScreen from "../Artists/screen";
|
||||
import AlbumsScreen from "../Albums/screen";
|
||||
import TracksScreen from "../Tracks/screen";
|
||||
import DetailsScreen from "../ItemDetail/screen";
|
||||
import PlaylistsScreen from "../Playlists/screen";
|
||||
|
||||
const LibraryStack = createNativeStackNavigator<StackParamList>();
|
||||
|
||||
@@ -82,6 +83,17 @@ export default function Library(): React.JSX.Element {
|
||||
}}
|
||||
/>
|
||||
|
||||
<LibraryStack.Screen
|
||||
name="Playlists"
|
||||
component={PlaylistsScreen}
|
||||
options={{
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleStyle: {
|
||||
fontFamily: 'Aileron-Bold'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<LibraryStack.Screen
|
||||
name="Playlist"
|
||||
component={PlaylistScreen}
|
||||
|
||||
@@ -9,7 +9,6 @@ const Categories : CategoryRoute[] = [
|
||||
{ name: "Albums", iconName: "music-box-multiple" },
|
||||
{ name: "Tracks", iconName: "music-note"},
|
||||
{ name: "Playlists", iconName: "playlist-music"},
|
||||
{ name: "Genres", iconName: "guitar-electric"}
|
||||
];
|
||||
|
||||
export default Categories;
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useItemImage } from "../../../api/queries/image";
|
||||
import { Blurhash } from "react-native-blurhash";
|
||||
import { Image, View } from "tamagui";
|
||||
import { View } from "tamagui";
|
||||
import { isEmpty } from "lodash";
|
||||
import { Image } from "react-native";
|
||||
|
||||
interface BlurhashLoadingProps {
|
||||
item: BaseItemDto;
|
||||
@@ -38,7 +39,8 @@ export default function BlurhashedImage({
|
||||
style={{
|
||||
height: height ?? width,
|
||||
width,
|
||||
borderRadius: cornered ? 2 : 25
|
||||
borderRadius: cornered ? 2 : 25,
|
||||
resizeMode: "contain"
|
||||
}}
|
||||
/>
|
||||
) : blurhash && (
|
||||
|
||||
@@ -8,6 +8,9 @@ import { useUserData } from "../../../api/queries/favorites";
|
||||
import { getTokens, Spinner } from "tamagui";
|
||||
import Client from "../../../api/client";
|
||||
import { usePlayerContext } from "../../..//player/provider";
|
||||
import { queryClient } from "../../../constants/query-client";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
|
||||
interface SetFavoriteMutation {
|
||||
item: BaseItemDto,
|
||||
@@ -35,8 +38,22 @@ export default function FavoriteButton({
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
trigger("notificationSuccess");
|
||||
|
||||
setIsFavorite(true);
|
||||
onToggle ? onToggle() : {};
|
||||
|
||||
// Force refresh of track user data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.UserData, item.Id],
|
||||
exact: true
|
||||
});
|
||||
|
||||
if (item.Type === 'Audio') {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.ItemTracks]
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ItemCard(props: CardProps) {
|
||||
|
||||
const dimensions = props.width && typeof(props.width) === "number" ? { width: props.width, height: props.width } : { width: 150, height: 150 };
|
||||
|
||||
const logoDimensions = props.width && typeof(props.width) === "number" ? { width: props.width / 2, height: props.width / 6 }: { width: 100, height: 30 };
|
||||
const logoDimensions = props.width && typeof(props.width) === "number" ? { width: props.width / 2, height: props.width / 6 }: { width: 100, height: 75 };
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -40,6 +40,7 @@ export function ItemCard(props: CardProps) {
|
||||
<TamaguiCard.Footer padded>
|
||||
{ props.item.Type === 'MusicArtist' && (
|
||||
<BlurhashedImage
|
||||
cornered
|
||||
item={props.item}
|
||||
type={ImageType.Logo}
|
||||
width={logoDimensions.width}
|
||||
|
||||
@@ -2,10 +2,12 @@ import React from "react";
|
||||
import { Square, Theme } from "tamagui";
|
||||
import Icon from "./icon";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import { Text } from "./text";
|
||||
|
||||
interface IconButtonProps {
|
||||
onPress: () => void;
|
||||
name: string;
|
||||
title?: string | undefined;
|
||||
circular?: boolean | undefined;
|
||||
size?: number;
|
||||
}
|
||||
@@ -13,6 +15,7 @@ interface IconButtonProps {
|
||||
export default function IconButton({
|
||||
name,
|
||||
onPress,
|
||||
title,
|
||||
circular,
|
||||
size
|
||||
} : IconButtonProps) : React.JSX.Element {
|
||||
@@ -37,7 +40,11 @@ export default function IconButton({
|
||||
large
|
||||
name={name}
|
||||
color={"$color"}
|
||||
/>
|
||||
/>
|
||||
|
||||
{ title && (
|
||||
<Text>{ title }</Text>
|
||||
)}
|
||||
</Square>
|
||||
</TouchableOpacity>
|
||||
</Theme>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function IconCard({
|
||||
<Icon color={getTokens().color.purpleDark.val} name={name} large />
|
||||
</Card.Header>
|
||||
<Card.Footer padded>
|
||||
<H4 color={getTokens().color.purpleDark.val}>{ caption ?? "" }</H4>
|
||||
<H4 color={getTokens().color.purpleDark}>{ caption ?? "" }</H4>
|
||||
</Card.Footer>
|
||||
<Card.Background backgroundColor={getTokens().color.telemagenta}>
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export function H4(props: TamaguiTextProps): React.JSX.Element {
|
||||
<TamaguiH4
|
||||
fontWeight={800}
|
||||
marginVertical={3}
|
||||
{...props}
|
||||
>
|
||||
{ props.children }
|
||||
</TamaguiH4>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function ItemDetail({
|
||||
|
||||
switch (item.Type) {
|
||||
case "Audio": {
|
||||
options = TrackOptions({ item, navigation, isNested });
|
||||
options = TrackOptions({ track: item, navigation, isNested });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,11 @@ export default function ItemDetail({
|
||||
|
||||
return (
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||
<YStack alignItems="center" flex={1}>
|
||||
<YStack
|
||||
alignItems="center"
|
||||
flex={1}
|
||||
marginTop={"$4"}
|
||||
>
|
||||
|
||||
<XStack
|
||||
justifyContent="center"
|
||||
@@ -73,51 +77,58 @@ export default function ItemDetail({
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
<YStack
|
||||
marginLeft={"$0.5"}
|
||||
alignContent="center"
|
||||
justifyContent="center"
|
||||
flex={2}
|
||||
>
|
||||
<Text textAlign="center" bold fontSize={"$6"}>
|
||||
{ item.Name ?? "Untitled Track" }
|
||||
</Text>
|
||||
{/* Item Name, Artist, Album, and Favorite Button */}
|
||||
<XStack maxWidth={width / 1.5}>
|
||||
<YStack
|
||||
marginLeft={"$0.5"}
|
||||
alignItems="flex-start"
|
||||
justifyContent="flex-start"
|
||||
flex={3}
|
||||
>
|
||||
<Text textAlign="center" bold fontSize={"$6"}>
|
||||
{ item.Name ?? "Untitled Track" }
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
textAlign="center"
|
||||
fontSize={"$6"}
|
||||
color={getTokens().color.telemagenta}
|
||||
onPress={() => {
|
||||
if (item.ArtistItems) {
|
||||
<Text
|
||||
textAlign="center"
|
||||
fontSize={"$6"}
|
||||
color={getTokens().color.telemagenta}
|
||||
onPress={() => {
|
||||
if (item.ArtistItems) {
|
||||
|
||||
if (isNested)
|
||||
navigation.getParent()!.goBack();
|
||||
|
||||
navigation.goBack();
|
||||
navigation.push("Artist", {
|
||||
artist: item.ArtistItems[0]
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{ item.Artists?.join(", ") ?? "Unknown Artist"}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
textAlign="center"
|
||||
fontSize={"$6"}
|
||||
color={"$borderColor"}
|
||||
>
|
||||
{ item.Album ?? "" }
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
if (isNested)
|
||||
navigation.getParent()!.goBack();
|
||||
|
||||
navigation.goBack();
|
||||
navigation.push("Artist", {
|
||||
artist: item.ArtistItems[0]
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{ item.Artists?.join(", ") ?? "Unknown Artist"}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
textAlign="center"
|
||||
fontSize={"$6"}
|
||||
color={"$borderColor"}
|
||||
<YStack
|
||||
flex={1}
|
||||
alignContent="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{ item.Album ?? "" }
|
||||
</Text>
|
||||
|
||||
<Spacer />
|
||||
|
||||
<FavoriteButton item={item} />
|
||||
<FavoriteButton item={item} />
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
<Spacer />
|
||||
<Spacer />
|
||||
|
||||
{ options ?? <View /> }
|
||||
</YStack>
|
||||
{ options ?? <View /> }
|
||||
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,62 +1,167 @@
|
||||
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 { XStack } from "tamagui";
|
||||
import { getToken, getTokens, ListItem, Separator, Spacer, Spinner, XStack, YGroup, YStack } from "tamagui";
|
||||
import { QueuingType } from "../../../enums/queuing-type";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import IconButton from "../../../components/Global/helpers/icon-button";
|
||||
import { Text } from "../../../components/Global/helpers/text";
|
||||
import { useUserPlaylists } from "../../../api/queries/playlist";
|
||||
import React from "react";
|
||||
import BlurhashedImage from "../../../components/Global/components/blurhashed-image";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AddToPlaylistMutation } from "../types";
|
||||
import { addToPlaylist } from "../../../api/mutations/functions/playlists";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
import { queryClient } from "../../../constants/query-client";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
|
||||
interface TrackOptionsProps {
|
||||
track: BaseItemDto;
|
||||
navigation: NativeStackNavigationProp<StackParamList>;
|
||||
|
||||
/**
|
||||
* Whether this is nested in the player modal
|
||||
*/
|
||||
isNested: boolean | undefined;
|
||||
}
|
||||
|
||||
export default function TrackOptions({
|
||||
item,
|
||||
track,
|
||||
navigation,
|
||||
isNested
|
||||
} : {
|
||||
item: BaseItemDto,
|
||||
navigation: NativeStackNavigationProp<StackParamList>,
|
||||
isNested: boolean | undefined// Whether this is nested in the player modal
|
||||
}) : React.JSX.Element {
|
||||
} : TrackOptionsProps) : React.JSX.Element {
|
||||
|
||||
const { data: album, isSuccess } = useItem(item.AlbumId ?? "");
|
||||
const { data: album, isSuccess: albumFetchSuccess } = useItem(track.AlbumId ?? "");
|
||||
|
||||
const { data: playlists, isPending : playlistsFetchPending, isSuccess: playlistsFetchSuccess } = useUserPlaylists();
|
||||
|
||||
const { useAddToQueue } = usePlayerContext();
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
return (
|
||||
<XStack alignContent="flex-end" justifyContent="space-between">
|
||||
{ isSuccess && (
|
||||
<Icon
|
||||
name="music-box"
|
||||
<YStack width={width}>
|
||||
|
||||
<XStack justifyContent="space-evenly">
|
||||
{ albumFetchSuccess ? (
|
||||
<IconButton
|
||||
name="music-box"
|
||||
title="Go to Album"
|
||||
onPress={() => {
|
||||
|
||||
if (isNested)
|
||||
navigation.getParent()!.goBack();
|
||||
|
||||
navigation.goBack();
|
||||
navigation.push("Album", {
|
||||
album
|
||||
});
|
||||
}}
|
||||
size={width / 5}
|
||||
/>
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
circular
|
||||
name="table-column-plus-before"
|
||||
title="Play Next"
|
||||
onPress={() => {
|
||||
|
||||
if (isNested)
|
||||
navigation.getParent()!.goBack();
|
||||
|
||||
navigation.goBack();
|
||||
navigation.push("Album", {
|
||||
album
|
||||
});
|
||||
useAddToQueue.mutate({
|
||||
track: track,
|
||||
queuingType: QueuingType.PlayingNext
|
||||
})
|
||||
}}
|
||||
size={width / 5}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
circular
|
||||
name="table-column-plus-after"
|
||||
title="Queue"
|
||||
onPress={() => {
|
||||
useAddToQueue.mutate({
|
||||
track: track
|
||||
})
|
||||
}}
|
||||
size={width / 5}
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
<Spacer />
|
||||
|
||||
{ playlistsFetchPending && (
|
||||
<Spinner />
|
||||
)}
|
||||
|
||||
<Icon
|
||||
name="table-column-plus-before"
|
||||
onPress={() => {
|
||||
useAddToQueue.mutate({
|
||||
track: item,
|
||||
queuingType: QueuingType.PlayingNext
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{ playlistsFetchSuccess && (
|
||||
<>
|
||||
<Text
|
||||
bold
|
||||
fontSize={"$6"}
|
||||
>
|
||||
Add to Playlist
|
||||
</Text>
|
||||
|
||||
<Icon
|
||||
name="table-column-plus-after"
|
||||
onPress={() => {
|
||||
useAddToQueue.mutate({
|
||||
track: item
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</XStack>
|
||||
<YGroup separator={(<Separator />)}>
|
||||
{ playlists.map(playlist => {
|
||||
|
||||
const useAddToPlaylist = useMutation({
|
||||
mutationFn: ({ track, playlist }: AddToPlaylistMutation) => {
|
||||
return addToPlaylist(track, playlist)
|
||||
},
|
||||
onSuccess: (data, { playlist }) => {
|
||||
trigger("notificationSuccess")
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.ItemTracks, playlist.Id!, false],
|
||||
exact: true
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
trigger("notificationError")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<YGroup.Item>
|
||||
<ListItem hoverTheme onPress={() => {
|
||||
useAddToPlaylist.mutate({
|
||||
track,
|
||||
playlist
|
||||
})
|
||||
}}>
|
||||
<XStack alignItems="center">
|
||||
<YStack flex={1}>
|
||||
|
||||
<BlurhashedImage
|
||||
cornered
|
||||
item={playlist}
|
||||
width={width / 6}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
<YStack
|
||||
alignItems="flex-start"
|
||||
flex={4}
|
||||
>
|
||||
<Text bold fontSize={"$6"}>{playlist.Name ?? "Untitled Playlist"}</Text>
|
||||
|
||||
<Text color={getTokens().color.amethyst}>{`${playlist.ChildCount ?? 0} tracks`}</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</ListItem>
|
||||
</YGroup.Item>
|
||||
)
|
||||
})}
|
||||
</YGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
export interface AddToPlaylistMutation {
|
||||
track: BaseItemDto;
|
||||
playlist: BaseItemDto;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useFavoritePlaylists } from "../../api/queries/favorites";
|
||||
import { FlatList, RefreshControl } from "react-native-gesture-handler";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import { ItemCard } from "../Global/components/item-card";
|
||||
import { PlaylistsProps } from "../types";
|
||||
|
||||
export default function Playlists({ navigation }: PlaylistsProps) : React.JSX.Element {
|
||||
|
||||
const { data: playlists, isPending, refetch } = useFavoritePlaylists();
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
numColumns={2}
|
||||
data={playlists}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPending}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
}
|
||||
renderItem={({ index, item: playlist }) => {
|
||||
return (
|
||||
<ItemCard
|
||||
item={playlist}
|
||||
caption={playlist.Name ?? "Untitled Playlist"}
|
||||
onPress={() => {
|
||||
navigation.push("Playlist", { playlist })
|
||||
}}
|
||||
width={width / 2.1}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { StackParamList } from "../types";
|
||||
import Playlists from "./component";
|
||||
import React from "react";
|
||||
|
||||
export default function PlaylistsScreen(
|
||||
props: NativeStackScreenProps<StackParamList, 'Playlists'>
|
||||
) : React.JSX.Element {
|
||||
return (
|
||||
<Playlists {...props} />
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
@@ -8,6 +7,7 @@ import { QueryKeys } from "../../enums/query-keys";
|
||||
import { fetchSearchResults } from "../../api/queries/functions/search";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlatList, useColorScheme } from "react-native";
|
||||
import { Text } from "../Global/helpers/text";
|
||||
|
||||
export default function Search({
|
||||
navigation
|
||||
@@ -42,7 +42,6 @@ export default function Search({
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
progressViewOffset={10}
|
||||
indicatorStyle={isDarkMode ? 'white' : 'default'}
|
||||
ListHeaderComponent={(
|
||||
<Input
|
||||
placeholder="Seek and ye shall find..."
|
||||
@@ -50,6 +49,9 @@ export default function Search({
|
||||
value={searchString}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={(
|
||||
<Text>No results found</Text>
|
||||
)}
|
||||
data={items}
|
||||
refreshing={isFetching}
|
||||
renderItem={({ index, item }) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StackParamList } from "@/components/types"
|
||||
import { StackParamList } from "../../../components/types"
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack"
|
||||
import DevTools from "../helpers/dev-tools"
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ export type ArtistsProps = NativeStackScreenProps<StackParamList, "Artists">;
|
||||
|
||||
export type AlbumsProps = NativeStackScreenProps<StackParamList, "Albums">;
|
||||
|
||||
export type PlaylistsProps = NativeStackScreenProps<StackParamList, "Playlists">;
|
||||
|
||||
export type TracksProps = NativeStackScreenProps<StackParamList, "Tracks">;
|
||||
|
||||
export type GenresProps = NativeStackScreenProps<StackParamList, "Genres">;
|
||||
|
||||
@@ -6,4 +6,4 @@ export const queryClient = new QueryClient({
|
||||
gcTime: (1000 * 60 * 24 * 24) * 5 // 5 days
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -38,4 +38,5 @@ export enum QueryKeys {
|
||||
Item = "Item",
|
||||
Search = "Search",
|
||||
SearchSuggestions = "SearchSuggestions",
|
||||
FavoritePlaylists = "FavoritePlaylists",
|
||||
}
|
||||
@@ -40,7 +40,7 @@ platform :ios do
|
||||
)
|
||||
|
||||
increment_version_number(
|
||||
version_number: ENV['VERISON_NUMBER'],
|
||||
version_number: ENV['VERSION_NUMBER'],
|
||||
xcodeproj: "Jellify.xcodeproj"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"start": "react-native start",
|
||||
"test": "jest",
|
||||
"pod:install": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=0 pod install",
|
||||
"pod:install-new-arch": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=1 pod install",
|
||||
"pod:clean": "cd ios && pod deintegrate"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JellifyTrack } from "@/types/JellifyTrack";
|
||||
import { JellifyTrack } from "../types/JellifyTrack";
|
||||
import { QueuingType } from "../enums/queuing-type";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 690 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 776 KiB After Width: | Height: | Size: 576 KiB |